Initial commit
This commit is contained in:
404
frontend/src/pages/Users.tsx
Normal file
404
frontend/src/pages/Users.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user