From f3c5387a925b7a67c90ec91a246fd4a76100a6e0 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 15 Jun 2026 07:32:36 +0200 Subject: [PATCH] Gestion des utilisateurs --- backend/src/db/index.js | 22 + backend/src/routes/admin.js | 21 +- backend/src/routes/auth.js | 9 +- backend/src/routes/invitations.js | 151 +++++ backend/src/server.js | 4 + frontend/src/App.jsx | 2 + frontend/src/pages/Admin.jsx | 16 +- frontend/src/pages/InvitationRegister.jsx | 193 ++++++ frontend/src/pages/admin/UsersSection.jsx | 722 ++++++++++++++++++---- frontend/src/pages/admin/adminHelpers.jsx | 27 + frontend/src/styles.css | 124 +++- 11 files changed, 1163 insertions(+), 128 deletions(-) create mode 100644 backend/src/routes/invitations.js create mode 100644 frontend/src/pages/InvitationRegister.jsx 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}} + {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} +
+ )} + +
+ + {/* Email — verrouillé */} +
+ +
+ + + + +
+
+ +
+ + set('displayName', e.target.value)} style={{ width: '100%' }} /> +
+ +
+ + set('password', e.target.value)} style={{ width: '100%' }} /> +
+ +
+ + set('confirm', e.target.value)} style={{ width: '100%' }} /> +
+ + + +
+ +

+ 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}
} +
+
+ + set('email', e.target.value)} /> +
+
+ + +
+

+ L'invité recevra un email avec un lien sécurisé pour créer son compte. Son adresse email sera pré-remplie et non modifiable. +

+
+ + )} +
+ ); +} + +// ── 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}
} +
+
+ + set('displayName', e.target.value)} placeholder="Prénom Nom" /> +
+
+ + set('email', e.target.value)} placeholder="utilisateur@exemple.com" /> +
+
+ + set('password', e.target.value)} placeholder="8 caractères minimum" /> +
+
+ + +
+
+
+ ); +} + +// ── 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' : ''} -

- - - - - - - - - - - - - - - {users.map(u => ( - - - - - - - - - - - ))} - -
IDNomEmailEmail vérifié2FARôleCréé leActions
{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. +

+ ) : ( +
+ + + + + + + + + + + + + {paged.map(u => { + const [bg, fg] = avatarColor(u.display_name || u.email); + return ( + + + + + + + + + ); + })} + +
UtilisateurStatutRôle2FAInscrit le
+
+
+ {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; +}