Gestion des utilisateurs
This commit is contained in:
@@ -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');
|
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;
|
export default db;
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const router = Router();
|
|||||||
/** Liste tous les utilisateurs */
|
/** Liste tous les utilisateurs */
|
||||||
router.get('/users', (req, res) => {
|
router.get('/users', (req, res) => {
|
||||||
const users = db.prepare(`
|
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
|
FROM users
|
||||||
ORDER BY id ASC
|
ORDER BY id ASC
|
||||||
`).all();
|
`).all();
|
||||||
@@ -96,6 +96,25 @@ router.post('/users', (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} 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 */
|
/** Modifie le rôle d'un utilisateur */
|
||||||
const PatchRoleSchema = z.object({
|
const PatchRoleSchema = z.object({
|
||||||
role: z.enum(['user', 'admin']),
|
role: z.enum(['user', 'admin']),
|
||||||
|
|||||||
@@ -101,13 +101,20 @@ router.post('/login', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const body = LoginSchema2FA.parse(req.body);
|
const body = LoginSchema2FA.parse(req.body);
|
||||||
const user = db
|
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);
|
.get(body.email);
|
||||||
if (!user) throw new HttpError(401, 'Invalid credentials');
|
if (!user) throw new HttpError(401, 'Invalid credentials');
|
||||||
|
|
||||||
const ok = bcrypt.compareSync(body.password, user.password_hash);
|
const ok = bcrypt.compareSync(body.password, user.password_hash);
|
||||||
if (!ok) throw new HttpError(401, 'Invalid credentials');
|
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) {
|
if (!user.email_verified) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Veuillez vérifier votre adresse email avant de vous connecter.',
|
error: 'Veuillez vérifier votre adresse email avant de vous connecter.',
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -33,6 +33,7 @@ import { errorHandler } from './middleware/errorHandler.js';
|
|||||||
import { requireAuth, requireAdmin } from './middleware/auth.js';
|
import { requireAuth, requireAdmin } from './middleware/auth.js';
|
||||||
import { startAutoStatutJob } from './jobs/autoStatut.js';
|
import { startAutoStatutJob } from './jobs/autoStatut.js';
|
||||||
import adminRouter from './routes/admin.js';
|
import adminRouter from './routes/admin.js';
|
||||||
|
import invitationsRouter from './routes/invitations.js';
|
||||||
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
|
import tauxCreditImpotRouter from './routes/tauxCreditImpot.js';
|
||||||
import referentielRouter from './routes/referentiel.js';
|
import referentielRouter from './routes/referentiel.js';
|
||||||
import referentielPublicRouter from './routes/referentielPublic.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/preferences', requireAuth, preferencesRouter);
|
||||||
app.use('/api/icons', requireAuth, iconsRouter);
|
app.use('/api/icons', requireAuth, iconsRouter);
|
||||||
app.use('/api/admin', requireAuth, requireAdmin, adminRouter);
|
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/taux-credit-impot', requireAuth, tauxCreditImpotRouter);
|
||||||
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
|
app.use('/api/referentiel', requireAuth, requireAdmin, referentielRouter);
|
||||||
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
|
app.use('/api/referentiel-public', requireAuth, referentielPublicRouter);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { useAuth } from './context/AuthContext.jsx';
|
import { useAuth } from './context/AuthContext.jsx';
|
||||||
import Login from './pages/Login.jsx';
|
import Login from './pages/Login.jsx';
|
||||||
import Register from './pages/Register.jsx';
|
import Register from './pages/Register.jsx';
|
||||||
|
import InvitationRegister from './pages/InvitationRegister.jsx';
|
||||||
import ForgotPassword from './pages/ForgotPassword.jsx';
|
import ForgotPassword from './pages/ForgotPassword.jsx';
|
||||||
import ResetPassword from './pages/ResetPassword.jsx';
|
import ResetPassword from './pages/ResetPassword.jsx';
|
||||||
import VerifyEmail from './pages/VerifyEmail.jsx';
|
import VerifyEmail from './pages/VerifyEmail.jsx';
|
||||||
@@ -41,6 +42,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/invitation/:token" element={<InvitationRegister />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext.jsx';
|
import { useAuth } from '../context/AuthContext.jsx';
|
||||||
import UsersSection from './admin/UsersSection.jsx';
|
import UsersSection from './admin/UsersSection.jsx';
|
||||||
import CreateUserSection from './admin/CreateUserSection.jsx';
|
import JobLogsSection from './admin/JobLogsSection.jsx';
|
||||||
import JobLogsSection from './admin/JobLogsSection.jsx';
|
import IconsSection from './admin/IconsSection.jsx';
|
||||||
import IconsSection from './admin/IconsSection.jsx';
|
import SmtpSection from './admin/SmtpSection.jsx';
|
||||||
import SmtpSection from './admin/SmtpSection.jsx';
|
|
||||||
|
|
||||||
/* ── Icônes nav ───────────────────────────────────────────────── */
|
/* ── 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 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 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 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>; }
|
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',
|
group: 'Administration de la plateforme',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
|
{ 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: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
|
||||||
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
|
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
|
||||||
],
|
],
|
||||||
@@ -44,7 +40,6 @@ const NAV = [
|
|||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [refreshKey, setRefreshKey] = useState(0);
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const section = new URLSearchParams(search).get('section') || 'users';
|
const section = new URLSearchParams(search).get('section') || 'users';
|
||||||
@@ -72,8 +67,7 @@ export default function Admin() {
|
|||||||
))}
|
))}
|
||||||
</aside>
|
</aside>
|
||||||
<div className="account-content">
|
<div className="account-content">
|
||||||
{section === 'users' && <UsersSection currentUserId={user?.id} key={refreshKey} />}
|
{section === 'users' && <UsersSection currentUserId={user?.id} />}
|
||||||
{section === 'create' && <CreateUserSection onCreated={() => setRefreshKey(k => k + 1)} />}
|
|
||||||
{section === 'job-logs' && <JobLogsSection />}
|
{section === 'job-logs' && <JobLogsSection />}
|
||||||
{section === 'icons' && <IconsSection />}
|
{section === 'icons' && <IconsSection />}
|
||||||
{section === 'smtp' && <SmtpSection />}
|
{section === 'smtp' && <SmtpSection />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { api } from '../../api.js';
|
||||||
import ConfirmModal from '../../components/ConfirmModal.jsx';
|
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 }) {
|
export default function UsersSection({ currentUserId }) {
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
const [confirmAction, setConfirmAction] = 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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try { setLoading(true); setUsers(await api.get('/admin/users')); }
|
||||||
setLoading(true);
|
catch (e) { setErr(e.message); }
|
||||||
const data = await api.get('/admin/users');
|
|
||||||
setUsers(data);
|
|
||||||
} catch (e) { setErr(e.message); }
|
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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';
|
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||||
setConfirmAction({
|
confirm('Changer le rôle',
|
||||||
title: 'Changer le rôle',
|
`Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
|
||||||
message: `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');
|
||||||
confirmLabel: 'Confirmer',
|
};
|
||||||
onConfirm: async () => {
|
const verifyEmail = u => confirm("Vérifier l'email",
|
||||||
try {
|
`Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
|
||||||
await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
|
async () => { await api.patch(`/admin/users/${u.id}/verify-email`, {}); load(); }, 'Confirmer');
|
||||||
load();
|
const resendVerif = async u => {
|
||||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
try { await api.post('/auth/resend-verification', { email: u.email }); alert(`Email envoyé à ${u.email}`); }
|
||||||
finally { setConfirmAction(null); }
|
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) => {
|
// ── Pill dropdown custom ───────────────────────────────────────────────
|
||||||
setConfirmAction({
|
const PillDropdown = ({ label, value, options, onChange }) => {
|
||||||
title: 'Vérifier l\'email manuellement',
|
const [open, setOpen] = useState(false);
|
||||||
message: `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
|
const ref = useRef(null);
|
||||||
confirmLabel: 'Confirmer',
|
const selected = options.find(o => o.value === value);
|
||||||
onConfirm: async () => {
|
useEffect(() => {
|
||||||
try {
|
if (!open) return;
|
||||||
await api.patch(`/admin/users/${u.id}/verify-email`, {});
|
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
|
||||||
load();
|
document.addEventListener('mousedown', h);
|
||||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
return () => document.removeEventListener('mousedown', h);
|
||||||
finally { setConfirmAction(null); }
|
}, [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) => {
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
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); }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
||||||
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
|
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="card">
|
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||||
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
|
|
||||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
{/* En-tête */}
|
||||||
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 20px 14px', gap: 16, borderBottom: '1px solid var(--border)', flexWrap: 'wrap' }}>
|
||||||
</p>
|
<div>
|
||||||
<table>
|
<h3 style={{ margin: '0 0 3px' }}>Comptes utilisateurs</h3>
|
||||||
<thead>
|
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||||
<tr>
|
Gérez les comptes et les accès des membres de l'application.
|
||||||
<th style={{ width: 36 }}>ID</th>
|
</p>
|
||||||
<th>Nom</th>
|
</div>
|
||||||
<th>Email</th>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<th>Email vérifié</th>
|
<div className="project-search-wrap" style={{ minWidth: 200 }}>
|
||||||
<th>2FA</th>
|
<IcoSearch />
|
||||||
<th>Rôle</th>
|
<input className="project-search-input" type="search" placeholder="Rechercher…"
|
||||||
<th>Créé le</th>
|
value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
<th>Actions</th>
|
{search && (
|
||||||
</tr>
|
<button className="project-search-clear" onClick={() => setSearch('')}>
|
||||||
</thead>
|
<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>
|
||||||
<tbody>
|
</button>
|
||||||
{users.map(u => (
|
)}
|
||||||
<tr key={u.id}>
|
</div>
|
||||||
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
<ExportDropdown
|
||||||
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
onCSV={() => dlBlob(toCSV(filtered), `utilisateurs_${today}.csv`, 'text/csv;charset=utf-8')}
|
||||||
<td>{u.email}</td>
|
onXLSX={() => dlBlob(toXLSX(filtered), `utilisateurs_${today}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
|
||||||
<td>
|
onJSON={() => dlBlob(toJSON(filtered), `utilisateurs_${today}.json`, 'application/json')}
|
||||||
{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>
|
<button className="btn btn-outline btn-sm" onClick={() => setShowInvite(true)}
|
||||||
: <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>
|
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>
|
||||||
</td>
|
Inviter
|
||||||
<td>
|
</button>
|
||||||
{u.totp_enabled
|
<button className="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}
|
||||||
? <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>
|
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
|
||||||
: <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>
|
<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
|
||||||
</td>
|
</button>
|
||||||
<td><Badge role={u.role} /></td>
|
</div>
|
||||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
</div>
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
{/* Filtres pills */}
|
||||||
<button
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 20px', borderBottom: '1px solid var(--border)' }}>
|
||||||
className="btn btn-sm btn-outline"
|
<PillDropdown label="Rôle" value={filterRole} options={ROLE_OPTIONS} onChange={setFilterRole} />
|
||||||
onClick={() => toggleRole(u)}
|
<PillDropdown label="Statut" value={filterStatus} options={STATUS_OPTIONS} onChange={setFilterStatus} />
|
||||||
disabled={u.id === currentUserId && u.role === 'admin'}
|
<span style={{ marginLeft: 'auto', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
|
||||||
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
|
{filtered.length !== users.length
|
||||||
>
|
? `${filtered.length} / ${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
|
||||||
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
: `${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
|
||||||
</button>
|
}
|
||||||
{!u.email_verified && (
|
</span>
|
||||||
<button className="btn btn-sm btn-outline" onClick={() => verifyEmail(u)}>
|
{(filterRole !== 'all' || filterStatus !== 'all' || search) && (
|
||||||
Vérifier email
|
<button onClick={() => { setFilterRole('all'); setFilterStatus('all'); setSearch(''); }}
|
||||||
</button>
|
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)'}
|
||||||
{u.id !== currentUserId && (
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
|
||||||
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
>Réinitialiser</button>
|
||||||
Supprimer
|
)}
|
||||||
</button>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Tableau */}
|
||||||
</td>
|
{filtered.length === 0 ? (
|
||||||
</tr>
|
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
|
||||||
))}
|
Aucun utilisateur ne correspond aux filtres.
|
||||||
</tbody>
|
</p>
|
||||||
</table>
|
) : (
|
||||||
|
<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>
|
</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
|
<ConfirmModal
|
||||||
open={!!confirmAction}
|
open={!!confirmAction}
|
||||||
title={confirmAction?.title}
|
title={confirmAction?.title}
|
||||||
message={confirmAction?.message}
|
message={confirmAction?.message}
|
||||||
confirmLabel={confirmAction?.confirmLabel}
|
confirmLabel={confirmAction?.confirmLabel}
|
||||||
onConfirm={confirmAction?.onConfirm}
|
onConfirm={async () => {
|
||||||
|
try { await confirmAction.onConfirm(); }
|
||||||
|
catch (e) { setErr(e.message); }
|
||||||
|
finally { setConfirmAction(null); }
|
||||||
|
}}
|
||||||
onCancel={() => setConfirmAction(null)}
|
onCancel={() => setConfirmAction(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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 }) {
|
export function StatusBadge({ status }) {
|
||||||
const ok = status === 'ok';
|
const ok = status === 'ok';
|
||||||
return (
|
return (
|
||||||
|
|||||||
+120
-4
@@ -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); }
|
.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 ──────────────────────────────────────── */
|
/* ── Burger menu topbar ──────────────────────────────────────── */
|
||||||
.topbar-burger-wrap { position: relative; display: none; }
|
.topbar-burger-wrap { position: relative; display: none; }
|
||||||
.topbar-burger-btn {
|
.topbar-burger-btn {
|
||||||
@@ -426,10 +445,10 @@ a { color: var(--primary); }
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
height: 40px;
|
height: 32px;
|
||||||
padding: 0 16px;
|
padding: 0 12px;
|
||||||
border: 1.5px solid var(--primary);
|
border: 1.5px solid var(--primary);
|
||||||
border-radius: 20px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
min-width: 330px;
|
min-width: 330px;
|
||||||
@@ -1895,4 +1914,101 @@ tr:hover td { background: var(--surface-2); }
|
|||||||
.drill-table tr:hover td { background: inherit; }
|
.drill-table tr:hover td { background: inherit; }
|
||||||
/* Les lignes cliquables ont leur propre hover via opacity inline — on protège
|
/* 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. */
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user