Gestion des utilisateurs

This commit is contained in:
2026-06-15 07:32:36 +02:00
parent 414c8dbb74
commit f3c5387a92
11 changed files with 1163 additions and 128 deletions
+22
View File
@@ -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;
+20 -1
View File
@@ -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']),
+8 -1
View File
@@ -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.',
+151
View File
@@ -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: `
<p>Vous avez reçu une invitation à rejoindre <strong>${cfg.appName}</strong>
en tant que <strong>${roleLabel}</strong>.</p>
<p>Cliquez sur le bouton ci-dessous pour créer votre compte.
Ce lien est valable <strong>7 jours</strong>.</p>
`,
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;
+4
View File
@@ -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);
+2
View File
@@ -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() {
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/invitation/:token" element={<InvitationRegister />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify-email" element={<VerifyEmail />} />
+5 -11
View File
@@ -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 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
function IconUserPlus() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>; }
function IconActivity() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>; }
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
@@ -21,7 +18,6 @@ const NAV = [
group: 'Administration de la plateforme',
items: [
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
{ id: 'create', label: 'Créer un utilisateur', icon: <IconUserPlus /> },
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
],
@@ -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() {
))}
</aside>
<div className="account-content">
{section === 'users' && <UsersSection currentUserId={user?.id} key={refreshKey} />}
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
{section === 'users' && <UsersSection currentUserId={user?.id} />}
{section === 'job-logs' && <JobLogsSection />}
{section === 'icons' && <IconsSection />}
{section === 'smtp' && <SmtpSection />}
+193
View File
@@ -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 }) => (
<div style={{ display: 'flex', minHeight: '100dvh' }}>
<div className="login-bg-col" style={{ flex: '1 1 50%', display: 'none', position: 'relative', overflow: 'hidden', background: '#0d0d0d' }}>
<img src="/login-bg.jpg" alt="" aria-hidden="true" onError={e => { e.target.style.display = 'none'; }}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} />
<div style={{ position: 'absolute', bottom: 40, left: 40, color: '#fff', display: 'flex', alignItems: 'center', gap: 12 }}>
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt="" width={36} height={36} style={{ borderRadius: 8 }} />}
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: '-0.3px' }}>{appInfo.appName}</span>
</div>
</div>
<div style={{ flex: '1 1 50%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 24px', background: 'var(--background, #fff)', minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, marginBottom: 32 }}>
{appInfo.iconUrl && <img src={appInfo.iconUrl} alt={appInfo.appName} width={52} height={52} style={{ borderRadius: 12 }} />}
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text)', letterSpacing: '-0.4px' }}>{appInfo.appName}</span>
</div>
<div style={{ width: '100%', maxWidth: 360 }}>{children}</div>
</div>
<style>{`@media (min-width: 768px) { .login-bg-col { display: block !important; } }`}</style>
</div>
);
// ── Chargement ───────────────────────────────────────────────────────────
if (status === 'loading') {
return (
<Wrap>
<p style={{ textAlign: 'center', color: 'var(--text-muted)' }}>Vérification du lien</p>
</Wrap>
);
}
// ── Lien invalide / expiré ───────────────────────────────────────────────
if (status === 'invalid') {
return (
<Wrap>
<div style={{ textAlign: 'center' }}>
<div style={{ width: 56, height: 56, borderRadius: '50%', background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#dc2626" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>
</svg>
</div>
<h1 style={{ margin: '0 0 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>Lien invalide</h1>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>{errMsg}</p>
<Link to="/login" style={{ display: 'block', width: '100%', padding: '11px 0', background: 'var(--primary, #1e40af)', color: '#fff', borderRadius: 8, fontSize: 15, fontWeight: 600, textDecoration: 'none', textAlign: 'center' }}>
Retour à la connexion
</Link>
</div>
</Wrap>
);
}
// ── Succès ───────────────────────────────────────────────────────────────
if (status === 'done') {
return (
<Wrap>
<div style={{ textAlign: 'center' }}>
<div style={{ width: 56, height: 56, borderRadius: '50%', background: '#f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="9 12 11 14 15 10"/>
</svg>
</div>
<h1 style={{ margin: '0 0 10px', fontSize: 22, fontWeight: 700, color: 'var(--text)' }}>Compte créé !</h1>
<p style={{ margin: '0 0 28px', color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
Votre compte a été activé avec succès. Vous pouvez maintenant vous connecter.
</p>
<button onClick={() => navigate('/login')} style={{ width: '100%', padding: '11px 0', background: 'var(--primary, #1e40af)', color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 600, cursor: 'pointer' }}>
Se connecter
</button>
</div>
</Wrap>
);
}
// ── Formulaire ───────────────────────────────────────────────────────────
const roleLabel = inv?.role === 'admin' ? 'Administrateur' : 'Utilisateur';
return (
<Wrap>
<div style={{ marginBottom: 28 }}>
<h1 style={{ margin: '0 0 6px', fontSize: 26, fontWeight: 700, letterSpacing: '-0.5px', color: 'var(--text)' }}>
Finaliser votre inscription
</h1>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14 }}>
Vous avez été invité en tant que <strong>{roleLabel}</strong>.
</p>
</div>
{err && (
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, marginBottom: 16, background: 'var(--danger-bg, #fef2f2)', color: 'var(--danger, #dc2626)', border: '1px solid var(--danger-light, #fca5a5)' }}>
{err}
</div>
)}
<form onSubmit={submit} autoComplete="off" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Email — verrouillé */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Adresse email</label>
<div style={{ position: 'relative' }}>
<input
className="form-input"
type="email"
value={inv?.email || ''}
readOnly
style={{ width: '100%', background: 'var(--surface-2)', color: 'var(--text-muted)', cursor: 'not-allowed', paddingRight: 36 }}
/>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }}>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Nom d'affichage <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(facultatif)</span></label>
<input className="form-input" type="text" autoComplete="off" placeholder="Prénom Nom" value={form.displayName} onChange={e => set('displayName', e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Mot de passe <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>(8 car. min.)</span></label>
<input className="form-input" type="password" required minLength={8} autoComplete="new-password" placeholder="••••••••" value={form.password} onChange={e => set('password', e.target.value)} style={{ width: '100%' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 14, fontWeight: 500, color: 'var(--text)' }}>Confirmer le mot de passe</label>
<input className="form-input" type="password" required minLength={8} autoComplete="new-password" placeholder="••••••••" value={form.confirm} onChange={e => set('confirm', e.target.value)} style={{ width: '100%' }} />
</div>
<button type="submit" disabled={busy} style={{ marginTop: 4, width: '100%', padding: '11px 0', background: busy ? 'var(--text-muted)' : 'var(--primary, #1e40af)', color: '#fff', border: 'none', borderRadius: 8, fontSize: 15, fontWeight: 600, cursor: busy ? 'not-allowed' : 'pointer', transition: 'background 0.15s' }}>
{busy ? 'Création' : 'Créer mon compte'}
</button>
</form>
<p style={{ marginTop: 24, textAlign: 'center', fontSize: 13, color: 'var(--text-muted)' }}>
Déjà inscrit ?{' '}
<Link to="/login" style={{ color: 'var(--text)', fontWeight: 500, textDecoration: 'underline' }}>Se connecter</Link>
</p>
</Wrap>
);
}
+611 -111
View File
@@ -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 = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
const IcoCheck = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>;
const IcoMail = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>;
const IcoPause = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>;
const IcoPlay = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>;
const IcoTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
const IcoDots = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>;
const IcoSearch = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>;
function MenuBtn({ onClick, icon, label, danger = false }) {
return (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}
>
{icon}{label}
</button>
);
}
// ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Exporter
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLSX)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13l2 2 4-4"/></svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/><path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/></svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
// ── 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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0 0', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
Lignes par page
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
style={{ fontSize: 'var(--fs-xs)', padding: '3px 6px', border: '1px solid var(--border)', borderRadius: 5, background: 'var(--surface)', color: 'var(--text)', cursor: 'pointer' }}>
{PAGE_SIZES.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Page {page} sur {totalPages}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
<PBtn onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}></PBtn>
{pages[0] > 1 && <><PBtn onClick={() => setPage(1)}>1</PBtn>{pages[0] > 2 && <Ellipsis />}</>}
{pages.map(n => <PBtn key={n} onClick={() => setPage(n)} active={n === page}>{n}</PBtn>)}
{pages[pages.length - 1] < totalPages && <>{pages[pages.length - 1] < totalPages - 1 && <Ellipsis />}<PBtn onClick={() => setPage(totalPages)}>{totalPages}</PBtn></>}
<PBtn onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}></PBtn>
</div>
</div>
);
}
const Ellipsis = () => <span style={{ padding: '0 2px', color: 'var(--text-muted)', fontSize: 12 }}></span>;
function PBtn({ onClick, disabled, active, children }) {
return (
<button onClick={onClick} disabled={disabled} style={{
minWidth: 30, height: 30, padding: '0 6px', border: active ? '1.5px solid var(--primary)' : '1px solid var(--border)',
borderRadius: 6, background: active ? 'var(--primary-bg, #eff6ff)' : 'var(--surface)',
color: active ? 'var(--primary)' : 'var(--text)', fontSize: 13, fontWeight: active ? 600 : 400,
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.4 : 1,
}}
onMouseEnter={e => { if (!disabled && !active) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'var(--surface)'; }}
>{children}</button>
);
}
// ── 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 (
<Modal open={open} title="Inviter un utilisateur" onClose={handleClose} width={440}
footer={sent ? (
<button className="btn btn-primary" onClick={handleClose}>Fermer</button>
) : (
<>
<button type="button" className="btn btn-outline" onClick={handleClose} disabled={loading}>Annuler</button>
<button type="submit" form="invite-user-form" className="btn btn-primary" disabled={loading}>
{loading ? 'Envoi…' : 'Envoyer l\'invitation'}
</button>
</>
)}
>
{sent ? (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: '#f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<p style={{ margin: '0 0 6px', fontWeight: 600, color: 'var(--text)' }}>Invitation envoyée !</p>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
Un email a été envoyé à <strong>{form.email}</strong>.<br/>Le lien est valable 7 jours.
</p>
</div>
) : (
<>
{err && <div className="error" style={{ marginBottom: 14 }}>{err}</div>}
<form id="invite-user-form" onSubmit={handleSubmit} autoComplete="off" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label>Email *</label>
<input type="email" required autoComplete="off" placeholder="utilisateur@exemple.com"
value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div>
<label>Rôle</label>
<select value={form.role} onChange={e => set('role', e.target.value)}>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
<p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
L'invité recevra un email avec un lien sécurisé pour créer son compte. Son adresse email sera pré-remplie et non modifiable.
</p>
</form>
</>
)}
</Modal>
);
}
// ── 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 (
<Modal open={open} title="Créer un utilisateur" onClose={handleClose} width={460}
footer={<>
<button type="button" className="btn btn-outline" onClick={handleClose} disabled={loading}>Annuler</button>
<button type="submit" form="create-user-form" className="btn btn-primary" disabled={loading}>
{loading ? 'Création' : 'Créer le compte'}
</button>
</>}
>
{err && <div className="error" style={{ marginBottom: 14 }}>{err}</div>}
<form id="create-user-form" onSubmit={handleSubmit} autoComplete="off" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label>Nom affiché</label>
<input autoComplete="off" value={form.displayName} onChange={e => set('displayName', e.target.value)} placeholder="Prénom Nom" />
</div>
<div>
<label>Email *</label>
<input type="email" required autoComplete="off" value={form.email} onChange={e => set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
</div>
<div>
<label>Mot de passe *</label>
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
</div>
<div>
<label>Rôle</label>
<select value={form.role} onChange={e => set('role', e.target.value)}>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</form>
</Modal>
);
}
// ── 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 (
<div ref={ref} style={{ position: 'relative' }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 20,
border: `1px solid ${open ? 'var(--primary)' : 'var(--border)'}`,
background: 'var(--surface)', cursor: 'pointer', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap',
}}>
<span style={{ color: 'var(--text-muted)' }}>{label} :</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{selected?.label}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ color: 'var(--text-muted)', marginLeft: 2, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,.12)',
padding: '4px 0', minWidth: 160,
}}>
{options.map(o => (
<button key={o.value} onClick={() => { onChange(o.value); setOpen(false); }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 14px', background: 'none', border: 'none',
cursor: 'pointer', fontSize: 'var(--fs-sm)', textAlign: 'left',
color: o.value === value ? 'var(--primary)' : 'var(--text)',
fontWeight: o.value === value ? 500 : 400,
}}
onMouseEnter={e => { if (o.value !== value) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{o.label}
{o.value === value && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</button>
))}
</div>
)}
</div>
);
};
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 <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
return (
<>
<div className="card">
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
</p>
<table>
<thead>
<tr>
<th style={{ width: 36 }}>ID</th>
<th>Nom</th>
<th>Email</th>
<th>Email vérifié</th>
<th>2FA</th>
<th>Rôle</th>
<th>Créé le</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}></em>}</td>
<td>{u.email}</td>
<td>
{u.email_verified
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}> Vérifié</span>
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>En attente</span>
}
</td>
<td>
{u.totp_enabled
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--primary-bg, #eff6ff)', color: 'var(--primary, #1e40af)', border: '1px solid #bfdbfe' }}>🔐 Activé</span>
: <span style={{ fontSize: 11, color: 'var(--text-muted)' }}></span>
}
</td>
<td><Badge role={u.role} /></td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
className="btn btn-sm btn-outline"
onClick={() => toggleRole(u)}
disabled={u.id === currentUserId && u.role === 'admin'}
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
>
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
</button>
{!u.email_verified && (
<button className="btn btn-sm btn-outline" onClick={() => verifyEmail(u)}>
Vérifier email
</button>
)}
{u.id !== currentUserId && (
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
Supprimer
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* En-tête */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 20px 14px', gap: 16, borderBottom: '1px solid var(--border)', flexWrap: 'wrap' }}>
<div>
<h3 style={{ margin: '0 0 3px' }}>Comptes utilisateurs</h3>
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
Gérez les comptes et les accès des membres de l'application.
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="project-search-wrap" style={{ minWidth: 200 }}>
<IcoSearch />
<input className="project-search-input" type="search" placeholder="Rechercher…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button className="project-search-clear" onClick={() => setSearch('')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
)}
</div>
<ExportDropdown
onCSV={() => 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')}
/>
<button className="btn btn-outline btn-sm" onClick={() => setShowInvite(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
Inviter
</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
Ajouter un utilisateur
</button>
</div>
</div>
{/* Filtres pills */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 20px', borderBottom: '1px solid var(--border)' }}>
<PillDropdown label="Rôle" value={filterRole} options={ROLE_OPTIONS} onChange={setFilterRole} />
<PillDropdown label="Statut" value={filterStatus} options={STATUS_OPTIONS} onChange={setFilterStatus} />
<span style={{ marginLeft: 'auto', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{filtered.length !== users.length
? `${filtered.length} / ${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
: `${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
}
</span>
{(filterRole !== 'all' || filterStatus !== 'all' || search) && (
<button onClick={() => { setFilterRole('all'); setFilterStatus('all'); setSearch(''); }}
style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 6px', borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--danger)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>Réinitialiser</button>
)}
</div>
{/* Tableau */}
{filtered.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
Aucun utilisateur ne correspond aux filtres.
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ paddingLeft: 20 }}>Utilisateur</th>
<th>Statut</th>
<th>Rôle</th>
<th>2FA</th>
<th>Inscrit le</th>
<th style={{ width: 48, paddingRight: 20 }}></th>
</tr>
</thead>
<tbody>
{paged.map(u => {
const [bg, fg] = avatarColor(u.display_name || u.email);
return (
<tr key={u.id} style={{ height: 62 }}>
<td style={{ paddingLeft: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
background: bg, color: fg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, letterSpacing: '.03em',
}}>
{initials(u)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 'var(--fs-sm)', lineHeight: 1.3 }}>
{u.display_name || <em style={{ color: 'var(--text-muted)', fontStyle: 'normal' }}>{u.email.split('@')[0]}</em>}
{u.id === currentUserId && <span style={{ marginLeft: 6, fontSize: 10, color: 'var(--text-muted)', fontWeight: 400 }}>(vous)</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.3 }}>{u.email}</div>
</div>
</div>
</td>
<td><UserStatusBadge status={u.status || 'active'} emailVerified={u.email_verified} /></td>
<td><Badge role={u.role} /></td>
<td>
{u.totp_enabled
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>🔐 Actif</span>
: <span style={{ color: 'var(--text-muted)' }}></span>
}
</td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td style={{ paddingRight: 20 }}>
<button onClick={e => openMenuFor(e, u)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '4px 6px', borderRadius: 4, display: 'flex', alignItems: 'center' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-2)'; e.currentTarget.style.color = 'var(--text)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = 'var(--text-muted)'; }}>
<IcoDots />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ padding: '0 20px 16px' }}>
<AdminPagination page={safePage} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} total={filtered.length} />
</div>
</div>
)}
</div>
{/* Menu ⋮ contextuel */}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{
position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 200,
}}>
<MenuBtn icon={<IcoEdit />} label={openMenu.user.role === 'admin' ? '→ Utilisateur' : '→ Admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
{!openMenu.user.email_verified && <>
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
<MenuBtn icon={<IcoMail />} label="Renvoyer la vérification" onClick={() => { setOpenMenu(null); resendVerif(openMenu.user); }} />
</>}
{openMenu.user.id !== currentUserId && <>
{(!openMenu.user.status || openMenu.user.status === 'active') && <MenuBtn icon={<IcoPause />} label="Désactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'deactivated'); }} />}
{openMenu.user.status === 'deactivated' && <MenuBtn icon={<IcoPlay />} label="Réactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
{openMenu.user.status === 'locked' && <MenuBtn icon={<IcoPlay />} label="Déverrouiller" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<MenuBtn icon={<IcoTrash />} label="Supprimer" danger onClick={() => { setOpenMenu(null); deleteUser(openMenu.user); }} />
</>}
</div>
</>
)}
<InviteUserModal
open={showInvite}
onClose={() => setShowInvite(false)}
/>
<CreateUserModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={() => load()}
/>
<ConfirmModal
open={!!confirmAction}
title={confirmAction?.title}
message={confirmAction?.message}
confirmLabel={confirmAction?.confirmLabel}
onConfirm={confirmAction?.onConfirm}
onConfirm={async () => {
try { await confirmAction.onConfirm(); }
catch (e) { setErr(e.message); }
finally { setConfirmAction(null); }
}}
onCancel={() => setConfirmAction(null)}
/>
</>
+27
View File
@@ -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 (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '2px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 700,
background: bg, color, border: `1px solid ${border}`,
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, display: 'inline-block', flexShrink: 0 }} />
{label}
</span>
);
}
export function StatusBadge({ status }) {
const ok = status === 'ok';
return (
+120 -4
View File
@@ -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;
}