This commit is contained in:
368
src/pages/Admin.jsx
Normal file
368
src/pages/Admin.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [resetPwModal, setResetPwModal] = useState(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
}, [user]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/users');
|
||||
setUsers(res.data.users);
|
||||
} catch {
|
||||
toast.error(t('admin.roleUpdateFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId, newRole) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||
toast.success(t('admin.roleUpdated'));
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (userId, userName) => {
|
||||
if (!confirm(t('admin.deleteUserConfirm', { name: userName }))) return;
|
||||
try {
|
||||
await api.delete(`/admin/users/${userId}`);
|
||||
toast.success(t('admin.userDeleted'));
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put(`/admin/users/${resetPwModal}/password`, { newPassword });
|
||||
toast.success(t('admin.passwordReset'));
|
||||
setResetPwModal(null);
|
||||
setNewPassword('');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.passwordResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreatingUser(true);
|
||||
try {
|
||||
await api.post('/admin/users', newUser);
|
||||
toast.success(t('admin.userCreated'));
|
||||
setShowCreateUser(false);
|
||||
setNewUser({ name: '', email: '', password: '', role: 'user' });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
|
||||
} finally {
|
||||
setCreatingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield size={24} className="text-th-accent" />
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('admin.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('admin.userCount', { count: users.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreateUser(true)} className="btn-primary">
|
||||
<UserPlus size={18} />
|
||||
<span className="hidden sm:inline">{t('admin.createUser')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="relative">
|
||||
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('admin.searchUsers')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-th-border">
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
||||
{t('admin.user')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden sm:table-cell">
|
||||
{t('admin.role')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden md:table-cell">
|
||||
{t('admin.rooms')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden lg:table-cell">
|
||||
{t('admin.registered')}
|
||||
</th>
|
||||
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
||||
{t('admin.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(u => (
|
||||
<tr key={u.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{u.avatar_image ? (
|
||||
<img
|
||||
src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
u.name[0]?.toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{u.name}</p>
|
||||
<p className="text-xs text-th-text-s">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden sm:table-cell">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
u.role === 'admin'
|
||||
? 'bg-th-accent/15 text-th-accent'
|
||||
: 'bg-th-bg-t text-th-text-s'
|
||||
}`}>
|
||||
{u.role === 'admin' ? <Shield size={10} /> : <Users size={10} />}
|
||||
{u.role === 'admin' ? t('admin.admin') : t('admin.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-th-text hidden md:table-cell">
|
||||
{u.room_count}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-th-text-s hidden lg:table-cell">
|
||||
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-end relative">
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === u.id ? null : u.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
disabled={u.id === user.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{openMenu === u.id && u.id !== user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
|
||||
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('admin.deleteUser')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||
<p className="text-th-text-s text-sm">{t('admin.noUsersFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset password modal */}
|
||||
{resetPwModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetPwModal(null)} />
|
||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
|
||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
|
||||
<form onSubmit={handleResetPassword}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setResetPwModal(null)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create user modal */}
|
||||
{showCreateUser && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateUser(false)} />
|
||||
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.name}
|
||||
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="user">{t('admin.user')}</option>
|
||||
<option value="admin">{t('admin.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowCreateUser(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={creatingUser} className="btn-primary flex-1">
|
||||
{creatingUser ? <Loader2 size={18} className="animate-spin" /> : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user