Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-17 23:36:25 +01:00
commit 6f44983225
12 files changed
+994 -24

No files matched your search

@@ -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')},
},
),
]
+35
View File
@@ -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']
+37 -1
View File
@@ -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()
+2
View File
@@ -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)),
+105 -5
View File
@@ -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
+28 -6
View File
@@ -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 | HTMLElement>(null);
const [catAnchorEl, setCatAnchorEl] = useState<null | HTMLElement>(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<Notification[]>([]);
const [following, setFollowing] = useState<{id: number, username: string, avatar: string | null}[]>([]);
const [loadingUser, setLoadingUser] = useState(true);
@@ -392,6 +395,19 @@ const App: React.FC = () => {
<ListItemText primary={t('nav.settings')} />
</ListItemButton>
</ListItem>
<ListItem disablePadding>
<ListItemButton component={Link} to="/support" onClick={() => setDrawerOpen(false)}>
<ListItemText primary={t('support.title')} />
</ListItemButton>
</ListItem>
{user.is_superuser && (
<ListItem disablePadding>
<ListItemButton component={Link} to="/admin" onClick={() => setDrawerOpen(false)}>
<ListItemText primary="Admin Panel" sx={{ color: 'primary.main', fontWeight: 'bold' }} />
</ListItemButton>
</ListItem>
)}
{following.length > 0 && (
<>
@@ -574,11 +590,14 @@ const App: React.FC = () => {
sx={{ mt: 1 }}
>
<MenuItem component={Link} to={`/profile/${user.username}`} onClick={() => setAnchorEl(null)}>{t('nav.profile')}</MenuItem>
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>{t('nav.dashboard')}</MenuItem>
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>{t('nav.settings')}</MenuItem>
<Divider />
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>{t('nav.logout')}</MenuItem>
</Menu>
<MenuItem component={Link} to="/dashboard" onClick={() => setAnchorEl(null)}>{t('nav.dashboard')}</MenuItem>
<MenuItem component={Link} to="/settings" onClick={() => setAnchorEl(null)}>{t('nav.settings')}</MenuItem>
<MenuItem component={Link} to="/support" onClick={() => setAnchorEl(null)}>{t('support.title')}</MenuItem>
{user.is_superuser && (
<MenuItem component={Link} to="/admin" onClick={() => setAnchorEl(null)} sx={{ fontWeight: 'bold', color: 'primary.main' }}>Admin Panel</MenuItem>
)}
<Divider />
<MenuItem onClick={handleLogout} sx={{ color: 'error.main' }}>{t('nav.logout')}</MenuItem> </Menu>
</Box>
) : (
<Box sx={{ display: isMobile ? 'none' : 'block' }}>
@@ -621,6 +640,9 @@ const App: React.FC = () => {
<Route path="/privacy" element={<Privacy />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings themeName={themeName} onThemeChange={handleThemeChange} />} />
<Route path="/support" element={<Support />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/user/:id" element={<AdminUserDetail />} />
<Route path="/login" element={<Login />} />
<Route path="*" element={<Box sx={{ p: 4, textAlign: 'center' }}><Typography variant="h5">404 - Page Not Found</Typography><Button component={Link} to="/" sx={{ mt: 2 }}>Back to Home</Button></Box>} />
</Routes>
+62
View File
@@ -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.",
+189
View File
@@ -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<Stats | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Typography variant="h3" sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.title')}</Typography>
<Grid container spacing={3} sx={{ mb: 6 }}>
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.primary.main, 0.1) }}>
<CardContent>
<Typography variant="overline">{t('admin.totalUsers')}</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{stats?.total_users}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ bgcolor: (theme) => alpha(theme.palette.error.main, 0.1) }}>
<CardContent>
<Typography variant="overline">{t('admin.openTickets')}</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold', color: 'error.main' }}>{stats?.open_tickets}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{t('admin.config')}: SMTP</Typography>
<Typography variant="caption" color="text.secondary">
{stats?.email_config.host}:{stats?.email_config.port} •
TLS: {String(stats?.email_config.use_tls)} •
Insecure: {String(stats?.email_config.skip_verify)}
</Typography>
</Box>
<Button variant="outlined" startIcon={<EmailIcon />} onClick={handleSendTestEmail}>
{t('admin.sendTestEmail')}
</Button>
</Stack>
</CardContent>
</Card>
</Grid>
</Grid>
<Box sx={{ mb: 4 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{t('admin.users')}</Typography>
<TextField
size="small" placeholder="Search..."
value={search} onChange={(e) => setSearch(e.target.value)}
sx={{ width: 300 }}
/>
</Stack>
<TableContainer component={Paper} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
<Table>
<TableHead sx={{ bgcolor: 'action.hover' }}>
<TableRow>
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>User</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Email</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Status</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Joined</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>IP History</TableCell>
<TableCell sx={{ fontWeight: 'bold' }} align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} hover>
<TableCell>{user.id}</TableCell>
<TableCell>
<Typography variant="body2" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
{user.is_superuser && <Chip label="Superuser" size="small" color="secondary" sx={{ height: 16, fontSize: '0.6rem' }} />}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Chip
label={user.is_active ? 'Active' : 'Inactive'}
color={user.is_active ? 'success' : 'default'}
size="small"
/>
</TableCell>
<TableCell>{formatDate(user.date_joined)}</TableCell>
<TableCell>
<Stack direction="row" spacing={0.5} flexWrap="wrap">
{user.ips.slice(0, 3).map(ip => (
<Chip key={ip} label={ip} size="small" variant="outlined" sx={{ fontSize: '0.7rem' }} />
))}
{user.ips.length > 3 && <Typography variant="caption">+{user.ips.length - 3}</Typography>}
</Stack>
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => navigate(`/admin/user/${user.id}`)}>
<EditIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
</Container>
);
};
export default AdminDashboard;
+203
View File
@@ -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<AdminUser | null>(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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
if (!user) return <Typography>User not found</Typography>;
return (
<Container maxWidth="lg" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin')}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Card elevation={0} sx={{ borderRadius: 4, border: '1px solid', borderColor: 'divider', mb: 4 }}>
<CardContent sx={{ p: 4 }}>
<Stack direction="row" spacing={3} alignItems="center">
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main' }}>
{user.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>{user.username}</Typography>
<Typography variant="body1" color="text.secondary">{user.email}</Typography>
<Typography variant="caption" color="text.secondary" display="block">
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
</Typography>
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Chip label={user.is_active ? 'Active' : 'Inactive'} color={user.is_active ? 'success' : 'default'} size="small" />
{user.is_superuser && <Chip label="Superuser" color="secondary" size="small" />}
</Stack>
</Box>
<Button
variant="outlined"
color={user.is_active ? "warning" : "success"}
onClick={handleToggleActive}
>
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
</Button>
</Stack>
</CardContent>
</Card>
<Grid container spacing={4}>
<Grid size={{ xs: 12, md: 4 }}>
<Stack spacing={4}>
<Paper sx={{ p: 3, borderRadius: 3 }}>
<Typography variant="h6" gutterBottom sx={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: 1 }}>
<SecurityIcon /> {t('admin.ipHistory')}
</Typography>
<List dense>
{user.ips.map((ip, idx) => (
<ListItem key={idx}>
<ListItemText primary={ip} />
</ListItem>
))}
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
</List>
</Paper>
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}>
<Typography variant="h6" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
Deleting this user will permanently remove their profile, ratings, replies, and all associated metadata.
</Typography>
<Button
variant="contained"
color="error"
startIcon={<DeleteIcon />}
onClick={handleDelete}
fullWidth
>
{t('admin.deleteUser')}
</Button>
</Paper>
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Content</Typography>
<Stack spacing={2}>
{user.ratings.map(rating => (
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3 }}>
<CardContent>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 'bold' }}>{rating.flavor_name}</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Chip label={`${rating.score}/10`} size="small" color="primary" />
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
</Box>
</Stack>
<Typography variant="body2" color="text.secondary" paragraph>
{rating.comment || <i>No comment</i>}
</Typography>
{rating.replies.length > 0 && (
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
{rating.replies.map((reply: any) => (
<Box key={reply.id} sx={{ mb: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{reply.user}</Typography>
<Button size="small" color="error" sx={{ minWidth: 0, p: 0 }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
</Stack>
<Typography variant="caption" display="block">{reply.text}</Typography>
</Box>
))}
</Box>
)}
</CardContent>
</Card>
))}
{user.ratings.length === 0 && <Typography color="text.secondary">No ratings yet.</Typography>}
</Stack>
</Grid>
</Grid>
</Container>
);
};
export default AdminUserDetail;
+10
View File
@@ -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.
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
<strong>IP-Adressen:</strong> 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.
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
<strong>Öffentliches Profil:</strong> 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.
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
<strong>IP Addresses:</strong> 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.
</Typography>
<Typography variant="body2" paragraph sx={{ lineHeight: 1.7 }}>
<strong>Public Profile:</strong> Your username, avatar, and ratings are publicly visible to other users.
You can change or delete this data at any time in your settings.
+26 -12
View File
@@ -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<ProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState(0);
const [currentUser, setCurrentUser] = useState<string | null>(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 = () => {
<Box sx={{ flex: 1, minWidth: 200 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 1, flexWrap: 'wrap' }}>
<Typography variant="h2" sx={{ fontWeight: 'bold', fontSize: { xs: '2.5rem', md: '3.75rem' } }}>{data.username}</Typography>
{currentUser !== data.username && (
<Button
variant={data.is_following ? "outlined" : "contained"}
onClick={handleFollowToggle}
sx={{ borderRadius: 4, px: 4 }}
>
{data.is_following ? "Following" : "Follow"}
</Button>
)}
<Stack direction="row" spacing={1}>
{currentUser?.username !== data.username && (
<Button
variant={data.is_following ? "outlined" : "contained"}
onClick={handleFollowToggle}
sx={{ borderRadius: 4, px: 4 }}
>
{data.is_following ? "Following" : "Follow"}
</Button>
)}
{currentUser?.is_superuser && (
<Button
variant="outlined"
color="secondary"
onClick={() => navigate(`/admin/user/${data.id}`)}
sx={{ borderRadius: 4, px: 4 }}
>
{t('admin.manageUser')}
</Button>
)}
</Stack>
</Box>
<Typography variant="h6" color="text.secondary">
Taste Profile • {data.ratings.length} Rated • {data.followers_count} Followers • {data.following_count} Following
+241
View File
@@ -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<Ticket[]>([]);
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<number | null>(null);
const [currentUser, setCurrentUser] = useState<any>(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 <Box sx={{ display: 'flex', justifyContent: 'center', mt: 8 }}><CircularProgress /></Box>;
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
{!currentUser?.is_superuser && (
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setShowCreate(!showCreate)}
sx={{ borderRadius: 2 }}
>
{t('support.createTicket')}
</Button>
)}
</Stack>
<Collapse in={showCreate}>
<Paper sx={{ p: 3, mb: 4, borderRadius: 3, border: '1px solid', borderColor: 'primary.main' }}>
<form onSubmit={handleCreate}>
<TextField
fullWidth label={t('support.subject')} margin="normal"
value={subject} onChange={(e) => setSubject(e.target.value)} required
/>
<TextField
fullWidth multiline rows={4} label={t('support.description')} margin="normal"
value={description} onChange={(e) => setDescription(e.target.value)} required
/>
<Button type="submit" variant="contained" sx={{ mt: 2, borderRadius: 2 }}>
{t('support.createTicket')}
</Button>
</form>
</Paper>
</Collapse>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 'bold' }}>
{currentUser?.is_superuser ? t('admin.users') + ' Tickets' : t('support.myTickets')}
</Typography>
{tickets.length === 0 ? (
<Box sx={{ py: 8, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 4 }}>
<Typography color="text.secondary">{t('support.noTickets')}</Typography>
</Box>
) : (
<Stack spacing={2}>
{tickets.map((ticket: Ticket) => (
<Card key={ticket.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
<CardContent sx={{ p: 0 }}>
<Box
onClick={() => setExpandedId(expandedId === ticket.id ? null : ticket.id)}
sx={{ p: 2, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', '&:hover': { bgcolor: 'action.hover' } }}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{ticket.subject}
{currentUser?.is_superuser && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
({ticket.username})
</Typography>
)}
</Typography>
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
</Box>
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
</Box>
<Collapse in={expandedId === ticket.id}>
<Divider />
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
{currentUser?.is_superuser && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
<Button
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
onClick={() => handleUpdateStatus(ticket.id, s)}
color={getStatusColor(s) as any}
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem' }}
>
{t(`support.${s}`)}
</Button>
))}
</Stack>
)}
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3 }}>
{ticket.description}
</Typography>
<Stack spacing={2} sx={{ mb: 3 }}>
{ticket.messages.map((msg: Message) => (
<Box key={msg.id} sx={{
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
maxWidth: '80%',
p: 1.5,
borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
color: msg.is_admin ? 'white' : 'inherit'
}}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
{msg.username} {msg.is_admin && '(Support)'}
</Typography>
<Typography variant="body2">{msg.text}</Typography>
<Typography variant="caption" sx={{ opacity: 0.7 }}>{formatDate(msg.created_at)}</Typography>
</Box>
))}
</Stack>
{ticket.status !== 'closed' && (
<Stack direction="row" spacing={1}>
<TextField
fullWidth size="small" placeholder={t('community.writeReply')}
value={replyText[ticket.id] || ''}
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })}
/>
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
<SendIcon />
</IconButton>
</Stack>
)}
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Stack>
)}
</Container>
);
};
export default Support;