mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
fixed user manage panel and updated support tickets to be deleted
This commit is contained in:
1 parent
1624988727
commit
20e9d8ba1f
4 files changed
+147
-80
No files matched your search
@@ -598,6 +598,11 @@ class TicketViewSet(viewsets.ModelViewSet):
|
||||
return Response({'status': 'updated'})
|
||||
return Response({'error': 'Invalid status'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
raise permissions.PermissionDenied("Only admins can delete tickets.")
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
class AdminViewSet(viewsets.ViewSet):
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Container, Typography, Box, Card, CardContent, Button,
|
||||
Stack, alpha, CircularProgress, Chip, Paper, List, ListItem, ListItemText,
|
||||
Avatar, Grid, TextField
|
||||
Stack, alpha, useTheme, CircularProgress, Chip, Paper, List, ListItem, ListItemText,
|
||||
Avatar, Grid, TextField, Tabs, Tab, Divider
|
||||
} 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 HistoryIcon from '@mui/icons-material/History';
|
||||
import RateReviewIcon from '@mui/icons-material/RateReview';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import api from '../api';
|
||||
@@ -30,9 +32,11 @@ interface AdminUser {
|
||||
const AdminUserDetail: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// Edit state for Ratings
|
||||
const [editingRatingId, setEditingRatingId] = useState<number | null>(null);
|
||||
@@ -49,7 +53,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
setUser(res.data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
navigate('/admin');
|
||||
navigate('/admin-panel');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -105,7 +109,7 @@ const AdminUserDetail: React.FC = () => {
|
||||
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');
|
||||
navigate('/admin-panel');
|
||||
} catch (err) { alert('Delete failed'); }
|
||||
};
|
||||
|
||||
@@ -117,81 +121,67 @@ const AdminUserDetail: React.FC = () => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => navigate('/admin')}
|
||||
onClick={() => navigate('/admin-panel')}
|
||||
sx={{ mb: 4, borderRadius: 2 }}
|
||||
>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
|
||||
{/* Profile Header */}
|
||||
<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 sx={{ p: { xs: 2, md: 4 } }}>
|
||||
<Grid container spacing={3} alignItems="center">
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Avatar sx={{ width: 80, height: 80, fontSize: '2rem', bgcolor: 'primary.main', mx: 'auto' }}>
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 8, md: 9 }}>
|
||||
<Box sx={{ textAlign: { xs: 'center', sm: 'left' } }}>
|
||||
<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" sx={{ mt: 0.5 }}>
|
||||
Joined: {formatDate(user.date_joined)} • Last Login: {formatDate(user.last_login)}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1.5, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
|
||||
<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>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 'auto' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
color={user.is_active ? "warning" : "success"}
|
||||
onClick={handleToggleActive}
|
||||
sx={{ borderRadius: 2, textTransform: 'none', fontWeight: 'bold' }}
|
||||
>
|
||||
{user.is_active ? t('admin.deactivate') : t('admin.activate')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</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>
|
||||
{/* Content Tabs */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} variant="fullWidth">
|
||||
<Tab icon={<RateReviewIcon />} label={`Content (${user.ratings.length})`} sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<HistoryIcon />} label="Security & IP" sx={{ fontWeight: 'bold' }} />
|
||||
<Tab icon={<SecurityIcon />} label="Danger Zone" sx={{ fontWeight: 'bold', color: 'error.main' }} />
|
||||
</Tabs>
|
||||
<Divider />
|
||||
</Box>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Content</Typography>
|
||||
{/* TAB 0: USER CONTENT (Ratings & Replies) */}
|
||||
{activeTab === 0 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ mb: 3, fontWeight: 'bold' }}>User Ratings & Discussion</Typography>
|
||||
<Stack spacing={2}>
|
||||
{user.ratings.map(rating => (
|
||||
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3 }}>
|
||||
<CardContent>
|
||||
<Card key={rating.id} variant="outlined" sx={{ borderRadius: 3, bgcolor: alpha(theme.palette.background.paper, 0.4) }}>
|
||||
<CardContent sx={{ p: { xs: 2, md: 3 } }}>
|
||||
{editingRatingId === rating.id ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
@@ -204,7 +194,8 @@ const AdminUserDetail: React.FC = () => {
|
||||
/>
|
||||
</Stack>
|
||||
<MentionTextField
|
||||
multiline rows={3} value={editComment}
|
||||
multiline rows={3}
|
||||
value={editComment}
|
||||
onChange={setEditComment}
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
@@ -217,17 +208,23 @@ const AdminUserDetail: React.FC = () => {
|
||||
<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" />
|
||||
<Chip label={`${rating.score}/10`} size="small" color="primary" sx={{ fontWeight: '900' }} />
|
||||
<Button size="small" onClick={() => { setEditingRatingId(rating.id); setEditScore(rating.score); setEditComment(rating.comment || ''); }}>Edit</Button>
|
||||
<Button size="small" color="error" onClick={() => handleDeleteRating(rating.id)}>Delete</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.primary" paragraph sx={{ bgcolor: 'action.hover', p: 1.5, borderRadius: 2 }}>
|
||||
{rating.comment ? <RichText text={rating.comment} /> : <i>No comment</i>}
|
||||
</Typography>
|
||||
<Box sx={{ bgcolor: 'action.hover', p: 1.5, borderRadius: 2, mb: 2 }}>
|
||||
{rating.comment ? (
|
||||
<Typography variant="body2" color="text.primary">
|
||||
<RichText text={rating.comment} />
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontStyle: 'italic' }}>No comment</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{rating.replies.length > 0 && (
|
||||
<Box sx={{ mt: 2, pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
<Box sx={{ pl: 2, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||
{rating.replies.map((reply: any) => (
|
||||
<Box key={reply.id} sx={{ mb: 1.5 }}>
|
||||
{editingReplyId === reply.id ? (
|
||||
@@ -241,13 +238,15 @@ const AdminUserDetail: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold' }}>{reply.user}</Typography>
|
||||
<Typography variant="caption" sx={{ fontWeight: 'bold', color: reply.user === user.username ? 'primary.main' : 'text.secondary' }}>
|
||||
{reply.user}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button size="small" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => { setEditingReplyId(reply.id); setEditReplyText(reply.text); }}>Edit</Button>
|
||||
<Button size="small" color="error" sx={{ minWidth: 0, p: 0, fontSize: '0.7rem' }} onClick={() => handleDeleteReply(reply.id)}>Delete</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Typography variant="caption" display="block"><RichText text={reply.text} /></Typography>
|
||||
<Typography variant="caption" display="block" sx={{ mt: 0.2 }}><RichText text={reply.text} /></Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -259,10 +258,55 @@ const AdminUserDetail: React.FC = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{user.ratings.length === 0 && <Typography color="text.secondary">No ratings yet.</Typography>}
|
||||
{user.ratings.length === 0 && (
|
||||
<Box sx={{ py: 6, textAlign: 'center', bgcolor: 'action.hover', borderRadius: 3, border: '1px dashed', borderColor: 'divider' }}>
|
||||
<Typography color="text.secondary">No ratings yet.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* TAB 1: SECURITY & IP */}
|
||||
{activeTab === 1 && (
|
||||
<Paper sx={{ p: 3, borderRadius: 3, border: '1px solid', borderColor: 'divider' }}>
|
||||
<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} divider={idx < user.ips.length - 1}>
|
||||
<ListItemText
|
||||
primary={ip}
|
||||
primaryTypographyProps={{ sx: { fontWeight: 'bold', fontFamily: 'monospace' } }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
{user.ips.length === 0 && <Typography variant="caption">No IP history available.</Typography>}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* TAB 2: DANGER ZONE */}
|
||||
{activeTab === 2 && (
|
||||
<Paper sx={{ p: 4, borderRadius: 3, border: '1px solid', borderColor: 'error.main', bgcolor: (theme) => alpha(theme.palette.error.main, 0.05) }}>
|
||||
<Typography variant="h5" color="error" gutterBottom sx={{ fontWeight: 'bold' }}>{t('admin.dangerZone')}</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 4, maxWidth: 600 }}>
|
||||
Deleting this user will permanently remove their profile, all their ratings, replies, and IP history.
|
||||
<strong> This action is irreversible.</strong>
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
size="large"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={handleDelete}
|
||||
sx={{ borderRadius: 2, fontWeight: 'bold', px: 4 }}
|
||||
>
|
||||
{t('admin.deleteUser')}
|
||||
</Button>
|
||||
</Paper>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -177,7 +177,7 @@ const PublicProfile: React.FC<PublicProfileProps> = ({ adminMode }) => {
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={() => navigate(`/admin/user/${data.id}`)}
|
||||
onClick={() => navigate(`/admin-panel/user/${data.id}`)}
|
||||
sx={{ borderRadius: 4, px: 4 }}
|
||||
>
|
||||
{t('admin.manageUser')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@mui/material';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import api from '../api';
|
||||
import { useTitle } from '../hooks/useTitle';
|
||||
@@ -98,6 +99,16 @@ const Support: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTicket = async (ticketId: number) => {
|
||||
if (!confirm('Delete this ticket?')) return;
|
||||
try {
|
||||
await api.delete(`tickets/${ticketId}/`);
|
||||
fetchTickets();
|
||||
} catch (err) {
|
||||
alert('Failed to delete ticket');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open': return 'error';
|
||||
@@ -169,7 +180,14 @@ const Support: React.FC = () => {
|
||||
</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' }} />
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
|
||||
{currentUser?.is_superuser && (
|
||||
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Collapse in={expandedId === ticket.id}>
|
||||
|
||||
Reference in new issue
Block a user