Initial commit

This commit is contained in:
2025-12-04 22:24:47 +01:00
commit 453ce10494
106 changed files with 17145 additions and 0 deletions

View File

@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } from 'react';
import Sidebar from '../components/Sidebar';
import { useAuth } from '../contexts/AuthContext';
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import { usersAPI } from '../api/client';
import type { User, UserCreate, UserUpdatePayload } from '../types';
import '../styles/Users.css';
type UserFormData = {
username: string;
email: string;
password: string;
is_active: boolean;
is_superuser: boolean;
};
const emptyForm: UserFormData = {
username: '',
email: '',
password: '',
is_active: true,
is_superuser: false,
};
export default function Users() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setModalOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [formData, setFormData] = useState<UserFormData>({ ...emptyForm });
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
const loadUsers = async () => {
setLoading(true);
setError('');
try {
const data = await usersAPI.list();
setUsers(data);
} catch (err: any) {
setError(err?.response?.data?.detail || t.usersPage.errorLoading);
} finally {
setLoading(false);
}
};
loadUsers();
}, [t.usersPage.errorLoading]);
const filteredUsers = useMemo(() => {
const term = searchTerm.toLowerCase().trim();
if (!term) return users;
return users.filter(
(u) =>
u.username.toLowerCase().includes(term) ||
u.email.toLowerCase().includes(term)
);
}, [users, searchTerm]);
const openCreateModal = () => {
setEditingUser(null);
setFormData({ ...emptyForm });
setModalOpen(true);
setError('');
};
const openEditModal = (user: User) => {
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
is_active: user.is_active,
is_superuser: user.is_superuser,
});
setModalOpen(true);
setError('');
};
const closeModal = () => {
setModalOpen(false);
setError('');
setFormData({ ...emptyForm });
setEditingUser(null);
};
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSaving(true);
setError('');
try {
if (editingUser) {
if (formData.password && formData.password.trim().length > 0 && formData.password.trim().length < 8) {
throw new Error('password-too-short');
}
const payload: UserUpdatePayload = {
username: formData.username,
email: formData.email,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
};
if (formData.password.trim()) {
payload.password = formData.password;
}
const updated = await usersAPI.update(editingUser.id, payload);
setUsers((prev) => prev.map((u) => (u.id === updated.id ? updated : u)));
} else {
if (!formData.password.trim()) {
throw new Error('password-required');
}
if (formData.password.trim().length < 8) {
throw new Error('password-too-short');
}
const payload: UserCreate = {
username: formData.username,
email: formData.email,
password: formData.password,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
};
const created = await usersAPI.create(payload);
setUsers((prev) => [created, ...prev]);
}
closeModal();
} catch (err: any) {
if (err?.message === 'password-required') {
setError(t.usersPage.passwordRequired);
} else if (err?.message === 'password-too-short') {
setError(t.usersPage.passwordTooShort);
} else {
setError(err?.response?.data?.detail || t.usersPage.saveError);
}
} finally {
setIsSaving(false);
}
};
const handleDelete = async (target: User) => {
if (currentUser?.id === target.id) {
setError(t.usersPage.selfDeleteWarning);
return;
}
const confirmed = window.confirm(t.usersPage.confirmDelete);
if (!confirmed) return;
setIsSaving(true);
setError('');
try {
await usersAPI.delete(target.id);
setUsers((prev) => prev.filter((u) => u.id !== target.id));
} catch (err: any) {
setError(err?.response?.data?.detail || t.usersPage.saveError);
} finally {
setIsSaving(false);
}
};
if (!currentUser?.is_superuser) {
return null;
}
return (
<div className="app-layout">
<Sidebar />
<main className="main-content users-root">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">group</span>
<span className="page-title-text">{t.admin.userManagement}</span>
</div>
<button className="btn-primary add-user-btn" onClick={openCreateModal}>
<span className="material-symbols-outlined">add</span>
<span>{t.usersPage.addUser}</span>
</button>
</div>
</div>
<div className="page-content users-page">
<div className="users-toolbar">
<div className="input-group">
<span className="material-symbols-outlined">search</span>
<input
type="text"
placeholder={t.usersPage.searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="users-badges">
<span className="badge badge-success">
{t.usersPage.active}: {users.filter((u) => u.is_active).length}
</span>
<span className="badge badge-neutral">
{t.usersPage.inactive}: {users.filter((u) => !u.is_active).length}
</span>
</div>
</div>
{error && <div className="users-alert">{error}</div>}
{loading ? (
<div className="loading">{t.common.loading}</div>
) : filteredUsers.length === 0 ? (
<div className="users-empty">{t.usersPage.noUsers}</div>
) : (
<div className="users-card">
<table className="users-table">
<thead>
<tr>
<th>{t.usersPage.name}</th>
<th>{t.usersPage.status}</th>
<th>{t.usersPage.role}</th>
<th>{t.usersPage.actions}</th>
</tr>
</thead>
<tbody>
{filteredUsers.map((u) => (
<tr key={u.id}>
<td>
<div className="user-cell">
<div className="user-avatar">
{u.username.charAt(0).toUpperCase()}
</div>
<div className="user-meta">
<span className="user-name">{u.username}</span>
<span className="user-email">{u.email}</span>
<span className="user-id">
{t.dashboard.userId}: {u.id}
</span>
</div>
</div>
</td>
<td>
<span
className={`badge ${u.is_active ? 'badge-success' : 'badge-muted'}`}
>
{u.is_active ? t.usersPage.active : t.usersPage.inactive}
</span>
</td>
<td>
<span
className={`badge ${u.is_superuser ? 'badge-accent' : 'badge-neutral'}`}
>
{u.is_superuser ? t.usersPage.superuser : t.usersPage.regular}
</span>
</td>
<td>
<div className="users-actions">
<button
className="btn-ghost"
onClick={() => openEditModal(u)}
disabled={isSaving}
>
<span className="material-symbols-outlined">edit</span>
{t.usersPage.edit}
</button>
<button
className="btn-ghost danger"
onClick={() => handleDelete(u)}
disabled={isSaving || currentUser?.id === u.id}
title={
currentUser?.id === u.id
? t.usersPage.selfDeleteWarning
: undefined
}
>
<span className="material-symbols-outlined">delete</span>
{t.usersPage.delete}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
{isModalOpen && (
<div className="users-modal-backdrop" onClick={closeModal}>
<div className="users-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>
{editingUser ? t.usersPage.editUser : t.usersPage.addUser}
</h2>
<button className="btn-icon btn-close" onClick={closeModal} aria-label={t.common.close}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
{error && <div className="users-alert in-modal">{error}</div>}
<form onSubmit={handleSubmit} className="users-form">
<div className="form-row">
<label htmlFor="username">{t.usersPage.name}</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) =>
setFormData((prev) => ({ ...prev, username: e.target.value }))
}
required
minLength={3}
/>
</div>
<div className="form-row">
<label htmlFor="email">{t.usersPage.email}</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData((prev) => ({ ...prev, email: e.target.value }))
}
required
/>
</div>
<div className="form-row">
<label htmlFor="password">
{t.usersPage.password}{' '}
{editingUser ? (
<span className="helper-text">{t.usersPage.passwordHintEdit}</span>
) : (
<span className="helper-text">{t.usersPage.passwordHintCreate}</span>
)}
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData((prev) => ({ ...prev, password: e.target.value }))
}
minLength={formData.password ? 8 : undefined}
/>
</div>
<div className="form-grid">
<label className="checkbox-row">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData((prev) => ({ ...prev, is_active: e.target.checked }))
}
/>
<span>{t.usersPage.isActive}</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={formData.is_superuser}
onChange={(e) =>
setFormData((prev) => ({
...prev,
is_superuser: e.target.checked,
}))
}
/>
<span>{t.usersPage.isSuperuser}</span>
</label>
</div>
<div className="modal-actions">
<button type="button" className="btn-ghost" onClick={closeModal}>
{t.common.cancel}
</button>
<button type="submit" className="btn-primary" disabled={isSaving}>
{isSaving ? t.common.loading : t.usersPage.save}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}