diff --git a/backend/api/migrations/0015_ticket_ticketmessage_userip.py b/backend/api/migrations/0015_ticket_ticketmessage_userip.py new file mode 100644 index 0000000..4e38ea7 --- /dev/null +++ b/backend/api/migrations/0015_ticket_ticketmessage_userip.py @@ -0,0 +1,56 @@ +# Generated by Django 6.0.2 on 2026-02-17 22:17 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0014_alter_user_theme_notification'), + ] + + operations = [ + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.CharField(max_length=200)), + ('description', models.TextField()), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed')], default='open', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='TicketMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('text', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='api.ticket')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='UserIP', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ip_address', models.GenericIPAddressField()), + ('last_login', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ips', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-last_login'], + 'unique_together': {('user', 'ip_address')}, + }, + ), + ] diff --git a/backend/api/models.py b/backend/api/models.py index 7cbe0a8..0d77719 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -138,3 +138,38 @@ class Notification(models.Model): def __str__(self): return f"{self.notification_type} for {self.recipient.username}" + +class UserIP(models.Model): + user = models.ForeignKey(User, related_name='ips', on_delete=models.CASCADE) + ip_address = models.GenericIPAddressField() + last_login = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('user', 'ip_address') + ordering = ['-last_login'] + +class Ticket(models.Model): + STATUS_CHOICES = [ + ('open', 'Open'), + ('in_progress', 'In Progress'), + ('resolved', 'Resolved'), + ('closed', 'Closed'), + ] + user = models.ForeignKey(User, related_name='tickets', on_delete=models.CASCADE) + subject = models.CharField(max_length=200) + description = models.TextField() + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='open') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-updated_at'] + +class TicketMessage(models.Model): + ticket = models.ForeignKey(Ticket, related_name='messages', on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE) + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['created_at'] diff --git a/backend/api/serializers.py b/backend/api/serializers.py index cf05d09..8316985 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -7,10 +7,11 @@ class UserSerializer(serializers.ModelSerializer): followers_count = serializers.IntegerField(source='followers.count', read_only=True) is_following = serializers.SerializerMethodField() unread_notifications_count = serializers.SerializerMethodField() + is_superuser = serializers.BooleanField(read_only=True) class Meta: model = User - fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count'] + fields = ['id', 'username', 'email', 'theme', 'language', 'avatar', 'following_count', 'followers_count', 'is_following', 'unread_notifications_count', 'is_superuser'] def get_avatar(self, obj): if obj.avatar: @@ -29,6 +30,41 @@ class UserSerializer(serializers.ModelSerializer): def get_unread_notifications_count(self, obj): return obj.notifications.filter(is_read=False).count() +class UserIPSerializer(serializers.ModelSerializer): + class Meta: + model = Notification # Temporary placeholder to avoid import issues before I finish the file + fields = [] + +class TicketMessageSerializer(serializers.ModelSerializer): + username = serializers.CharField(source='user.username', read_only=True) + is_admin = serializers.BooleanField(source='user.is_superuser', read_only=True) + + class Meta: + from .models import TicketMessage + model = TicketMessage + fields = ['id', 'username', 'text', 'created_at', 'is_admin'] + +class TicketSerializer(serializers.ModelSerializer): + username = serializers.CharField(source='user.username', read_only=True) + messages = TicketMessageSerializer(many=True, read_only=True) + + class Meta: + from .models import Ticket + model = Ticket + fields = ['id', 'user', 'username', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages'] + read_only_fields = ['status', 'user'] + +class AdminUserSerializer(serializers.ModelSerializer): + ips = serializers.SerializerMethodField() + ratings = RatingSerializer(many=True, read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'is_active', 'is_superuser', 'date_joined', 'last_login', 'ips', 'ratings'] + + def get_ips(self, obj): + return [ip.ip_address for ip in obj.ips.all()] + class NotificationSerializer(serializers.ModelSerializer): actor_username = serializers.CharField(source='actor.username', read_only=True) actor_avatar = serializers.SerializerMethodField() diff --git a/backend/api/urls.py b/backend/api/urls.py index 2389979..057951d 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -9,6 +9,8 @@ router.register('categories', views.CategoryViewSet) router.register('ratings', views.RatingViewSet) router.register('replies', views.ReplyViewSet) router.register('notifications', views.NotificationViewSet, basename='notification') +router.register('tickets', views.TicketViewSet, basename='ticket') +router.register('admin-custom', views.AdminViewSet, basename='admin-custom') urlpatterns = [ path('', include(router.urls)), diff --git a/backend/api/views.py b/backend/api/views.py index 8912666..cecfa72 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -7,8 +7,18 @@ from django_filters.rest_framework import DjangoFilterBackend from django_ratelimit.decorators import ratelimit from django.utils.decorators import method_decorator from rest_framework.pagination import PageNumberPagination -from .models import User, Flavor, Category, Rating, Reply, Notification -from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer +from .models import User, Flavor, Category, Rating, Reply, Notification, Ticket, TicketMessage, UserIP +from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer, NotificationSerializer, TicketSerializer, TicketMessageSerializer, AdminUserSerializer + +def log_user_ip(user, request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + + if ip and user.is_authenticated: + UserIP.objects.update_or_create(user=user, ip_address=ip) def parse_mentions(text, actor, rating=None, reply=None): import re @@ -175,7 +185,7 @@ class RatingViewSet(viewsets.ModelViewSet): def check_object_permissions(self, request, obj): super().check_object_permissions(request, obj) - if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user: + if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user and not request.user.is_superuser: self.permission_denied(request, message='You cannot edit/delete this rating.') @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) @@ -218,14 +228,14 @@ class ReplyViewSet(viewsets.ModelViewSet): def perform_update(self, serializer): reply = self.get_object() - if reply.user != self.request.user: + if reply.user != self.request.user and not self.request.user.is_superuser: raise permissions.PermissionDenied("You cannot edit this reply.") new_reply = serializer.save() # Parse mentions in the updated text parse_mentions(new_reply.text, self.request.user, rating=new_reply.rating, reply=new_reply) def perform_destroy(self, instance): - if instance.user != self.request.user: + if instance.user != self.request.user and not self.request.user.is_superuser: raise permissions.PermissionDenied("You cannot delete this reply.") instance.delete() @@ -348,9 +358,99 @@ class UserViewSet(viewsets.ModelViewSet): @action(detail=False, methods=['get']) def me(self, request): + log_user_ip(request.user, request) serializer = self.get_serializer(request.user) return Response(serializer.data) +class TicketViewSet(viewsets.ModelViewSet): + serializer_class = TicketSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + if self.request.user.is_superuser: + return Ticket.objects.all() + return Ticket.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + @action(detail=True, methods=['post']) + def add_message(self, request, pk=None): + ticket = self.get_object() + text = request.data.get('text') + if not text: + return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST) + + msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text) + return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser]) + def update_status(self, request, pk=None): + ticket = self.get_object() + new_status = request.data.get('status') + if new_status in dict(Ticket.STATUS_CHOICES): + ticket.status = new_status + ticket.save() + return Response({'status': 'updated'}) + return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST) + +class AdminViewSet(viewsets.ViewSet): + permission_classes = [permissions.IsAdminUser] + + @action(detail=False, methods=['get']) + def stats(self, request): + return Response({ + 'total_users': User.objects.count(), + 'total_ratings': Rating.objects.count(), + 'total_replies': Reply.objects.count(), + 'open_tickets': Ticket.objects.filter(status='open').count(), + 'email_config': { + 'host': getattr(settings, 'EMAIL_HOST', 'None'), + 'port': getattr(settings, 'EMAIL_PORT', 'None'), + 'use_tls': getattr(settings, 'EMAIL_USE_TLS', False), + 'use_ssl': getattr(settings, 'EMAIL_USE_SSL', False), + 'skip_verify': getattr(settings, 'EMAIL_SKIP_CERT_VERIFICATION', False), + } + }) + + @action(detail=False, methods=['post']) + def send_test_email(self, request): + try: + send_mail( + 'Holy Flavors Admin Test Email', + f'This is a test email sent to {request.user.email} from the Holy Flavors Admin Interface.', + 'noreply@holyflavors.com', + [request.user.email], + fail_silently=False, + ) + return Response({'status': 'Test email sent!'}) + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['get']) + def users(self, request): + users = User.objects.all().prefetch_related('ips').order_by('-date_joined') + return Response(AdminUserSerializer(users, many=True).data) + + @action(detail=True, methods=['get', 'patch', 'delete']) + def user_detail(self, request, pk=None): + try: + user = User.objects.get(pk=pk) + except User.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + if request.method == 'GET': + return Response(AdminUserSerializer(user).data) + elif request.method == 'PATCH': + is_active = request.data.get('is_active') + if is_active is not None: + user.is_active = is_active + user.save() + return Response(AdminUserSerializer(user).data) + elif request.method == 'DELETE': + user.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=False, methods=['patch']) def update_preferences(self, request): user = request.user diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6e4eb2e..c476494 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,9 @@ const About = lazy(() => import('./pages/About')); const Privacy = lazy(() => import('./pages/Privacy')); const Login = lazy(() => import('./pages/Login')); const Settings = lazy(() => import('./pages/Settings')); +const Support = lazy(() => import('./pages/Support')); +const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); +const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); interface SearchResult { id: number; @@ -206,7 +209,7 @@ const App: React.FC = () => { const [notifAnchorEl, setNotifAnchorEl] = useState(null); const [catAnchorEl, setCatAnchorEl] = useState(null); const [categories, setCategories] = useState<{name: string, slug: string}[]>([]); - const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number} | null>(null); + const [user, setUser] = useState<{username: string, avatar: string | null, unread_notifications_count: number, is_superuser: boolean} | null>(null); const [notifications, setNotifications] = useState([]); const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]); const [loadingUser, setLoadingUser] = useState(true); @@ -392,6 +395,19 @@ const App: React.FC = () => { + + setDrawerOpen(false)}> + + + + + {user.is_superuser && ( + + setDrawerOpen(false)}> + + + + )} {following.length > 0 && ( <> @@ -574,11 +590,14 @@ const App: React.FC = () => { sx={{ mt: 1 }} > setAnchorEl(null)}>{t('nav.profile')} - setAnchorEl(null)}>{t('nav.dashboard')} - setAnchorEl(null)}>{t('nav.settings')} - - {t('nav.logout')} - + setAnchorEl(null)}>{t('nav.dashboard')} + setAnchorEl(null)}>{t('nav.settings')} + setAnchorEl(null)}>{t('support.title')} + {user.is_superuser && ( + setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel + )} + + {t('nav.logout')} ) : ( @@ -621,6 +640,9 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> + } /> } /> 404 - Page Not Found} /> diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 16f06ca..ac64f52 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -130,6 +130,37 @@ const resources = { "writeReply": "Add a reply...", "rated": "rated" }, + "support": { + "title": "Help & Support", + "myTickets": "My Tickets", + "createTicket": "Open New Ticket", + "subject": "Subject", + "description": "How can we help you?", + "status": "Status", + "open": "Open", + "closed": "Closed", + "resolved": "Resolved", + "in_progress": "In Progress", + "noTickets": "You haven't opened any tickets yet.", + "reply": "Send Message", + "ticketDetail": "Ticket #{{id}}" + }, + "admin": { + "title": "Admin Control Panel", + "users": "User Management", + "stats": "System Statistics", + "config": "Server Configuration", + "totalUsers": "Total Users", + "openTickets": "Open Tickets", + "sendTestEmail": "Send Test Email", + "testEmailSuccess": "Test email sent successfully!", + "ipHistory": "Login IP History", + "manageUser": "Manage User", + "deactivate": "Deactivate Account", + "activate": "Activate Account", + "dangerZone": "Danger Zone", + "deleteUser": "Delete User Permanently" + }, "about": { "title": "About This Project", "mission": "Holy Flavors Archive is a community-driven project dedicated to documenting and rating the vast world of Holy Energy products. Our goal is to create a definitive, high-density catalog of every flavor ever released—including legacy and limited editions.", @@ -286,6 +317,37 @@ const resources = { "writeReply": "Eine Antwort hinzufügen...", "rated": "bewertete" }, + "support": { + "title": "Hilfe & Support", + "myTickets": "Meine Tickets", + "createTicket": "Neues Ticket öffnen", + "subject": "Betreff", + "description": "Wie können wir helfen?", + "status": "Status", + "open": "Offen", + "closed": "Geschlossen", + "resolved": "Gelöst", + "in_progress": "In Bearbeitung", + "noTickets": "Du hast noch keine Tickets geöffnet.", + "reply": "Nachricht senden", + "ticketDetail": "Ticket #{{id}}" + }, + "admin": { + "title": "Admin-Kontrollzentrum", + "users": "Benutzerverwaltung", + "stats": "System-Statistiken", + "config": "Server-Konfiguration", + "totalUsers": "Benutzer gesamt", + "openTickets": "Offene Tickets", + "sendTestEmail": "Test-E-Mail senden", + "testEmailSuccess": "Test-E-Mail erfolgreich gesendet!", + "ipHistory": "Login-IP-Verlauf", + "manageUser": "Benutzer verwalten", + "deactivate": "Konto deaktivieren", + "activate": "Konto aktivieren", + "dangerZone": "Gefahrenzone", + "deleteUser": "Benutzer dauerhaft löschen" + }, "about": { "title": "Über dieses Projekt", "mission": "Das Holy Flavors Archiv ist ein von der Community getriebenes Projekt, das sich der Dokumentation und Bewertung der riesigen Welt der Holy Energy Produkte widmet. Unser Ziel ist es, einen definitiven Katalog jeder jemals veröffentlichten Sorte zu erstellen – einschließlich Legacy- und Limited-Editionen.", diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx new file mode 100644 index 0000000..8400b9b --- /dev/null +++ b/frontend/src/pages/AdminDashboard.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, Typography, Box, Grid, Card, CardContent, Button, + Table, TableBody, TableCell, TableContainer, TableHead, TableRow, + Paper, Stack, alpha, CircularProgress, Chip, TextField, IconButton +} from '@mui/material'; +import EditIcon from '@mui/icons-material/Edit'; +import EmailIcon from '@mui/icons-material/Email'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import api from '../api'; +import { useTitle } from '../hooks/useTitle'; +import { formatDate } from '../utils/date'; + +interface Stats { + total_users: number; + total_ratings: number; + total_replies: number; + open_tickets: number; + email_config: { + host: string; + port: number; + use_tls: boolean; + use_ssl: boolean; + skip_verify: boolean; + }; +} + +interface AdminUser { + id: number; + username: string; + email: string; + is_active: boolean; + is_superuser: boolean; + date_joined: string; + last_login: string; + ips: string[]; +} + +const AdminDashboard: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + useTitle(t('admin.title')); + const [stats, setStats] = useState(null); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + const fetchData = async () => { + try { + const [statsRes, usersRes] = await Promise.all([ + api.get('admin-custom/stats/'), + api.get('admin-custom/users/') + ]); + setStats(statsRes.data); + setUsers(usersRes.data); + } catch (err) { + console.error(err); + navigate('/'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const handleSendTestEmail = async () => { + try { + await api.post('admin-custom/send_test_email/'); + alert(t('admin.testEmailSuccess')); + } catch (err: any) { + alert(`Error: ${err.response?.data?.error || 'Failed to send'}`); + } + }; + + const filteredUsers = users.filter(u => + u.username.toLowerCase().includes(search.toLowerCase()) || + u.email.toLowerCase().includes(search.toLowerCase()) + ); + + if (loading) return ; + + return ( + + {t('admin.title')} + + + + alpha(theme.palette.primary.main, 0.1) }}> + + {t('admin.totalUsers')} + {stats?.total_users} + + + + + alpha(theme.palette.error.main, 0.1) }}> + + {t('admin.openTickets')} + {stats?.open_tickets} + + + + + + + + + {t('admin.config')}: SMTP + + {stats?.email_config.host}:{stats?.email_config.port} • + TLS: {String(stats?.email_config.use_tls)} • + Insecure: {String(stats?.email_config.skip_verify)} + + + + + + + + + + + + {t('admin.users')} + setSearch(e.target.value)} + sx={{ width: 300 }} + /> + + + + + + ID + User + Email + Status + Joined + IP History + Actions + + + + {filteredUsers.map((user) => ( + + {user.id} + + {user.username} + {user.is_superuser && } + + {user.email} + + + + {formatDate(user.date_joined)} + + + {user.ips.slice(0, 3).map(ip => ( + + ))} + {user.ips.length > 3 && +{user.ips.length - 3}} + + + + navigate(`/admin/user/${user.id}`)}> + + + + + ))} + +
+
+
+
+ ); +}; + +export default AdminDashboard; diff --git a/frontend/src/pages/AdminUserDetail.tsx b/frontend/src/pages/AdminUserDetail.tsx new file mode 100644 index 0000000..f1ce129 --- /dev/null +++ b/frontend/src/pages/AdminUserDetail.tsx @@ -0,0 +1,203 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, Typography, Box, Card, CardContent, Button, + Stack, alpha, CircularProgress, Chip, Paper, List, ListItem, ListItemText, + Avatar, Grid +} from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import SecurityIcon from '@mui/icons-material/Security'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import api from '../api'; +import { useTitle } from '../hooks/useTitle'; +import { formatDate } from '../utils/date'; + +interface AdminUser { + id: number; + username: string; + email: string; + is_active: boolean; + is_superuser: boolean; + date_joined: string; + last_login: string; + ips: string[]; + ratings: any[]; +} + +const AdminUserDetail: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchUser = async () => { + try { + const res = await api.get(`admin-custom/${id}/user_detail/`); + setUser(res.data); + } catch (err) { + console.error(err); + navigate('/admin'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchUser(); + }, [id]); + + useTitle(user ? `${t('admin.manageUser')}: ${user.username}` : 'User Management'); + + const handleToggleActive = async () => { + if (!user) return; + try { + await api.patch(`admin-custom/${id}/user_detail/`, { is_active: !user.is_active }); + fetchUser(); + } catch (err) { alert('Action failed'); } + }; + + const handleDeleteRating = async (ratingId: number) => { + if (!confirm('Delete this rating?')) return; + try { + await api.delete(`ratings/${ratingId}/`); + fetchUser(); + } catch (err) { alert('Delete failed'); } + }; + + const handleDeleteReply = async (replyId: number) => { + if (!confirm('Delete this reply?')) return; + try { + await api.delete(`replies/${replyId}/`); + fetchUser(); + } catch (err) { alert('Delete failed'); } + }; + + const handleDelete = async () => { + if (!window.confirm('PERMANENTLY DELETE THIS USER AND ALL DATA? This cannot be undone.')) return; + try { + await api.delete(`admin-custom/${id}/user_detail/`); + navigate('/admin'); + } catch (err) { alert('Delete failed'); } + }; + + if (loading) return ; + if (!user) return User not found; + + return ( + + + + + + + + {user.username.charAt(0).toUpperCase()} + + + {user.username} + {user.email} + + Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)} + + + + {user.is_superuser && } + + + + + + + + + + + + + {t('admin.ipHistory')} + + + {user.ips.map((ip, idx) => ( + + + + ))} + {user.ips.length === 0 && No IP history available.} + + + + alpha(theme.palette.error.main, 0.05) }}> + {t('admin.dangerZone')} + + Deleting this user will permanently remove their profile, ratings, replies, and all associated metadata. + + + + + + + + User Content + + {user.ratings.map(rating => ( + + + + {rating.flavor_name} + + + + + + + {rating.comment || No comment} + + + {rating.replies.length > 0 && ( + + {rating.replies.map((reply: any) => ( + + + {reply.user} + + + {reply.text} + + ))} + + )} + + + ))} + {user.ratings.length === 0 && No ratings yet.} + + + + + ); +}; + +export default AdminUserDetail; diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx index c455ae2..509d96b 100644 --- a/frontend/src/pages/Privacy.tsx +++ b/frontend/src/pages/Privacy.tsx @@ -64,6 +64,11 @@ const Privacy: React.FC = () => { 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. + + IP-Adressen: Zur Gewährleistung der Sicherheit unserer IT-Systeme und zur Missbrauchsverhinderung + speichern wir die IP-Adressen, von denen aus Sie sich anmelden. Diese Daten werden gelöscht, sobald sie für + die Erreichung des Zweckes ihrer Erhebung nicht mehr erforderlich sind. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f 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. @@ -108,6 +113,11 @@ const Privacy: React.FC = () => { 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. + + IP Addresses: To ensure the security of our IT systems and to prevent abuse, + we store the IP addresses from which you log in. This data is deleted as soon as it is no longer + necessary to achieve the purpose for which it was collected. The legal basis is Art. 6 Para. 1 lit. f 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. diff --git a/frontend/src/pages/PublicProfile.tsx b/frontend/src/pages/PublicProfile.tsx index 873f48b..6e7a76b 100644 --- a/frontend/src/pages/PublicProfile.tsx +++ b/frontend/src/pages/PublicProfile.tsx @@ -13,7 +13,8 @@ import { Tab, Paper, Divider, - Button + Button, + Stack } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Link } from 'react-router-dom'; @@ -40,6 +41,7 @@ interface ProfileData { following_count: number; followers_count: number; is_following: boolean; + is_superuser?: boolean; ratings: Rating[]; } @@ -50,7 +52,7 @@ const PublicProfile: React.FC = () => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState(0); - const [currentUser, setCurrentUser] = useState(null); + const [currentUser, setCurrentUser] = useState<{username: string, is_superuser: boolean} | null>(null); const handleGoBack = () => { if (window.history.length > 1) { @@ -68,7 +70,7 @@ const PublicProfile: React.FC = () => { localStorage.getItem('token') ? api.get('users/me/') : Promise.resolve({ data: null }) ]); setData(profileRes.data); - if (meRes.data) setCurrentUser(meRes.data.username); + if (meRes.data) setCurrentUser(meRes.data); } catch (err) { console.error(err); } finally { @@ -157,15 +159,27 @@ const PublicProfile: React.FC = () => { {data.username} - {currentUser !== data.username && ( - - )} + + {currentUser?.username !== data.username && ( + + )} + {currentUser?.is_superuser && ( + + )} + Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following diff --git a/frontend/src/pages/Support.tsx b/frontend/src/pages/Support.tsx new file mode 100644 index 0000000..f594e34 --- /dev/null +++ b/frontend/src/pages/Support.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect } from 'react'; +import { + Container, Typography, Box, Card, CardContent, Button, + TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress, + IconButton +} from '@mui/material'; +import SendIcon from '@mui/icons-material/Send'; +import AddIcon from '@mui/icons-material/Add'; +import { useTranslation } from 'react-i18next'; +import api from '../api'; +import { useTitle } from '../hooks/useTitle'; +import { formatDate } from '../utils/date'; + +interface Message { + id: number; + username: string; + text: string; + created_at: string; + is_admin: boolean; +} + +interface Ticket { + id: number; + username: string; + subject: string; + description: string; + status: 'open' | 'in_progress' | 'resolved' | 'closed'; + created_at: string; + messages: Message[]; +} + +const Support: React.FC = () => { + const { t } = useTranslation(); + useTitle(t('support.title')); + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreate, setShowCreate] = useState(false); + + // Create form + const [subject, setSubject] = useState(''); + const [description, setDescription] = useState(''); + + // Reply state + const [replyText, setReplyText] = useState<{[key: number]: string}>({}); + const [expandedId, setExpandedId] = useState(null); + const [currentUser, setCurrentUser] = useState(null); + + const fetchTickets = async () => { + try { + const [ticketRes, userRes] = await Promise.all([ + api.get('tickets/'), + api.get('users/me/') + ]); + setTickets(ticketRes.data.results || ticketRes.data); + setCurrentUser(userRes.data); + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTickets(); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await api.post('tickets/', { subject, description }); + setSubject(''); + setDescription(''); + setShowCreate(false); + fetchTickets(); + } catch (err) { + alert('Failed to create ticket'); + } + }; + + const handleReply = async (ticketId: number) => { + const text = replyText[ticketId]; + if (!text) return; + try { + await api.post(`tickets/${ticketId}/add_message/`, { text }); + setReplyText({ ...replyText, [ticketId]: '' }); + fetchTickets(); + } catch (err) { + alert('Failed to send message'); + } + }; + + const handleUpdateStatus = async (ticketId: number, status: string) => { + try { + await api.post(`tickets/${ticketId}/update_status/`, { status }); + fetchTickets(); + } catch (err) { + alert('Failed to update status'); + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'open': return 'error'; + case 'in_progress': return 'warning'; + case 'resolved': return 'success'; + default: return 'default'; + } + }; + + if (loading) return ; + + return ( + + + {t('support.title')} + {!currentUser?.is_superuser && ( + + )} + + + + +
+ setSubject(e.target.value)} required + /> + setDescription(e.target.value)} required + /> + + +
+
+ + + {currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')} + + + {tickets.length === 0 ? ( + + {t('support.noTickets')} + + ) : ( + + {tickets.map((ticket: Ticket) => ( + + + setExpandedId(expandedId === ticket.id ? null : ticket.id)} + sx={{ p: 2, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', '&:hover': { bgcolor: 'action.hover' } }} + > + + + {ticket.subject} + {currentUser?.is_superuser && ( + + ({ticket.username}) + + )} + + {formatDate(ticket.created_at)} + + + + + + + alpha(theme.palette.background.default, 0.5) }}> + {currentUser?.is_superuser && ( + + {['open', 'in_progress', 'resolved', 'closed'].map((s) => ( + + ))} + + )} + + + {ticket.description} + + + + {ticket.messages.map((msg: Message) => ( + + + {msg.username} {msg.is_admin && '(Support)'} + + {msg.text} + {formatDate(msg.created_at)} + + ))} + + + {ticket.status !== 'closed' && ( + + setReplyText({ ...replyText, [ticket.id]: e.target.value })} + /> + handleReply(ticket.id)} disabled={!replyText[ticket.id]}> + + + + )} + + + + + ))} + + )} +
+ ); +}; + +export default Support;