From 8e05d3a3e70cfbf9c8d1148050709120bb9eb918 Mon Sep 17 00:00:00 2001 From: Jayme Menchaca-Higgins Date: Thu, 15 Jan 2026 15:46:55 -0600 Subject: [PATCH 1/2] added account deletion backend and frontend --- .../commands/delete_expired_accounts.py | 34 +++ .../0013_customuser_deletion_requested_at.py | 18 ++ backend/api/models.py | 5 + backend/api/urls.py | 5 +- backend/api/views.py | 81 ++++++ frontend/app/settings/page.jsx | 244 ++++++++++++++++++ 6 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 backend/api/management/commands/delete_expired_accounts.py create mode 100644 backend/api/migrations/0013_customuser_deletion_requested_at.py create mode 100644 frontend/app/settings/page.jsx diff --git a/backend/api/management/commands/delete_expired_accounts.py b/backend/api/management/commands/delete_expired_accounts.py new file mode 100644 index 0000000..4e42e3e --- /dev/null +++ b/backend/api/management/commands/delete_expired_accounts.py @@ -0,0 +1,34 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone +from datetime import timedelta +from api.models import CustomUser + + +class Command(BaseCommand): + help = 'Delete user accounts that requested deletion 7+ days ago' + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(days=7) + + users_to_delete = CustomUser.objects.filter( + deletion_requested_at__isnull=False, + deletion_requested_at__lte=cutoff + ) + + count = users_to_delete.count() + + if count == 0: + self.stdout.write(self.style.SUCCESS('No accounts to delete')) + return + + # Log emails before deletion (for audit trail) + for user in users_to_delete: + self.stdout.write(f'Deleting account: {user.email}') + # TODO: Send final deletion confirmation email here + + # Perform CASCADE deletion + users_to_delete.delete() + + self.stdout.write( + self.style.SUCCESS(f'Successfully deleted {count} account(s)') + ) \ No newline at end of file diff --git a/backend/api/migrations/0013_customuser_deletion_requested_at.py b/backend/api/migrations/0013_customuser_deletion_requested_at.py new file mode 100644 index 0000000..dbec77f --- /dev/null +++ b/backend/api/migrations/0013_customuser_deletion_requested_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1 on 2026-01-15 20:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_populate_chapter_counts'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='deletion_requested_at', + field=models.DateTimeField(blank=True, help_text='When user requested account deletion', null=True), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 9372c76..8d32786 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -48,6 +48,11 @@ class CustomUser(AbstractBaseUser, PermissionsMixin): is_superuser = models.BooleanField(default=False) last_login = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(default=timezone.now) + deletion_requested_at = models.DateTimeField( + null=True, + blank=True, + help_text="When user requested account deletion" + ) objects = CustomUserManager() diff --git a/backend/api/urls.py b/backend/api/urls.py index 5210226..75810c4 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -21,7 +21,10 @@ # Profile management path('profile/', views.UserProfileDetailView.as_view(), name='profile-detail'), path('profile/avatar/', views.upload_avatar, name='upload-avatar'), - + # Account deletion + path('auth/request-deletion/', views.request_account_deletion, name='request-deletion'), + path('auth/cancel-deletion/', views.cancel_account_deletion, name='cancel-deletion'), + path('auth/export-data/', views.export_user_data, name='export-data'), # Content selection endpoints path('translations/', views.translations_list, name='translations-list'), path('books/', views.books_list, name='books-list'), diff --git a/backend/api/views.py b/backend/api/views.py index e15591d..8144fae 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -655,3 +655,84 @@ def chapters_list(request): return Response({ 'chapters': serializer.data }, status=status.HTTP_200_OK) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def request_account_deletion(request): + """Start 7-day deletion countdown after password confirmation.""" + password = request.data.get('password') + + if not password: + return Response({'error': 'Password is required'}, status=status.HTTP_400_BAD_REQUEST) + + if not request.user.check_password(password): + return Response({'error': 'Invalid password'}, status=status.HTTP_400_BAD_REQUEST) + + request.user.deletion_requested_at = timezone.now() + request.user.save() + + # TODO: Send email notification (template creation deferred) + + deletion_date = timezone.now() + timedelta(days=7) + + return Response({ + 'message': 'Account deletion scheduled for 7 days from now', + 'deletion_date': deletion_date.isoformat(), + 'can_cancel': True + }, status=status.HTTP_200_OK) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def cancel_account_deletion(request): + """Cancel pending account deletion.""" + if not request.user.deletion_requested_at: + return Response( + {'error': 'No pending deletion request'}, + status=status.HTTP_400_BAD_REQUEST + ) + + request.user.deletion_requested_at = None + request.user.save() + + # TODO: Send cancellation confirmation email + + return Response({ + 'message': 'Account deletion cancelled successfully' + }, status=status.HTTP_200_OK) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='10/h', method='GET') +def export_user_data(request): + """Export all user data as JSON for GDPR compliance.""" + user = request.user + + data = { + 'user': { + 'email': user.email, + 'created_at': user.created_at.isoformat(), + 'email_verified': user.email_verified, + }, + 'profile': {}, + 'habits': list(user.habits.values('habit', 'frequency', 'purpose', 'time', 'location', 'skipped')), + 'study_notes': list(user.study_notes.values('id', 'verse_reference', 'content', 'created_at', 'updated_at')), + 'recent_verses': list(user.recent_verses.values('book__name', 'chapter', 'last_accessed')), + 'decks': list(user.decks.values('name', 'is_public', 'created_at')), + } + + # Include profile if exists + try: + profile = user.profile + data['profile'] = { + 'display_name': profile.display_name, + 'default_translation': profile.default_translation.code if profile.default_translation else None, + 'review_goal_per_day': profile.review_goal_per_day, + } + except: + pass + + response = Response(data, status=status.HTTP_200_OK) + response['Content-Disposition'] = 'attachment; filename="user_data.json"' + return response diff --git a/frontend/app/settings/page.jsx b/frontend/app/settings/page.jsx new file mode 100644 index 0000000..b8374f3 --- /dev/null +++ b/frontend/app/settings/page.jsx @@ -0,0 +1,244 @@ +'use client'; + +import axios from 'axios'; +import { useState, useEffect } from 'react'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { apiURL } from '@/lib/config'; + +export default function SettingsPage() { + const [user, setUser] = useState(null); + const [password, setPassword] = useState(''); + const [deletionDate, setDeletionDate] = useState(null); + const [daysRemaining, setDaysRemaining] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const fetchUserData = async () => { + try { + const token = localStorage.getItem('access_token'); + const response = await axios.get(`${apiURL}/auth/profile/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + setUser(response.data); + + if (response.data.deletion_requested_at) { + calculateDaysRemaining(response.data.deletion_requested_at); + } + } catch (err) { + setError('Failed to load user data'); + } + }; + + useEffect(() => { + fetchUserData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const calculateDaysRemaining = (deletionRequestedAt) => { + const requestDate = new Date(deletionRequestedAt); + const deleteDate = new Date( + requestDate.getTime() + 7 * 24 * 60 * 60 * 1000 + ); + const now = new Date(); + const days = Math.ceil((deleteDate - now) / (1000 * 60 * 60 * 24)); + + setDeletionDate(deleteDate.toLocaleDateString()); + setDaysRemaining(days > 0 ? days : 0); + }; + + const handleRequestDeletion = async () => { + if (!password) { + setError('Password is required'); + return; + } + + setLoading(true); + setError(''); + + try { + const token = localStorage.getItem('access_token'); + const response = await axios.post( + `${apiURL}/auth/request-deletion/`, + { password }, + { headers: { Authorization: `Bearer ${token}` } } + ); + + alert(response.data.message); + setPassword(''); + fetchUserData(); + } catch (err) { + setError(err.response?.data?.error || 'Failed to request deletion'); + } finally { + setLoading(false); + } + }; + + const handleCancelDeletion = async () => { + setLoading(true); + try { + const token = localStorage.getItem('access_token'); + const response = await axios.post( + `${apiURL}/auth/cancel-deletion/`, + {}, + { headers: { Authorization: `Bearer ${token}` } } + ); + + alert(response.data.message); + fetchUserData(); + } catch (err) { + setError(err.response?.data?.error || 'Failed to cancel deletion'); + } finally { + setLoading(false); + } + }; + + const handleExportData = async () => { + try { + const token = localStorage.getItem('access_token'); + const response = await axios.get(`${apiURL}/auth/export-data/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + const blob = new Blob([JSON.stringify(response.data, null, 2)], { + type: 'application/json', + }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'user_data.json'; + a.click(); + } catch (err) { + alert('Failed to export data'); + } + }; + + if (!user) return
Loading...
; + + return ( +
+
+

Account Settings

+ + {/* Deletion Warning Alert */} + {user.deletion_requested_at && ( + + ⚠️ Account Deletion Scheduled + +

+ Your account will be permanently deleted in{' '} + {daysRemaining} days ( + {deletionDate}) +

+ +
+
+ )} + + {/* Export Data */} + +

+ Export Your Data +

+

+ Download all your account data +

+ +
+ + {/* Delete Account */} + {!user.deletion_requested_at && ( + +

+ Delete Account +

+

+ Permanently delete your account and all associated + data. You'll have 7 days to cancel. +

+ + + + + + + + + Are you absolutely sure? + + + This will schedule your account for + deletion in 7 days. All your habits, + notes, and progress will be permanently + deleted. + + + +
+ + + setPassword(e.target.value) + } + placeholder="Enter password" + /> + {error && ( +

+ {error} +

+ )} +
+ + + setPassword('')} + > + Cancel + + + {loading + ? 'Processing...' + : 'Delete Account'} + + +
+
+
+ )} +
+
+ ); +} From 78bd666497392c466c74c60c21ff8033dbd13b90 Mon Sep 17 00:00:00 2001 From: Jayme Menchaca-Higgins Date: Thu, 22 Jan 2026 15:25:05 -0600 Subject: [PATCH 2/2] Add account deletion with 7-day grace period - Add deletion_requested_at field to CustomUser model - Implement request/cancel deletion endpoints with password confirmation - Add user data export endpoint (GDPR compliance) - Create management command to delete expired accounts - Build frontend settings page with deletion UI and countdown - Update UserSerializer to include deletion_requested_at - All foreign keys use CASCADE for proper cleanup - Scheduling setup deferred to infrastructure ticket --- backend/api/serializers.py | 4 +-- frontend/app/settings/page.jsx | 66 +++++++++++++++++++++------------- 2 files changed, 44 insertions(+), 26 deletions(-) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 8e4cdbc..3d53bf3 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -55,8 +55,8 @@ def validate(self, attrs): class UserSerializer(serializers.ModelSerializer): class Meta: model = CustomUser - fields = ('id', 'email', 'is_active', 'created_at', 'last_login') - read_only_fields = ('id', 'created_at', 'last_login') + fields = ('id', 'email', 'is_active', 'created_at', 'last_login', 'deletion_requested_at') + read_only_fields = ('id', 'created_at', 'last_login', 'deletion_requested_at') class HabitSerializer(serializers.ModelSerializer): diff --git a/frontend/app/settings/page.jsx b/frontend/app/settings/page.jsx index b8374f3..4d8be13 100644 --- a/frontend/app/settings/page.jsx +++ b/frontend/app/settings/page.jsx @@ -1,6 +1,7 @@ 'use client'; import axios from 'axios'; +import { useRouter } from 'next/navigation'; import { useState, useEffect } from 'react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; @@ -18,23 +19,36 @@ import { import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { useAuthStore } from '@/lib/auth-store'; import { apiURL } from '@/lib/config'; export default function SettingsPage() { - const [user, setUser] = useState(null); + const { user, accessToken, isAuthenticated } = useAuthStore(); + const router = useRouter(); const [password, setPassword] = useState(''); const [deletionDate, setDeletionDate] = useState(null); const [daysRemaining, setDaysRemaining] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const [userWithDeletion, setUserWithDeletion] = useState(null); + + useEffect(() => { + if (!isAuthenticated) { + router.push('/login'); + return; + } + if (accessToken) { + fetchUserData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accessToken, isAuthenticated]); const fetchUserData = async () => { try { - const token = localStorage.getItem('access_token'); const response = await axios.get(`${apiURL}/auth/profile/`, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${accessToken}` }, }); - setUser(response.data); + setUserWithDeletion(response.data); if (response.data.deletion_requested_at) { calculateDaysRemaining(response.data.deletion_requested_at); @@ -44,11 +58,6 @@ export default function SettingsPage() { } }; - useEffect(() => { - fetchUserData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const calculateDaysRemaining = (deletionRequestedAt) => { const requestDate = new Date(deletionRequestedAt); const deleteDate = new Date( @@ -71,16 +80,15 @@ export default function SettingsPage() { setError(''); try { - const token = localStorage.getItem('access_token'); const response = await axios.post( `${apiURL}/auth/request-deletion/`, { password }, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${accessToken}` } } ); alert(response.data.message); setPassword(''); - fetchUserData(); + await fetchUserData(); } catch (err) { setError(err.response?.data?.error || 'Failed to request deletion'); } finally { @@ -90,16 +98,17 @@ export default function SettingsPage() { const handleCancelDeletion = async () => { setLoading(true); + setError(''); + try { - const token = localStorage.getItem('access_token'); const response = await axios.post( `${apiURL}/auth/cancel-deletion/`, {}, - { headers: { Authorization: `Bearer ${token}` } } + { headers: { Authorization: `Bearer ${accessToken}` } } ); alert(response.data.message); - fetchUserData(); + await fetchUserData(); } catch (err) { setError(err.response?.data?.error || 'Failed to cancel deletion'); } finally { @@ -109,9 +118,8 @@ export default function SettingsPage() { const handleExportData = async () => { try { - const token = localStorage.getItem('access_token'); const response = await axios.get(`${apiURL}/auth/export-data/`, { - headers: { Authorization: `Bearer ${token}` }, + headers: { Authorization: `Bearer ${accessToken}` }, }); const blob = new Blob([JSON.stringify(response.data, null, 2)], { @@ -122,24 +130,31 @@ export default function SettingsPage() { a.href = url; a.download = 'user_data.json'; a.click(); + window.URL.revokeObjectURL(url); } catch (err) { alert('Failed to export data'); } }; - if (!user) return
Loading...
; + if (!user || !userWithDeletion) { + return
Loading...
; + } return (

Account Settings

+
+

Email: {user.email}

+
+ {/* Deletion Warning Alert */} - {user.deletion_requested_at && ( + {userWithDeletion.deletion_requested_at && ( ⚠️ Account Deletion Scheduled -

+

Your account will be permanently deleted in{' '} {daysRemaining} days ( {deletionDate}) @@ -150,7 +165,7 @@ export default function SettingsPage() { className="mt-2" disabled={loading} > - Cancel Deletion + {loading ? 'Canceling...' : 'Cancel Deletion'} @@ -162,7 +177,7 @@ export default function SettingsPage() { Export Your Data

- Download all your account data + Download all your account data in JSON format