diff --git a/backend/src/db/index.js b/backend/src/db/index.js
index 0061486..84a56fb 100644
--- a/backend/src/db/index.js
+++ b/backend/src/db/index.js
@@ -1800,4 +1800,26 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_2fa_dev_uid ON two_fa_trusted_devices(
console.log('[DB] Migrations 2FA OK');
+// ── Table invitations ────────────────────────────────────────────────────────
+{
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='invitations'").get();
+ if (!tables) {
+ db.exec(`
+ CREATE TABLE invitations (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ token TEXT NOT NULL UNIQUE,
+ email TEXT NOT NULL,
+ role TEXT NOT NULL DEFAULT 'user',
+ invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
+ expires_at TEXT NOT NULL,
+ used_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_invitations_token ON invitations(token)');
+ console.log('[DB] Table invitations créée');
+ }
+}
+
+
export default db;
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index 9f7b918..bc483e2 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -48,7 +48,7 @@ const router = Router();
/** Liste tous les utilisateurs */
router.get('/users', (req, res) => {
const users = db.prepare(`
- SELECT id, email, display_name, role, email_verified, totp_enabled, created_at
+ SELECT id, email, display_name, role, email_verified, totp_enabled, status, created_at
FROM users
ORDER BY id ASC
`).all();
@@ -96,6 +96,25 @@ router.post('/users', (req, res, next) => {
} catch (e) { next(e); }
});
+/** Modifie le statut d'un utilisateur (active / deactivated / locked) */
+const PatchStatusSchema = z.object({
+ status: z.enum(['active', 'deactivated', 'locked']),
+});
+
+router.patch('/users/:id/status', (req, res, next) => {
+ try {
+ const { status } = PatchStatusSchema.parse(req.body);
+ const targetId = Number(req.params.id);
+ if (targetId === req.user.id) {
+ throw new HttpError(400, 'Vous ne pouvez pas modifier votre propre statut');
+ }
+ const r = db.prepare("UPDATE users SET status=?, updated_at=datetime('now') WHERE id=?")
+ .run(status, targetId);
+ if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable');
+ res.json({ id: targetId, status });
+ } catch (e) { next(e); }
+});
+
/** Modifie le rôle d'un utilisateur */
const PatchRoleSchema = z.object({
role: z.enum(['user', 'admin']),
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index f58a202..c6e5bfe 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -101,13 +101,20 @@ router.post('/login', async (req, res, next) => {
try {
const body = LoginSchema2FA.parse(req.body);
const user = db
- .prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled FROM users WHERE email = ?')
+ .prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled, status FROM users WHERE email = ?')
.get(body.email);
if (!user) throw new HttpError(401, 'Invalid credentials');
const ok = bcrypt.compareSync(body.password, user.password_hash);
if (!ok) throw new HttpError(401, 'Invalid credentials');
+ if (user.status === 'deactivated') {
+ return res.status(403).json({ error: 'Ce compte a été désactivé. Contactez un administrateur.', code: 'ACCOUNT_DEACTIVATED' });
+ }
+ if (user.status === 'locked') {
+ return res.status(403).json({ error: 'Ce compte est verrouillé. Contactez un administrateur.', code: 'ACCOUNT_LOCKED' });
+ }
+
if (!user.email_verified) {
return res.status(403).json({
error: 'Veuillez vérifier votre adresse email avant de vous connecter.',
diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js
new file mode 100644
index 0000000..525947f
--- /dev/null
+++ b/backend/src/routes/invitations.js
@@ -0,0 +1,151 @@
+/**
+ * /api/invitations
+ *
+ * Routes publiques (pas de requireAuth) :
+ * GET /:token — valide le token, retourne email + role
+ * POST /:token/register — finalise l'inscription
+ *
+ * Route admin :
+ * POST / — envoie une invitation (requireAdmin dans server.js)
+ * GET / — liste les invitations en cours (requireAdmin)
+ * DELETE /:id — révoque une invitation (requireAdmin)
+ */
+
+import { Router } from 'express';
+import crypto from 'crypto';
+import bcrypt from 'bcryptjs';
+import { z } from 'zod';
+import db from '../db/index.js';
+import { HttpError } from '../middleware/errorHandler.js';
+import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js';
+
+const router = Router();
+
+// ── Schémas Zod ──────────────────────────────────────────────────────────────
+
+const InviteSchema = z.object({
+ email: z.string().email(),
+ role: z.enum(['user', 'admin']).default('user'),
+});
+
+const RegisterSchema = z.object({
+ displayName: z.string().max(100).optional(),
+ password: z.string().min(8),
+});
+
+// ── Admin : envoyer une invitation ───────────────────────────────────────────
+
+router.post('/', async (req, res, next) => {
+ try {
+ const { email, role } = InviteSchema.parse(req.body);
+
+ // Vérifier que l'email n'est pas déjà utilisé
+ const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(email);
+ if (existing) throw new HttpError(409, 'Un compte avec cet email existe déjà.');
+
+ // Révoquer toute invitation en cours non utilisée pour cet email
+ db.prepare("DELETE FROM invitations WHERE LOWER(email) = LOWER(?) AND used_at IS NULL").run(email);
+
+ // Créer le token (7 jours)
+ const token = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
+
+ db.prepare(`
+ INSERT INTO invitations (token, email, role, invited_by, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ `).run(token, email, role, req.user.id, expiresAt);
+
+ // Envoyer l'email
+ const cfg = getSmtpConfig();
+ const appUrl = cfg.appUrl || '';
+ const inviteUrl = `${appUrl}/invitation/${token}`;
+ const roleLabel = role === 'admin' ? 'Administrateur' : 'Utilisateur';
+
+ await sendMail({
+ to: email,
+ subject: `Invitation à rejoindre ${cfg.appName}`,
+ html: buildEmailHtml({
+ title: 'Vous avez été invité',
+ body: `
+
Vous avez reçu une invitation à rejoindre ${cfg.appName}
+ en tant que ${roleLabel}.
+ Cliquez sur le bouton ci-dessous pour créer votre compte.
+ Ce lien est valable 7 jours.
+ `,
+ ctaLabel: 'Créer mon compte',
+ ctaUrl: inviteUrl,
+ }),
+ });
+
+ res.json({ ok: true, email, expiresAt });
+ } catch (e) { next(e); }
+});
+
+// ── Admin : liste des invitations ─────────────────────────────────────────────
+
+router.get('/', (req, res, next) => {
+ try {
+ const rows = db.prepare(`
+ SELECT i.id, i.email, i.role, i.expires_at, i.used_at, i.created_at,
+ u.display_name AS invited_by_name, u.email AS invited_by_email
+ FROM invitations i
+ LEFT JOIN users u ON u.id = i.invited_by
+ ORDER BY i.created_at DESC
+ LIMIT 200
+ `).all();
+ res.json(rows);
+ } catch (e) { next(e); }
+});
+
+// ── Admin : révoquer une invitation ───────────────────────────────────────────
+
+router.delete('/:id', (req, res, next) => {
+ try {
+ const r = db.prepare('DELETE FROM invitations WHERE id = ? AND used_at IS NULL').run(Number(req.params.id));
+ if (r.changes === 0) throw new HttpError(404, 'Invitation introuvable ou déjà utilisée.');
+ res.json({ ok: true });
+ } catch (e) { next(e); }
+});
+
+// ── Public : valider un token ─────────────────────────────────────────────────
+
+router.get('/:token', (req, res, next) => {
+ try {
+ const inv = db.prepare('SELECT * FROM invitations WHERE token = ?').get(req.params.token);
+ if (!inv) throw new HttpError(404, 'Lien d\'invitation invalide.');
+ if (inv.used_at) throw new HttpError(410, 'Ce lien a déjà été utilisé.');
+ if (new Date(inv.expires_at) < new Date()) throw new HttpError(410, 'Ce lien a expiré.');
+ res.json({ email: inv.email, role: inv.role, expiresAt: inv.expires_at });
+ } catch (e) { next(e); }
+});
+
+// ── Public : finaliser l'inscription ─────────────────────────────────────────
+
+router.post('/:token/register', async (req, res, next) => {
+ try {
+ const { displayName, password } = RegisterSchema.parse(req.body);
+
+ const inv = db.prepare('SELECT * FROM invitations WHERE token = ?').get(req.params.token);
+ if (!inv) throw new HttpError(404, 'Lien d\'invitation invalide.');
+ if (inv.used_at) throw new HttpError(410, 'Ce lien a déjà été utilisé.');
+ if (new Date(inv.expires_at) < new Date()) throw new HttpError(410, 'Ce lien a expiré.');
+
+ // Vérifier que l'email n'est pas déjà pris (race condition)
+ const existing = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)').get(inv.email);
+ if (existing) throw new HttpError(409, 'Un compte avec cet email existe déjà.');
+
+ const hash = await bcrypt.hash(password, 12);
+
+ const result = db.prepare(`
+ INSERT INTO users (email, password_hash, display_name, role, email_verified, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 1, 'active', datetime('now'), datetime('now'))
+ `).run(inv.email, hash, displayName || null, inv.role);
+
+ // Marquer l'invitation comme utilisée
+ db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id);
+
+ res.json({ ok: true, userId: result.lastInsertRowid });
+ } catch (e) { next(e); }
+});
+
+export default router;
diff --git a/backend/src/server.js b/backend/src/server.js
index 4fa3e0c..e047cd0 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -33,6 +33,7 @@ import { errorHandler } from './middleware/errorHandler.js';
import { requireAuth, requireAdmin } from './middleware/auth.js';
import { startAutoStatutJob } from './jobs/autoStatut.js';
import adminRouter from './routes/admin.js';
+import invitationsRouter from './routes/invitations.js';
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
import referentielRouter from './routes/referentiel.js';
import referentielPublicRouter from './routes/referentielPublic.js';
@@ -108,6 +109,9 @@ app.use('/api/comptes', requireAuth, comptesRouter);
app.use('/api/preferences', requireAuth, preferencesRouter);
app.use('/api/icons', requireAuth, iconsRouter);
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
+// Invitations : routes admin protégées + routes publiques (register/validate)
+app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter);
+app.use('/api/invitations', invitationsRouter);
app.use('/api/taux-credit-impot', requireAuth, tauxCreditImpotRouter);
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a783421..392b6c2 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from './context/AuthContext.jsx';
import Login from './pages/Login.jsx';
import Register from './pages/Register.jsx';
+import InvitationRegister from './pages/InvitationRegister.jsx';
import ForgotPassword from './pages/ForgotPassword.jsx';
import ResetPassword from './pages/ResetPassword.jsx';
import VerifyEmail from './pages/VerifyEmail.jsx';
@@ -41,6 +42,7 @@ export default function App() {
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
index 842c708..44a382a 100644
--- a/frontend/src/pages/Admin.jsx
+++ b/frontend/src/pages/Admin.jsx
@@ -1,15 +1,12 @@
-import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
-import UsersSection from './admin/UsersSection.jsx';
-import CreateUserSection from './admin/CreateUserSection.jsx';
-import JobLogsSection from './admin/JobLogsSection.jsx';
-import IconsSection from './admin/IconsSection.jsx';
-import SmtpSection from './admin/SmtpSection.jsx';
+import UsersSection from './admin/UsersSection.jsx';
+import JobLogsSection from './admin/JobLogsSection.jsx';
+import IconsSection from './admin/IconsSection.jsx';
+import SmtpSection from './admin/SmtpSection.jsx';
/* ── Icônes nav ───────────────────────────────────────────────── */
function IconUsers() { return ; }
-function IconUserPlus() { return ; }
function IconActivity() { return ; }
function IconImage() { return ; }
function IconTax() { return ; }
@@ -21,7 +18,6 @@ const NAV = [
group: 'Administration de la plateforme',
items: [
{ id: 'users', label: 'Utilisateurs', icon: },
- { id: 'create', label: 'Créer un utilisateur', icon: },
{ id: 'job-logs', label: 'Logs des jobs', icon: },
{ id: 'icons', label: "Bibliothèque d'icônes", icon: },
],
@@ -44,7 +40,6 @@ const NAV = [
export default function Admin() {
const { search } = useLocation();
const navigate = useNavigate();
- const [refreshKey, setRefreshKey] = useState(0);
const { user } = useAuth();
const section = new URLSearchParams(search).get('section') || 'users';
@@ -72,8 +67,7 @@ export default function Admin() {
))}
- {section === 'users' &&
}
- {section === 'create' &&
setRefreshKey(k => k + 1)} />}
+ {section === 'users' && }
{section === 'job-logs' && }
{section === 'icons' && }
{section === 'smtp' && }
diff --git a/frontend/src/pages/InvitationRegister.jsx b/frontend/src/pages/InvitationRegister.jsx
new file mode 100644
index 0000000..e809840
--- /dev/null
+++ b/frontend/src/pages/InvitationRegister.jsx
@@ -0,0 +1,193 @@
+import { useState, useEffect } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+
+export default function InvitationRegister() {
+ const { token } = useParams();
+ const navigate = useNavigate();
+ const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null });
+ const [inv, setInv] = useState(null); // { email, role, expiresAt }
+ const [status, setStatus] = useState('loading'); // loading | valid | invalid | done
+ const [errMsg, setErrMsg] = useState('');
+ const [form, setForm] = useState({ displayName: '', password: '', confirm: '' });
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+
+ useEffect(() => {
+ fetch('/api/app-info').then(r => r.json()).then(setAppInfo).catch(() => {});
+ }, []);
+
+ useEffect(() => {
+ fetch(`/api/invitations/${token}`)
+ .then(async r => {
+ if (!r.ok) {
+ const d = await r.json().catch(() => ({}));
+ throw new Error(d.error || 'Lien invalide.');
+ }
+ return r.json();
+ })
+ .then(data => { setInv(data); setStatus('valid'); })
+ .catch(e => { setErrMsg(e.message); setStatus('invalid'); });
+ }, [token]);
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
+
+ const submit = async (e) => {
+ e.preventDefault();
+ if (form.password !== form.confirm) { setErr('Les mots de passe ne correspondent pas.'); return; }
+ setBusy(true); setErr(null);
+ try {
+ const r = await fetch(`/api/invitations/${token}/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ displayName: form.displayName || undefined, password: form.password }),
+ });
+ if (!r.ok) {
+ const d = await r.json().catch(() => ({}));
+ throw new Error(d.error || 'Erreur lors de la création du compte.');
+ }
+ setStatus('done');
+ } catch (e) { setErr(e.message); }
+ finally { setBusy(false); }
+ };
+
+ // ── Layout commun ────────────────────────────────────────────────────────
+ const Wrap = ({ children }) => (
+
+
+

{ e.target.style.display = 'none'; }}
+ style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} />
+
+ {appInfo.iconUrl &&

}
+
{appInfo.appName}
+
+
+
+
+ {appInfo.iconUrl &&

}
+
{appInfo.appName}
+
+
{children}
+
+
+
+ );
+
+ // ── Chargement ───────────────────────────────────────────────────────────
+ if (status === 'loading') {
+ return (
+
+ Vérification du lien…
+
+ );
+ }
+
+ // ── Lien invalide / expiré ───────────────────────────────────────────────
+ if (status === 'invalid') {
+ return (
+
+
+
+
+
+
Lien invalide
+
{errMsg}
+
+ Retour à la connexion
+
+
+
+ );
+ }
+
+ // ── Succès ───────────────────────────────────────────────────────────────
+ if (status === 'done') {
+ return (
+
+
+
+
Compte créé !
+
+ Votre compte a été activé avec succès. Vous pouvez maintenant vous connecter.
+
+
+
+
+ );
+ }
+
+ // ── Formulaire ───────────────────────────────────────────────────────────
+ const roleLabel = inv?.role === 'admin' ? 'Administrateur' : 'Utilisateur';
+
+ return (
+
+
+
+ Finaliser votre inscription
+
+
+ Vous avez été invité en tant que {roleLabel}.
+
+
+
+ {err && (
+
+ {err}
+
+ )}
+
+
+
+
+ Déjà inscrit ?{' '}
+ Se connecter
+
+
+ );
+}
diff --git a/frontend/src/pages/admin/UsersSection.jsx b/frontend/src/pages/admin/UsersSection.jsx
index c4361e4..0928a26 100644
--- a/frontend/src/pages/admin/UsersSection.jsx
+++ b/frontend/src/pages/admin/UsersSection.jsx
@@ -1,146 +1,646 @@
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import * as XLSX from 'xlsx';
import { api } from '../../api.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
-import { fmt, Badge } from './adminHelpers.jsx';
+import Modal from '../../components/Modal.jsx';
+import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+function computedStatus(u) {
+ if (u.status === 'deactivated') return 'deactivated';
+ if (u.status === 'locked') return 'locked';
+ if (!u.email_verified) return 'pending';
+ return 'active';
+}
+
+const STATUS_OPTIONS = [
+ { value: 'all', label: 'Tous' },
+ { value: 'active', label: 'Actif' },
+ { value: 'pending', label: 'En attente' },
+ { value: 'deactivated', label: 'Désactivé' },
+ { value: 'locked', label: 'Verrouillé' },
+];
+
+const ROLE_OPTIONS = [
+ { value: 'all', label: 'Tous' },
+ { value: 'user', label: 'Utilisateur' },
+ { value: 'admin', label: 'Admin' },
+];
+
+const AVATAR_COLORS = [
+ ['#dbeafe','#1d4ed8'], ['#fce7f3','#be185d'], ['#d1fae5','#065f46'],
+ ['#fef3c7','#92400e'], ['#ede9fe','#5b21b6'], ['#fee2e2','#991b1b'],
+ ['#cffafe','#0e7490'], ['#dcfce7','#166534'],
+];
+
+function avatarColor(name) {
+ const code = [...(name || '?')].reduce((a, c) => a + c.charCodeAt(0), 0);
+ return AVATAR_COLORS[code % AVATAR_COLORS.length];
+}
+
+function initials(u) {
+ const n = u.display_name || u.email;
+ const parts = n.trim().split(/\s+/);
+ return parts.length >= 2
+ ? (parts[0][0] + parts[1][0]).toUpperCase()
+ : n.slice(0, 2).toUpperCase();
+}
+
+// ── Icônes SVG ─────────────────────────────────────────────────────────────
+const IcoEdit = () => ;
+const IcoCheck = () => ;
+const IcoMail = () => ;
+const IcoPause = () => ;
+const IcoPlay = () => ;
+const IcoTrash = () => ;
+const IcoDots = () => ;
+const IcoSearch = () => ;
+
+function MenuBtn({ onClick, icon, label, danger = false }) {
+ return (
+
+ );
+}
+
+// ── Export ─────────────────────────────────────────────────────────────────
+const STATUS_FR = { active: 'Actif', pending: 'En attente', deactivated: 'Désactivé', locked: 'Verrouillé' };
+
+function toRows(users) {
+ return users.map(u => ({
+ ID: u.id, Nom: u.display_name || '', Email: u.email,
+ Statut: STATUS_FR[computedStatus(u)] || '',
+ Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
+ '2FA': u.totp_enabled ? 'Oui' : 'Non',
+ 'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
+ }));
+}
+
+function dlBlob(content, filename, type) {
+ const blob = new Blob([content], { type });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url; a.download = filename; a.click();
+ URL.revokeObjectURL(url);
+}
+
+function toCSV(users) {
+ const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', '2FA', 'Inscrit le'];
+ const rows = toRows(users).map(r => Object.values(r).map(v => `"${String(v).replace(/"/g,'""')}"`).join(','));
+ return '' + [headers.map(h => `"${h}"`).join(','), ...rows].join('\n');
+}
+function toXLSX(users) {
+ const ws = XLSX.utils.json_to_sheet(toRows(users));
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, 'Utilisateurs');
+ return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
+}
+function toJSON(users) { return JSON.stringify(toRows(users), null, 2); }
+
+function ExportDropdown({ onCSV, onXLSX, onJSON }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, [open]);
+ const choose = fn => { setOpen(false); fn(); };
+ return (
+
+
+ {open && (
+
+
+
+
+
+ )}
+
+ );
+}
+
+// ── Pagination ─────────────────────────────────────────────────────────────
+const PAGE_SIZES = [10, 15, 25, 50];
+
+function AdminPagination({ page, setPage, pageSize, setPageSize, total }) {
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ if (total === 0) return null;
+ const delta = 2;
+ const pages = [];
+ for (let i = Math.max(1, page - delta); i <= Math.min(totalPages, page + delta); i++) pages.push(i);
+ return (
+
+
+
+ Page {page} sur {totalPages}
+
+
+
setPage(p => Math.max(1, p - 1))} disabled={page === 1}>‹
+ {pages[0] > 1 && <>
setPage(1)}>1{pages[0] > 2 &&
}>}
+ {pages.map(n =>
setPage(n)} active={n === page}>{n})}
+ {pages[pages.length - 1] < totalPages && <>{pages[pages.length - 1] < totalPages - 1 &&
}
setPage(totalPages)}>{totalPages}>}
+
setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>›
+
+
+ );
+}
+const Ellipsis = () => …;
+function PBtn({ onClick, disabled, active, children }) {
+ return (
+
+ );
+}
+
+// ── Composant principal ────────────────────────────────────────────────────
+// ── Modale invitation ──────────────────────────────────────────────────────
+function InviteUserModal({ open, onClose }) {
+ const [form, setForm] = useState({ email: '', role: 'user' });
+ const [loading, setLoading] = useState(false);
+ const [err, setErr] = useState(null);
+ const [sent, setSent] = useState(false);
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
+ const reset = () => { setForm({ email: '', role: 'user' }); setErr(null); setSent(false); };
+ const handleClose = () => { reset(); onClose(); };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true); setErr(null);
+ try {
+ await api.post('/admin/invitations', { email: form.email, role: form.role });
+ setSent(true);
+ } catch (e) { setErr(e.message); }
+ finally { setLoading(false); }
+ };
+
+ return (
+ Fermer
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ {sent ? (
+
+
+
Invitation envoyée !
+
+ Un email a été envoyé à {form.email}.
Le lien est valable 7 jours.
+
+
+ ) : (
+ <>
+ {err && {err}
}
+
+ >
+ )}
+
+ );
+}
+
+// ── Modale création utilisateur ────────────────────────────────────────────
+function CreateUserModal({ open, onClose, onCreated }) {
+ const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
+ const [loading, setLoading] = useState(false);
+ const [err, setErr] = useState(null);
+
+ const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
+
+ const reset = () => { setForm({ email: '', password: '', displayName: '', role: 'user' }); setErr(null); };
+
+ const handleClose = () => { reset(); onClose(); };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true); setErr(null);
+ try {
+ const created = await api.post('/admin/users', {
+ email: form.email,
+ password: form.password,
+ displayName: form.displayName || undefined,
+ role: form.role,
+ });
+ reset();
+ onCreated?.(created);
+ onClose();
+ } catch (e) { setErr(e.message); }
+ finally { setLoading(false); }
+ };
+
+ return (
+
+
+
+ >}
+ >
+ {err && {err}
}
+
+
+ );
+}
+
+// ── Composant principal ────────────────────────────────────────────────────
export default function UsersSection({ currentUserId }) {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [confirmAction, setConfirmAction] = useState(null);
+ const [showCreate, setShowCreate] = useState(false);
+ const [showInvite, setShowInvite] = useState(false);
+ const [search, setSearch] = useState('');
+ const [filterStatus, setFilterStatus] = useState('all');
+ const [filterRole, setFilterRole] = useState('all');
+ const [openMenu, setOpenMenu] = useState(null);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
const load = useCallback(async () => {
- try {
- setLoading(true);
- const data = await api.get('/admin/users');
- setUsers(data);
- } catch (e) { setErr(e.message); }
+ try { setLoading(true); setUsers(await api.get('/admin/users')); }
+ catch (e) { setErr(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
+ useEffect(() => { setPage(1); }, [search, filterStatus, filterRole]);
+ useEffect(() => {
+ if (!openMenu) return;
+ const close = () => setOpenMenu(null);
+ window.addEventListener('scroll', close, true);
+ return () => window.removeEventListener('scroll', close, true);
+ }, [openMenu]);
- const toggleRole = (u) => {
+ // ── Filtrage ──────────────────────────────────────────────────────────
+ const filtered = users.filter(u => {
+ const q = search.toLowerCase();
+ if (q && !u.email.toLowerCase().includes(q) && !(u.display_name || '').toLowerCase().includes(q)) return false;
+ if (filterStatus !== 'all' && computedStatus(u) !== filterStatus) return false;
+ if (filterRole !== 'all' && u.role !== filterRole) return false;
+ return true;
+ });
+
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
+ const safePage = Math.min(page, totalPages);
+ const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);
+
+ // ── Actions ───────────────────────────────────────────────────────────
+ const confirm = (title, message, onConfirm, confirmLabel) =>
+ setConfirmAction({ title, message, onConfirm, confirmLabel });
+
+ const toggleRole = u => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
- setConfirmAction({
- title: 'Changer le rôle',
- message: `Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
- confirmLabel: 'Confirmer',
- onConfirm: async () => {
- try {
- await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
- load();
- } catch (e) { setErr('Erreur : ' + e.message); }
- finally { setConfirmAction(null); }
- },
- });
+ confirm('Changer le rôle',
+ `Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
+ async () => { await api.patch(`/admin/users/${u.id}/role`, { role: newRole }); load(); }, 'Confirmer');
+ };
+ const verifyEmail = u => confirm("Vérifier l'email",
+ `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
+ async () => { await api.patch(`/admin/users/${u.id}/verify-email`, {}); load(); }, 'Confirmer');
+ const resendVerif = async u => {
+ try { await api.post('/auth/resend-verification', { email: u.email }); alert(`Email envoyé à ${u.email}`); }
+ catch (e) { setErr(e.message); }
+ };
+ const setStatus = (u, status) => {
+ const labels = { deactivated: 'Désactiver', active: 'Réactiver', locked: 'Verrouiller' };
+ confirm(`${labels[status]} le compte`, `${labels[status]} le compte de ${u.display_name || u.email} ?`,
+ async () => { await api.patch(`/admin/users/${u.id}/status`, { status }); load(); }, labels[status]);
+ };
+ const deleteUser = u => confirm("Supprimer l'utilisateur",
+ `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
+ async () => { await api.del(`/admin/users/${u.id}`); load(); });
+
+ const openMenuFor = (e, u) => {
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ setOpenMenu({ user: u, x: rect.right, y: rect.bottom });
};
- const verifyEmail = (u) => {
- setConfirmAction({
- title: 'Vérifier l\'email manuellement',
- message: `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
- confirmLabel: 'Confirmer',
- onConfirm: async () => {
- try {
- await api.patch(`/admin/users/${u.id}/verify-email`, {});
- load();
- } catch (e) { setErr('Erreur : ' + e.message); }
- finally { setConfirmAction(null); }
- },
- });
+ // ── Pill dropdown custom ───────────────────────────────────────────────
+ const PillDropdown = ({ label, value, options, onChange }) => {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ const selected = options.find(o => o.value === value);
+ useEffect(() => {
+ if (!open) return;
+ const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
+ document.addEventListener('mousedown', h);
+ return () => document.removeEventListener('mousedown', h);
+ }, [open]);
+ return (
+
+
+ {open && (
+
+ {options.map(o => (
+
+ ))}
+
+ )}
+
+ );
};
- const deleteUser = (u) => {
- setConfirmAction({
- title: 'Supprimer l\'utilisateur',
- message: `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
- onConfirm: async () => {
- try {
- await api.del(`/admin/users/${u.id}`);
- load();
- } catch (e) { setErr('Erreur : ' + e.message); }
- finally { setConfirmAction(null); }
- },
- });
- };
+ const today = new Date().toISOString().slice(0, 10);
if (loading) return Chargement…
;
if (err) return {err}
;
return (
<>
-
-
Comptes utilisateurs
-
- {users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
-
-
-
-
- | ID |
- Nom |
- Email |
- Email vérifié |
- 2FA |
- Rôle |
- Créé le |
- Actions |
-
-
-
- {users.map(u => (
-
- | {u.id} |
- {u.display_name || —} |
- {u.email} |
-
- {u.email_verified
- ? ✓ Vérifié
- : En attente
- }
- |
-
- {u.totp_enabled
- ? 🔐 Activé
- : —
- }
- |
- |
- {fmt(u.created_at)} |
-
-
-
- {!u.email_verified && (
-
- )}
- {u.id !== currentUserId && (
-
- )}
-
- |
-
- ))}
-
-
+
+
+ {/* En-tête */}
+
+
+
Comptes utilisateurs
+
+ Gérez les comptes et les accès des membres de l'application.
+
+
+
+
+
+ setSearch(e.target.value)} />
+ {search && (
+
+ )}
+
+
dlBlob(toCSV(filtered), `utilisateurs_${today}.csv`, 'text/csv;charset=utf-8')}
+ onXLSX={() => dlBlob(toXLSX(filtered), `utilisateurs_${today}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
+ onJSON={() => dlBlob(toJSON(filtered), `utilisateurs_${today}.json`, 'application/json')}
+ />
+
+
+
+
+
+ {/* Filtres pills */}
+
+
+
+
+ {filtered.length !== users.length
+ ? `${filtered.length} / ${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
+ : `${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
+ }
+
+ {(filterRole !== 'all' || filterStatus !== 'all' || search) && (
+
+ )}
+
+
+ {/* Tableau */}
+ {filtered.length === 0 ? (
+
+ Aucun utilisateur ne correspond aux filtres.
+
+ ) : (
+
+
+
+
+ | Utilisateur |
+ Statut |
+ Rôle |
+ 2FA |
+ Inscrit le |
+ |
+
+
+
+ {paged.map(u => {
+ const [bg, fg] = avatarColor(u.display_name || u.email);
+ return (
+
+
+
+
+ {initials(u)}
+
+
+
+ {u.display_name || {u.email.split('@')[0]}}
+ {u.id === currentUserId && (vous)}
+
+ {u.email}
+
+
+ |
+ |
+ |
+
+ {u.totp_enabled
+ ? 🔐 Actif
+ : —
+ }
+ |
+ {fmt(u.created_at)} |
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ )}
+
+ {/* Menu ⋮ contextuel */}
+ {openMenu && (
+ <>
+
setOpenMenu(null)} />
+
+
} label={openMenu.user.role === 'admin' ? '→ Utilisateur' : '→ Admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
+ {!openMenu.user.email_verified && <>
+
} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
+
} label="Renvoyer la vérification" onClick={() => { setOpenMenu(null); resendVerif(openMenu.user); }} />
+ >}
+ {openMenu.user.id !== currentUserId && <>
+ {(!openMenu.user.status || openMenu.user.status === 'active') &&
} label="Désactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'deactivated'); }} />}
+ {openMenu.user.status === 'deactivated' &&
} label="Réactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
+ {openMenu.user.status === 'locked' &&
} label="Déverrouiller" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
+
+
} label="Supprimer" danger onClick={() => { setOpenMenu(null); deleteUser(openMenu.user); }} />
+ >}
+
+ >
+ )}
+
+
setShowInvite(false)}
+ />
+
+ setShowCreate(false)}
+ onCreated={() => load()}
+ />
+
{
+ try { await confirmAction.onConfirm(); }
+ catch (e) { setErr(e.message); }
+ finally { setConfirmAction(null); }
+ }}
onCancel={() => setConfirmAction(null)}
/>
>
diff --git a/frontend/src/pages/admin/adminHelpers.jsx b/frontend/src/pages/admin/adminHelpers.jsx
index d79bbee..be70a6b 100644
--- a/frontend/src/pages/admin/adminHelpers.jsx
+++ b/frontend/src/pages/admin/adminHelpers.jsx
@@ -25,6 +25,33 @@ export function Badge({ role }) {
);
}
+/** Badge statut utilisateur — dérivé de status + email_verified */
+export function UserStatusBadge({ status, emailVerified }) {
+ // status: 'active' | 'deactivated' | 'locked'
+ // Le statut affiché est une combinaison des deux champs
+ let label, bg, color, border;
+ if (status === 'deactivated') {
+ label = 'Désactivé'; bg = 'rgba(100,116,139,.12)'; color = '#64748b'; border = 'rgba(100,116,139,.3)';
+ } else if (status === 'locked') {
+ label = 'Verrouillé'; bg = 'rgba(239,68,68,.12)'; color = '#dc2626'; border = 'rgba(239,68,68,.3)';
+ } else if (!emailVerified) {
+ label = 'En attente'; bg = 'rgba(245,158,11,.12)'; color = '#d97706'; border = 'rgba(245,158,11,.3)';
+ } else {
+ label = 'Actif'; bg = 'rgba(34,197,94,.12)'; color = '#16a34a'; border = 'rgba(34,197,94,.3)';
+ }
+ return (
+
+
+ {label}
+
+ );
+}
+
export function StatusBadge({ status }) {
const ok = status === 'ok';
return (
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index b073999..8e22275 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -251,6 +251,25 @@ a { color: var(--primary); }
}
.btn-add-invest:hover { background: var(--primary-hover); box-shadow: 0 3px 12px rgba(59,130,246,.5); }
+/* ── Boutons génériques ──────────────────────────────────────── */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
+ padding: 0 14px; height: 32px;
+ border-radius: 8px; border: 1px solid transparent;
+ font-size: var(--fs-sm); font-weight: 500;
+ cursor: pointer; white-space: nowrap;
+ transition: background .15s, border-color .15s, box-shadow .15s;
+}
+.btn-sm { height: 32px; padding: 0 12px; font-size: var(--fs-xs); }
+.btn-primary {
+ background: var(--primary); color: #fff; border-color: var(--primary);
+}
+.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
+.btn-outline {
+ background: var(--surface); color: var(--text); border-color: var(--border);
+}
+.btn-outline:hover { background: var(--surface-2); border-color: var(--border); }
+
/* ── Burger menu topbar ──────────────────────────────────────── */
.topbar-burger-wrap { position: relative; display: none; }
.topbar-burger-btn {
@@ -426,10 +445,10 @@ a { color: var(--primary); }
display: flex;
align-items: center;
gap: 8px;
- height: 40px;
- padding: 0 16px;
+ height: 32px;
+ padding: 0 12px;
border: 1.5px solid var(--primary);
- border-radius: 20px;
+ border-radius: 8px;
background: transparent;
color: var(--primary);
min-width: 330px;
@@ -1895,4 +1914,101 @@ tr:hover td { background: var(--surface-2); }
.drill-table tr:hover td { background: inherit; }
/* Les lignes cliquables ont leur propre hover via opacity inline — on protège
les lignes "header de section" (sous-total, total) contre le global hover. */
-.drill-table tr.drill-row-fixed:hover td { background: inherit !important; }
+.drill-table tr.drill-row-fixed:hove
+/* ── Pagination ──────────────────────────────────────────────────────────── */
+.pagination-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 2px 2px;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+.pagination-info {
+ font-size: var(--fs-xs);
+ color: var(--text-muted);
+ white-space: nowrap;
+}
+.pagination-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+.pagination-size-label {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: var(--fs-xs);
+ color: var(--text-muted);
+ margin-right: 10px;
+ white-space: nowrap;
+}
+.pagination-size-select {
+ font-size: var(--fs-xs);
+ padding: 3px 6px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--surface);
+ color: var(--text);
+ cursor: pointer;
+}
+.pagination-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ font-size: 13px;
+ color: var(--text);
+ cursor: pointer;
+ line-height: 1;
+ transition: background .12s, border-color .12s;
+}
+.pagination-btn:hover:not(:disabled) {
+ background: var(--surface-2);
+ border-color: var(--primary);
+ color: var(--primary);
+}
+.pagination-btn:disabled {
+ opacity: .35;
+ cursor: default;
+}
+.pagination-pages {
+ font-size: var(--fs-xs);
+ color: var(--text-muted);
+ padding: 0 8px;
+ white-space: nowrap;
+}
+
+/* ── Barre de filtres admin users ─────────────────────────────────────────── */
+.admin-filters {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+.admin-filters input[type=search] {
+ flex: 1;
+ min-width: 180px;
+ padding: 7px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ font-size: var(--fs-sm);
+}
+.admin-filters input[type=search]::placeholder { color: var(--text-muted); }
+.admin-filters select {
+ padding: 7px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ font-size: var(--fs-sm);
+ min-width: 160px;
+}