From 3046103d483f86a2c9797c2c0157cfc065ee0295 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Feb 2026 03:32:42 +0100 Subject: [PATCH] added delete option to user settings and updated privacy --- backend/api/migrations/0013_user_language.py | 18 +++ backend/api/models.py | 5 + backend/api/serializers.py | 2 +- backend/api/views.py | 45 +++++- frontend/package-lock.json | 89 ++++++++++- frontend/package.json | 3 + frontend/src/App.tsx | 27 ++-- frontend/src/components/Footer.tsx | 36 +++++ frontend/src/i18n.ts | 109 ++++++++++++++ frontend/src/pages/MainPage.tsx | 37 ++--- frontend/src/pages/Privacy.tsx | 130 ++++++++++++++++ frontend/src/pages/Settings.tsx | 148 +++++++++++++++---- 12 files changed, 588 insertions(+), 61 deletions(-) create mode 100644 backend/api/migrations/0013_user_language.py create mode 100644 frontend/src/components/Footer.tsx create mode 100644 frontend/src/i18n.ts create mode 100644 frontend/src/pages/Privacy.tsx diff --git a/backend/api/migrations/0013_user_language.py b/backend/api/migrations/0013_user_language.py new file mode 100644 index 0000000..3747a0f --- /dev/null +++ b/backend/api/migrations/0013_user_language.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-17 02:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0012_user_following'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='language', + field=models.CharField(choices=[('en', 'English'), ('de', 'Deutsch')], default='en', max_length=2), + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 168e8e8..99f37c2 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -19,7 +19,12 @@ class User(AbstractUser): ('cyberpunk', 'Cyberpunk'), ('forest', 'Forest Dark'), ] + LANGUAGE_CHOICES = [ + ('en', 'English'), + ('de', 'Deutsch'), + ] theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='mocha') + language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default='en') avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) pending_email = models.EmailField(max_length=254, blank=True, null=True) email_confirmation_code = models.CharField(max_length=6, blank=True, null=True) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 878680f..e1b3e1b 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -9,7 +9,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'username', 'email', 'theme', 'avatar', 'following_count', 'followers_count', 'is_following'] + fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following'] def get_avatar(self, obj): if obj.avatar: diff --git a/backend/api/views.py b/backend/api/views.py index fd23395..ab33026 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -242,14 +242,23 @@ class UserViewSet(viewsets.ModelViewSet): return Response(serializer.data) @action(detail=False, methods=['patch']) - def update_theme(self, request): + def update_preferences(self, request): user = request.user theme = request.data.get('theme') + language = request.data.get('language') + + updated = False if theme in dict(User.THEME_CHOICES): user.theme = theme + updated = True + if language in dict(User.LANGUAGE_CHOICES): + user.language = language + updated = True + + if updated: user.save() - return Response({'status': 'theme updated', 'theme': theme}) - return Response({'error': 'Invalid theme'}, status=status.HTTP_400_BAD_REQUEST) + return Response({'status': 'preferences updated', 'theme': user.theme, 'language': user.language}) + return Response({'error': 'No valid updates provided'}, status=status.HTTP_400_BAD_REQUEST) @action(detail=False, methods=['post']) def update_avatar(self, request): @@ -380,6 +389,36 @@ class UserViewSet(viewsets.ModelViewSet): user.save() return Response({'status': 'Password reset successful!'}) + @action(detail=False, methods=['post']) + @method_decorator(ratelimit(key='user', rate='3/h', method='POST', block=True)) + def request_account_deletion(self, request): + import secrets + import string + code = ''.join(secrets.choice(string.digits) for _ in range(6)) + + user = request.user + user.email_confirmation_code = code + user.save() + + send_mail( + 'Confirm Account Deletion - Holy Flavors Archive', + f'Hi {user.username},\n\nYou requested to delete your account. This action is permanent.\n\nYour deletion code is: {code}', + 'noreply@holyflavors.com', + [user.email], + fail_silently=False, + ) + return Response({'status': 'Code sent to your email'}) + + @action(detail=False, methods=['post']) + def confirm_account_deletion(self, request): + user = request.user + code = request.data.get('code') + if not user.email_confirmation_code or code != user.email_confirmation_code: + return Response({'error': 'Invalid or expired code'}, status=status.HTTP_400_BAD_REQUEST) + + user.delete() + return Response({'status': 'Account deleted'}) + @action(detail=False, methods=['get']) def dashboard(self, request): user = request.user diff --git a/frontend/package-lock.json b/frontend/package-lock.json index abf346f..20b40e7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,8 +14,11 @@ "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", "axios": "^1.13.5", + "i18next": "^24.2.2", + "i18next-browser-languagedetector": "^8.0.2", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^15.4.0", "react-router-dom": "^7.13.0" }, "devDependencies": { @@ -3197,6 +3200,55 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "24.2.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.3.tgz", + "integrity": "sha512-lfbf80OzkocvX7nmZtu7nSTNbrTYR52sLWxPtlXX1zAhVw8WEnFk4puUkCR4B1dNQwbSpEHHHemcZu//7EcB7A==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3748,6 +3800,32 @@ "react": "^19.2.4" } }, + "node_modules/react-i18next": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz", + "integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.4.0", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", @@ -4047,7 +4125,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4204,6 +4282,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 29de294..f5d2d4b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,8 +16,11 @@ "@mui/icons-material": "^7.3.8", "@mui/material": "^7.3.8", "axios": "^1.13.5", + "i18next": "^24.2.2", + "i18next-browser-languagedetector": "^8.0.2", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^15.4.0", "react-router-dom": "^7.13.0" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f427c52..13e9206 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -33,6 +33,9 @@ import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; import { getTheme, type CatppuccinTheme } from './theme'; import api from './api'; +import './i18n'; +import { useTranslation } from 'react-i18next'; +import Footer from './components/Footer'; // Code splitting const MainPage = lazy(() => import('./pages/MainPage')); @@ -42,6 +45,7 @@ const Dashboard = lazy(() => import('./pages/Dashboard')); const PublicProfile = lazy(() => import('./pages/PublicProfile')); const CommunityFeed = lazy(() => import('./pages/CommunityFeed')); const About = lazy(() => import('./pages/About')); +const Privacy = lazy(() => import('./pages/Privacy')); const Login = lazy(() => import('./pages/Login')); const Settings = lazy(() => import('./pages/Settings')); @@ -159,6 +163,7 @@ const GlobalSearch = () => { }; const App: React.FC = () => { + const { i18n, t } = useTranslation(); const [themeName, setThemeName] = useState((localStorage.getItem('theme') as CatppuccinTheme) || 'mocha'); const [anchorEl, setAnchorEl] = useState(null); const [catAnchorEl, setCatAnchorEl] = useState(null); @@ -181,6 +186,7 @@ const App: React.FC = () => { if (userRes.data && userRes.data.username) { setUser(userRes.data); if (userRes.data.theme) setThemeName(userRes.data.theme); + if (userRes.data.language) i18n.changeLanguage(userRes.data.language); // Also fetch following list for the mobile sidebar try { @@ -223,6 +229,7 @@ const App: React.FC = () => { localStorage.removeItem('token'); setUser(null); setFollowing([]); + i18n.changeLanguage(navigator.language.split('-')[0] || 'en'); window.location.href = '/'; }; @@ -243,19 +250,19 @@ const App: React.FC = () => { setDrawerOpen(false)}> - + setDrawerOpen(false)}> - + setDrawerOpen(false)}> - + @@ -285,12 +292,12 @@ const App: React.FC = () => { <> setDrawerOpen(false)}> - + setDrawerOpen(false)}> - + @@ -315,14 +322,14 @@ const App: React.FC = () => { - + ) : ( setDrawerOpen(false)}> - + )} @@ -356,7 +363,7 @@ const App: React.FC = () => { to="/community" sx={{ fontWeight: 'bold', textTransform: 'none', fontSize: '1rem', mr: 2 }} > - Community + {t('nav.community')} {feedRatings.length === 0 ? ( - Your feed is a bit quiet - Follow more users to see their activity here! - + {t('home.quietFeed')} + {t('home.followMore')} + ) : ( @@ -300,17 +302,17 @@ const MainPage: React.FC = () => { }} > - The Ultimate Holy Archive + {t('hero.title')} - Browse every flavor ever released, discover new favorites, and share your ratings with the community. + {t('hero.subtitle')} @@ -325,7 +327,7 @@ const MainPage: React.FC = () => { - Hall of Fame + {t('home.hallOfFame')} @@ -362,8 +364,9 @@ const MainPage: React.FC = () => { loading="lazy" /> - - + + + {currentTop.name} @@ -395,7 +398,7 @@ const MainPage: React.FC = () => { to={`/flavor/${currentTop.id}`} sx={{ mt: { xs: 3, md: 4 }, width: { xs: '100%', sm: 'fit-content' }, px: 4, py: 1.5, borderRadius: 2 }} > - View All Reviews + {t('home.viewAllReviews')} @@ -423,7 +426,7 @@ const MainPage: React.FC = () => { {/* Recent Reviews - SIDE BY SIDE */} {recentReviews.length > 0 && ( - Community Voice + {t('home.communityVoice')} {recentReviews.slice(0, 5).map(review => ( { ))} @@ -467,7 +470,7 @@ const MainPage: React.FC = () => { {/* Newest Arrivals */} {newestFlavors.length > 0 && ( - New Arrivals + {t('home.newArrivals')} { + useTitle('Privacy Policy / Datenschutzerklärung'); + const [lang, setLang] = React.useState<'de' | 'en'>('de'); + + return ( + + + + + v && setLang(v)} + size="small" + > + Deutsch + English + + + + + {lang === 'de' ? ( + + Datenschutzerklärung + + Der Schutz Ihrer persönlichen Daten ist uns ein wichtiges Anliegen. Nachfolgend informieren wir Sie + über die Verarbeitung Ihrer Daten auf unserer Webseite gemäß den Anforderungen der DSGVO. + + + 1. Verantwortlicher + + Verantwortlich für die Datenverarbeitung auf dieser Webseite ist:
+ {import.meta.env.VITE_IMPRESSUM_NAME || '[Ihr Name]'}
+ {import.meta.env.VITE_IMPRESSUM_STREET || '[Ihre Straße]'}
+ {import.meta.env.VITE_IMPRESSUM_CITY || '[Ihre Stadt]'} +
+ + 2. Erfassung und Speicherung personenbezogener Daten + + Registrierung: Wenn Sie ein Konto erstellen, speichern wir Ihren Benutzernamen, + Ihre E-Mail-Adresse und Ihr Passwort (verschlüsselt). Diese Daten werden zur Bereitstellung der + Community-Funktionen (Bewertungen, Folgen von Nutzern) verwendet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. + + + Öffentliches Profil: Ihr Benutzername, Ihr Avatar und Ihre Bewertungen sind für + andere Nutzer öffentlich sichtbar. Sie können diese Daten jederzeit in den Einstellungen ändern oder löschen. + + + Kontolöschung: Sie können Ihr Konto und alle damit verbundenen Daten jederzeit + in den Kontoeinstellungen dauerhaft löschen. Zur Sicherheit ist hierfür eine Verifizierung per E-Mail-Code erforderlich. + + + 3. Datenübermittlung an Dritte + + Wir geben Ihre Daten nicht ohne Ihre ausdrückliche Einwilligung an Dritte weiter, es sei denn, + dies ist zur Vertragserfüllung oder zur Erfüllung gesetzlicher Verpflichtungen erforderlich. + + + 4. Ihre Rechte + + Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 DSGVO) + und Einschränkung der Verarbeitung Ihrer Daten. Bitte kontaktieren Sie uns hierzu unter: + {import.meta.env.VITE_IMPRESSUM_EMAIL} + +
+ ) : ( + + Privacy Policy + + Protecting your personal data is a top priority for us. Below we inform you about the processing + of your data on our website in accordance with the requirements of the GDPR. + + + 1. Controller + + The party responsible for data processing on this website is:
+ {import.meta.env.VITE_IMPRESSUM_NAME || '[Your Name]'}
+ {import.meta.env.VITE_IMPRESSUM_STREET || '[Your Street]'}
+ {import.meta.env.VITE_IMPRESSUM_CITY || '[Your City]'} +
+ + 2. Collection and Storage of Personal Data + + Registration: When you create an account, we store your username, email address, + and password (encrypted). This data is used to provide community features (ratings, following users). + The legal basis is Art. 6 Para. 1 lit. b GDPR. + + + Public Profile: Your username, avatar, and ratings are publicly visible to other users. + You can change or delete this data at any time in your settings. + + + Account Deletion: You can permanently delete your account and all associated data + at any time in your account settings. For security reasons, this requires verification via an email code. + + + 3. Data Transfer to Third Parties + + We do not pass your data on to third parties without your express consent, unless this is necessary + to fulfill a contract or a legal obligation. + + + 4. Your Rights + + You have the right to information (Art. 15 GDPR), correction (Art. 16 GDPR), deletion (Art. 17 GDPR), + and restriction of the processing of your data. Please contact us at: + {import.meta.env.VITE_IMPRESSUM_EMAIL} + +
+ )} +
+
+ ); +}; + +export default Privacy; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d99fc53..a1ff2eb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -18,6 +18,7 @@ import { import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Link } from 'react-router-dom'; import api from '../api'; +import { useTranslation } from 'react-i18next'; import { useTitle } from '../hooks/useTitle'; import type { CatppuccinTheme } from '../theme'; @@ -27,15 +28,19 @@ interface SettingsProps { } const Settings: React.FC = ({ themeName, onThemeChange }) => { - useTitle('Account Settings'); + const { t, i18n } = useTranslation(); + useTitle(t('nav.settings')); const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); const [currentEmail, setCurrentEmail] = useState(''); const [avatar, setAvatar] = useState(null); + const [language, setLanguage] = useState(i18n.language.split('-')[0]); const [confirmationCode, setConfirmationCode] = useState(''); const [pendingConfirmation, setPendingConfirmation] = useState(false); const [oldPassword, setOldPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); + const [deletionCode, setDeletionCode] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null); useEffect(() => { @@ -46,6 +51,9 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { setEmail(res.data.email); setCurrentEmail(res.data.email); setAvatar(res.data.avatar); + if (res.data.language) { + setLanguage(res.data.language); + } } catch (err) { console.error(err); } @@ -76,6 +84,16 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { } }; + const handleLanguageChange = async (newLang: string) => { + setLanguage(newLang); + i18n.changeLanguage(newLang); + try { + await api.patch('users/update_preferences/', { language: newLang }); + } catch (err) { + console.error('Failed to update language on server'); + } + }; + const handleUpdateProfile = async (e: React.FormEvent) => { e.preventDefault(); try { @@ -117,6 +135,28 @@ const Settings: React.FC = ({ themeName, onThemeChange }) => { } }; + const handleRequestDeletion = async () => { + try { + await api.post('users/request_account_deletion/'); + setIsDeleting(true); + setMessage({ type: 'success', text: 'Verification code sent to your email.' }); + } catch (err: any) { + setMessage({ type: 'error', text: 'Failed to request deletion.' }); + } + }; + + const handleConfirmDeletion = async (e: React.FormEvent) => { + e.preventDefault(); + if (!window.confirm('Are you absolutely sure? This cannot be undone.')) return; + try { + await api.post('users/confirm_account_deletion/', { code: deletionCode }); + localStorage.removeItem('token'); + window.location.href = '/'; + } catch (err: any) { + setMessage({ type: 'error', text: 'Invalid code. Deletion failed.' }); + } + }; + return ( + + + Danger Zone + + Permanently delete your account and all associated data. This action is irreversible. + + + {!isDeleting ? ( + + ) : ( +
+ Enter verification code: + setDeletionCode(e.target.value)} + sx={{ mb: 2 }} + /> + + + + + + )} +
); };