Init v1.0.0
Some checks failed
Build & Push Docker Image / build (push) Failing after 53s

This commit is contained in:
2026-02-24 18:14:16 +01:00
commit 54d6ee553a
49 changed files with 10410 additions and 0 deletions

368
src/pages/Admin.jsx Normal file
View 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>
);
}