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/serializers.py b/backend/api/serializers.py index c7165a7..c60d2df 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/backend/api/urls.py b/backend/api/urls.py index b6ac7ba..b6a9346 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 89cb8fb..a9d37a7 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -658,6 +658,86 @@ def chapters_list(request): '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 @api_view(['GET']) @permission_classes([IsAuthenticated]) diff --git a/frontend/app/settings/page.jsx b/frontend/app/settings/page.jsx new file mode 100644 index 0000000..4d8be13 --- /dev/null +++ b/frontend/app/settings/page.jsx @@ -0,0 +1,262 @@ +'use client'; + +import axios from 'axios'; +import { useRouter } from 'next/navigation'; +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 { useAuthStore } from '@/lib/auth-store'; +import { apiURL } from '@/lib/config'; + +export default function SettingsPage() { + 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 response = await axios.get(`${apiURL}/auth/profile/`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + setUserWithDeletion(response.data); + + if (response.data.deletion_requested_at) { + calculateDaysRemaining(response.data.deletion_requested_at); + } + } catch (err) { + setError('Failed to load user data'); + } + }; + + 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 response = await axios.post( + `${apiURL}/auth/request-deletion/`, + { password }, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + + alert(response.data.message); + setPassword(''); + await fetchUserData(); + } catch (err) { + setError(err.response?.data?.error || 'Failed to request deletion'); + } finally { + setLoading(false); + } + }; + + const handleCancelDeletion = async () => { + setLoading(true); + setError(''); + + try { + const response = await axios.post( + `${apiURL}/auth/cancel-deletion/`, + {}, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + + alert(response.data.message); + await fetchUserData(); + } catch (err) { + setError(err.response?.data?.error || 'Failed to cancel deletion'); + } finally { + setLoading(false); + } + }; + + const handleExportData = async () => { + try { + const response = await axios.get(`${apiURL}/auth/export-data/`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + 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(); + window.URL.revokeObjectURL(url); + } catch (err) { + alert('Failed to export data'); + } + }; + + if (!user || !userWithDeletion) { + return
Loading...
; + } + + return ( +
+
+

Account Settings

+ +
+

Email: {user.email}

+
+ + {/* Deletion Warning Alert */} + {userWithDeletion.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 in JSON format +

+ +
+ + {/* Delete Account */} + {!userWithDeletion.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(''); + setError(''); + }} + > + Cancel + + + {loading + ? 'Processing...' + : 'Delete Account'} + + +
+
+
+ )} +
+
+ ); +}