Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2281894802 | |||
| 89190c4561 |
@@ -442,6 +442,14 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id);
|
|||||||
- **Déployé sur les 8 écrans contenant un champ mot de passe** : Login, Register, ResetPassword (×2 champs), InvitationRegister (×2 champs), MonCompte (SecurityForm ×3 champs + changement email ×1 + désactivation 2FA ×1), admin/CreateUserSection, admin/UsersSection
|
- **Déployé sur les 8 écrans contenant un champ mot de passe** : Login, Register, ResetPassword (×2 champs), InvitationRegister (×2 champs), MonCompte (SecurityForm ×3 champs + changement email ×1 + désactivation 2FA ×1), admin/CreateUserSection, admin/UsersSection
|
||||||
- **Règle à respecter** : tout nouveau champ mot de passe doit utiliser `<PasswordInput>` plutôt que `<input type="password">` brut, pour garder l'UX cohérente sur toute l'app
|
- **Règle à respecter** : tout nouveau champ mot de passe doit utiliser `<PasswordInput>` plutôt que `<input type="password">` brut, pour garder l'UX cohérente sur toute l'app
|
||||||
|
|
||||||
|
### Suppression définitive de compte (self-service)
|
||||||
|
- Route `DELETE /api/auth/me` (requireAuth, body `{ password }`) dans `auth.js` : vérifie le mot de passe (bcrypt), bloque si l'utilisateur est le **dernier admin** (`COUNT(*) WHERE role='admin' <= 1`), logge un audit `account_self_deleted` (catégorie `account`, `details.initiated_by:'self'`) **avant** la suppression, notifie tous les autres admins (type `security`, lien `/admin?section=audit-logs`), puis `DELETE FROM users WHERE id=?`
|
||||||
|
- **Le nettoyage des données ne fait AUCUN delete manuel par table** — il repose entièrement sur les FK `ON DELETE CASCADE` déjà en place sur `user_id`/`investisseur_id`/etc. (`db.pragma('foreign_keys = ON')` activé globalement dans `db/index.js`). C'est le même mécanisme que `DELETE /api/admin/users/:id` (admin.js) qui fait déjà un simple `DELETE FROM users` sans étape de nettoyage manuel
|
||||||
|
- `audit_logs.actor_id`/`target_user_id` sont en `ON DELETE SET NULL` (pas CASCADE) : le log survit à la suppression de l'utilisateur, les infos identifiantes (email, display_name, role) sont dupliquées dans `details` JSON pour rester lisibles même une fois les FK à NULL
|
||||||
|
- Frontend : `AuthContext.deleteAccount(password)` → `api.del('/auth/me', {password})` puis `logout()` ; `api.del` accepte maintenant un `body` optionnel (`api.js`)
|
||||||
|
- `DeleteAccountSection` dans `MonCompte.jsx`, en bas de l'onglet **Mon compte** (après le bloc Préférences, pas dans Sécurité) : carte bordée rouge, warning, reveal formulaire mot de passe au clic, boutons alignés à droite (cohérent avec le reste de la page)
|
||||||
|
- Après suppression : redirection vers `/login?deleted=1`, `Login.jsx` affiche une bannière verte de confirmation si ce paramètre est présent
|
||||||
|
|
||||||
### Bug — profil principal / compte courant non créés hors /auth/register
|
### Bug — profil principal / compte courant non créés hors /auth/register
|
||||||
- **Constat** : seul `/api/auth/register` (auto-inscription) créait le profil investisseur principal (`is_principal=1`) ET le compte courant associé. Les deux autres parcours de création de compte en étaient dépourvus :
|
- **Constat** : seul `/api/auth/register` (auto-inscription) créait le profil investisseur principal (`is_principal=1`) ET le compte courant associé. Les deux autres parcours de création de compte en étaient dépourvus :
|
||||||
- `POST /api/admin/users` (admin.js, `CreateUserSection.jsx`) : créait l'investisseur mais **sans `is_principal=1`** et **sans compte courant**
|
- `POST /api/admin/users` (admin.js, `CreateUserSection.jsx`) : créait l'investisseur mais **sans `is_principal=1`** et **sans compte courant**
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ function purgeOldLogs() {
|
|||||||
db.prepare("DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days')").run();
|
db.prepare("DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days')").run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Classification succès / avertissement / échec par action ──────────────
|
||||||
|
// IMPORTANT : garder cette liste synchronisée avec ACTION_STATUS dans
|
||||||
|
// frontend/src/pages/admin/AuditLogsSection.jsx (icônes + libellés).
|
||||||
|
const STATUS_ACTIONS = {
|
||||||
|
success: ['login_success', 'login_2fa_success', 'user_registered', 'user_created', 'email_verified_admin', 'invitation_accepted', 'invitation_sent', '2fa_enabled'],
|
||||||
|
warning: ['role_changed', 'status_changed', '2fa_disabled', 'user_deleted', 'account_self_deleted'],
|
||||||
|
failure: ['login_failed'],
|
||||||
|
};
|
||||||
|
const STATUS_CASE_SQL = `
|
||||||
|
CASE
|
||||||
|
WHEN al.action IN (${STATUS_ACTIONS.success.map(() => '?').join(',')}) THEN 'success'
|
||||||
|
WHEN al.action IN (${STATUS_ACTIONS.warning.map(() => '?').join(',')}) THEN 'warning'
|
||||||
|
WHEN al.action IN (${STATUS_ACTIONS.failure.map(() => '?').join(',')}) THEN 'failure'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
const STATUS_CASE_PARAMS = [...STATUS_ACTIONS.success, ...STATUS_ACTIONS.warning, ...STATUS_ACTIONS.failure];
|
||||||
|
|
||||||
// ── GET / — liste paginée avec filtres ────────────────────────────────────
|
// ── GET / — liste paginée avec filtres ────────────────────────────────────
|
||||||
router.get('/', (req, res, next) => {
|
router.get('/', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -22,37 +40,48 @@ router.get('/', (req, res, next) => {
|
|||||||
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const category = req.query.category || null;
|
const category = req.query.category || null;
|
||||||
|
const status = req.query.status || null; // 'success' | 'warning' | 'failure'
|
||||||
const search = req.query.search || null; // filtre sur email acteur ou cible
|
const search = req.query.search || null; // filtre sur email acteur ou cible
|
||||||
const dateFrom = req.query.dateFrom || null;
|
const dateFrom = req.query.dateFrom || null;
|
||||||
const dateTo = req.query.dateTo || null;
|
const dateTo = req.query.dateTo || null;
|
||||||
const userId = req.query.userId ? Number(req.query.userId) : null;
|
const userId = req.query.userId ? Number(req.query.userId) : null;
|
||||||
|
|
||||||
const conditions = [];
|
// Conditions communes (hors statut) — réutilisées pour les compteurs par statut,
|
||||||
const params = [];
|
// qui doivent rester visibles/à jour même quand un statut est déjà sélectionné.
|
||||||
|
const baseConditions = [];
|
||||||
|
const baseParams = [];
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
conditions.push('al.category = ?');
|
baseConditions.push('al.category = ?');
|
||||||
params.push(category);
|
baseParams.push(category);
|
||||||
}
|
}
|
||||||
if (userId) {
|
if (userId) {
|
||||||
conditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
|
baseConditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
|
||||||
params.push(userId, userId);
|
baseParams.push(userId, userId);
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
conditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)');
|
baseConditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)');
|
||||||
const like = `%${search}%`;
|
const like = `%${search}%`;
|
||||||
params.push(like, like, like, like);
|
baseParams.push(like, like, like, like);
|
||||||
}
|
}
|
||||||
if (dateFrom) {
|
if (dateFrom) {
|
||||||
conditions.push('al.created_at >= ?');
|
baseConditions.push('al.created_at >= ?');
|
||||||
params.push(dateFrom);
|
baseParams.push(dateFrom);
|
||||||
}
|
}
|
||||||
if (dateTo) {
|
if (dateTo) {
|
||||||
conditions.push('al.created_at <= ?');
|
baseConditions.push('al.created_at <= ?');
|
||||||
params.push(dateTo + ' 23:59:59');
|
baseParams.push(dateTo + ' 23:59:59');
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
const conditions = [...baseConditions];
|
||||||
|
const params = [...baseParams];
|
||||||
|
if (status) {
|
||||||
|
conditions.push(`(${STATUS_CASE_SQL}) = ?`);
|
||||||
|
params.push(...STATUS_CASE_PARAMS, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -85,13 +114,41 @@ router.get('/', (req, res, next) => {
|
|||||||
${where}
|
${where}
|
||||||
`).get(...params).n;
|
`).get(...params).n;
|
||||||
|
|
||||||
// Parser les details JSON
|
// Compteurs par statut, calculés avec les mêmes filtres (catégorie/recherche/dates)
|
||||||
const parsed = rows.map(r => ({
|
// mais SANS le filtre statut lui-même, pour permettre de basculer entre statuts.
|
||||||
...r,
|
const statusRows = db.prepare(`
|
||||||
details: r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null,
|
SELECT (${STATUS_CASE_SQL}) AS ev_status, COUNT(*) AS n
|
||||||
}));
|
FROM audit_logs al
|
||||||
|
LEFT JOIN users actor ON actor.id = al.actor_id
|
||||||
|
LEFT JOIN users target ON target.id = al.target_user_id
|
||||||
|
${baseWhere}
|
||||||
|
GROUP BY ev_status
|
||||||
|
`).all(...STATUS_CASE_PARAMS, ...baseParams);
|
||||||
|
|
||||||
res.json({ total, page, limit, rows: parsed });
|
const statusCounts = { success: 0, warning: 0, failure: 0 };
|
||||||
|
for (const r of statusRows) {
|
||||||
|
if (r.ev_status && statusCounts[r.ev_status] !== undefined) statusCounts[r.ev_status] = r.n;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser les details JSON
|
||||||
|
const parsed = rows.map(r => {
|
||||||
|
const details = r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null;
|
||||||
|
// Repli sur les infos figées dans "details" quand l'utilisateur (acteur et/ou cible)
|
||||||
|
// a depuis été supprimé — son FK passe à NULL (ON DELETE SET NULL) mais l'email/nom
|
||||||
|
// saisis au moment de l'action restent lisibles pour les admins.
|
||||||
|
const fallbackName = details && typeof details === 'object' ? (details.display_name || null) : null;
|
||||||
|
const fallbackEmail = details && typeof details === 'object' ? (details.email || null) : null;
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
details,
|
||||||
|
actor_name: r.actor_name ?? (r.actor_id == null ? fallbackName : null),
|
||||||
|
actor_email: r.actor_email ?? (r.actor_id == null ? fallbackEmail : null),
|
||||||
|
target_name: r.target_name ?? (r.target_id == null ? fallbackName : null),
|
||||||
|
target_email:r.target_email?? (r.target_id == null ? fallbackEmail: null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ total, page, limit, rows: parsed, statusCounts });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -252,6 +252,76 @@ router.put('/me', requireAuth, async (req, res, next) => {
|
|||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suppression définitive du compte (self-service) ────────────────────────
|
||||||
|
const DeleteMeSchema = z.object({
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/me', requireAuth, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { password } = DeleteMeSchema.parse(req.body);
|
||||||
|
|
||||||
|
const user = db
|
||||||
|
.prepare('SELECT id, email, display_name, role, password_hash FROM users WHERE id = ?')
|
||||||
|
.get(req.user.id);
|
||||||
|
if (!user) throw new HttpError(404, 'Utilisateur introuvable');
|
||||||
|
|
||||||
|
const ok = bcrypt.compareSync(password, user.password_hash);
|
||||||
|
if (!ok) throw new HttpError(401, 'Mot de passe incorrect.');
|
||||||
|
|
||||||
|
// Empêche de se retrouver sans aucun administrateur sur l'application
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
const { n: adminCount } = db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'admin'").get();
|
||||||
|
if (adminCount <= 1) {
|
||||||
|
throw new HttpError(400, "Vous êtes le seul administrateur de l'application. Promouvez un autre compte en administrateur avant de supprimer le vôtre.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log AVANT suppression : target_user_id/actor_id passeront à NULL après le DELETE
|
||||||
|
// (FK ON DELETE SET NULL), mais les informations restent lisibles dans "details".
|
||||||
|
audit(req, {
|
||||||
|
action: 'account_self_deleted',
|
||||||
|
category: 'account',
|
||||||
|
actorId: user.id,
|
||||||
|
targetUserId: user.id,
|
||||||
|
details: {
|
||||||
|
email: user.email,
|
||||||
|
display_name: user.display_name,
|
||||||
|
role: user.role,
|
||||||
|
initiated_by: 'self',
|
||||||
|
note: "Suppression de compte initiée par l'utilisateur lui-même depuis Mon compte.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Notifier les autres administrateurs
|
||||||
|
const otherAdmins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND id != ?").all(user.id);
|
||||||
|
if (otherAdmins.length > 0) {
|
||||||
|
const insertNotif = db.prepare(
|
||||||
|
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
const notifyTx = db.transaction((rows) => {
|
||||||
|
for (const admin of rows) {
|
||||||
|
insertNotif.run(
|
||||||
|
admin.id,
|
||||||
|
'security',
|
||||||
|
'Suppression de compte utilisateur',
|
||||||
|
`${user.display_name || user.email} (${user.email}) a supprimé définitivement son propre compte.`,
|
||||||
|
'/admin?section=audit-logs',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notifyTx(otherAdmins);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppression définitive — cascade en base sur toutes les données liées
|
||||||
|
// (investisseurs, plateformes, investissements, remboursements, comptes,
|
||||||
|
// préférences, notifications, tickets, appareils de confiance, etc.)
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(user.id);
|
||||||
|
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
// ── Vérification d'adresse email ──────────────────────────────────────────
|
// ── Vérification d'adresse email ──────────────────────────────────────────
|
||||||
router.get('/verify-email', (req, res, next) => {
|
router.get('/verify-email', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+6
-2
@@ -54,8 +54,12 @@ export const api = {
|
|||||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}).then(handle),
|
}).then(handle),
|
||||||
del: (path) =>
|
del: (path, body) =>
|
||||||
fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle),
|
fetch(BASE + path, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(),
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
}).then(handle),
|
||||||
upload: (path, formData) =>
|
upload: (path, formData) =>
|
||||||
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle),
|
||||||
postForm: (path, formData) =>
|
postForm: (path, formData) =>
|
||||||
|
|||||||
@@ -67,10 +67,15 @@ export function AuthProvider({ children }) {
|
|||||||
return r.user;
|
return r.user;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async (password) => {
|
||||||
|
await api.del('/auth/me', { password });
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, isAdmin }}>
|
<AuthCtx.Provider value={{ token, user, loading, login, completeLogin, register, logout, updateUser, deleteAccount, isAdmin }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthCtx.Provider>
|
</AuthCtx.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext.jsx';
|
import { useAuth } from '../context/AuthContext.jsx';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import AuthBgCol from '../components/AuthBgCol.jsx';
|
import AuthBgCol from '../components/AuthBgCol.jsx';
|
||||||
@@ -28,6 +28,8 @@ function AppHeader({ appInfo }) {
|
|||||||
export default function Login() {
|
export default function Login() {
|
||||||
const { login, completeLogin } = useAuth();
|
const { login, completeLogin } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const accountDeleted = searchParams.get('deleted') === '1';
|
||||||
|
|
||||||
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
|
const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true });
|
||||||
|
|
||||||
@@ -193,6 +195,12 @@ export default function Login() {
|
|||||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<ErrBox msg={err} />
|
<ErrBox msg={err} />
|
||||||
|
|
||||||
|
{accountDeleted && !err && (
|
||||||
|
<div style={{ padding: '10px 14px', borderRadius: 8, fontSize: 14, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>
|
||||||
|
Votre compte a été supprimé avec succès.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{unverified && (
|
{unverified && (
|
||||||
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
|
<div style={{ padding: '12px 14px', borderRadius: 8, fontSize: 13, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid var(--warning-border, #fcd34d)', lineHeight: 1.5 }}>
|
||||||
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
|
<strong>Email non vérifié.</strong> Vérifiez votre boîte mail et cliquez sur le lien reçu.
|
||||||
|
|||||||
@@ -713,6 +713,75 @@ function TrustedDevicesSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Suppression définitive du compte ────────────────────────── */
|
||||||
|
function DeleteAccountSection() {
|
||||||
|
const { deleteAccount } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [err, setErr] = useState(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErr(null); setBusy(true);
|
||||||
|
try {
|
||||||
|
await deleteAccount(password);
|
||||||
|
navigate('/login?deleted=1', { replace: true });
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || 'Une erreur est survenue.');
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginTop: 20, border: '1px solid var(--danger,#dc2626)' }}>
|
||||||
|
<h3 style={{ margin: '0 0 4px', color: 'var(--danger,#dc2626)' }}>Supprimer mon compte</h3>
|
||||||
|
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
|
||||||
|
Cette action est <strong>définitive et irréversible</strong>. Toutes vos données seront immédiatement
|
||||||
|
supprimées : plateformes, investissements, remboursements, dépôts/retraits, comptes courants,
|
||||||
|
préférences et historique. Il ne sera pas possible de les récupérer.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
|
||||||
|
|
||||||
|
{!confirming && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<button type="button" className="danger"
|
||||||
|
onClick={() => setConfirming(true)}>
|
||||||
|
Supprimer mon compte
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirming && (
|
||||||
|
<form onSubmit={submit} style={{
|
||||||
|
marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 400,
|
||||||
|
}}>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
|
||||||
|
Confirmez votre mot de passe pour supprimer définitivement votre compte.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label>Mot de passe actuel</label>
|
||||||
|
<PasswordInput required autoComplete="current-password" placeholder="••••••••"
|
||||||
|
value={password} onChange={e => setPassword(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 12 }}>
|
||||||
|
<button type="submit" className="danger"
|
||||||
|
disabled={busy || !password}>
|
||||||
|
{busy ? 'Suppression…' : 'Confirmer la suppression définitive'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setConfirming(false); setPassword(''); setErr(null); }}
|
||||||
|
style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 13, cursor: 'pointer', textDecoration: 'underline', padding: 0 }}>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Page principale ─────────────────────────────────────────── */
|
/* ── Page principale ─────────────────────────────────────────── */
|
||||||
export default function MonCompte() {
|
export default function MonCompte() {
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
@@ -747,7 +816,7 @@ export default function MonCompte() {
|
|||||||
{/* ── Contenu ─────────────────────────────────────── */}
|
{/* ── Contenu ─────────────────────────────────────── */}
|
||||||
<div className="account-content account-content-center">
|
<div className="account-content account-content-center">
|
||||||
<div className="account-content-narrow">
|
<div className="account-content-narrow">
|
||||||
{section === 'profil' && <AccountForm />}
|
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,39 +12,59 @@ const CATEGORY_META = {
|
|||||||
invitation: { label: 'Invitation', color: '#6366f1' },
|
invitation: { label: 'Invitation', color: '#6366f1' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABELS = {
|
// ── Statut succès / avertissement / échec par action ───────────────────────
|
||||||
login_success: 'Connexion réussie',
|
// IMPORTANT : garder synchronisé avec STATUS_ACTIONS dans backend/src/routes/auditLogs.js
|
||||||
login_failed: 'Échec de connexion',
|
const ACTION_STATUS = {
|
||||||
login_2fa_success: 'Connexion 2FA réussie',
|
login_success: 'success',
|
||||||
user_registered: 'Auto-inscription',
|
login_2fa_success: 'success',
|
||||||
user_created: 'Compte créé (admin)',
|
user_registered: 'success',
|
||||||
user_deleted: 'Compte supprimé',
|
user_created: 'success',
|
||||||
email_verified_admin:'Email vérifié (admin)',
|
email_verified_admin: 'success',
|
||||||
role_changed: 'Rôle modifié',
|
invitation_accepted: 'success',
|
||||||
status_changed: 'Statut modifié',
|
invitation_sent: 'success',
|
||||||
'2fa_enabled': '2FA activé',
|
'2fa_enabled': 'success',
|
||||||
'2fa_disabled': '2FA désactivé',
|
|
||||||
invitation_sent: 'Invitation envoyée',
|
role_changed: 'warning',
|
||||||
invitation_accepted: 'Invitation acceptée',
|
status_changed: 'warning',
|
||||||
|
'2fa_disabled': 'warning',
|
||||||
|
user_deleted: 'warning',
|
||||||
|
account_self_deleted: 'warning',
|
||||||
|
|
||||||
|
login_failed: 'failure',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
const STATUS_META = {
|
||||||
|
success: { label: 'Succès', color: '#16a34a' },
|
||||||
|
warning: { label: 'Avertissement', color: '#f59e0b' },
|
||||||
|
failure: { label: 'Échec', color: '#dc2626' },
|
||||||
|
};
|
||||||
|
|
||||||
function fmtDateTime(str) {
|
function StatusIcon({ status, color }) {
|
||||||
if (!str) return '—';
|
const meta = STATUS_META[status];
|
||||||
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z'));
|
if (!meta) return null;
|
||||||
return d.toLocaleString('fr-FR', {
|
const common = { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: color || meta.color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
return (
|
||||||
hour: '2-digit', minute: '2-digit',
|
<span style={{ display: 'inline-flex', marginRight: 6, verticalAlign: 'middle' }} title={meta.label}>
|
||||||
});
|
{status === 'success' && (
|
||||||
|
<svg {...common}><circle cx="12" cy="12" r="10"/><polyline points="16 9 11 14 8 11"/></svg>
|
||||||
|
)}
|
||||||
|
{status === 'warning' && (
|
||||||
|
<svg {...common}><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
||||||
|
)}
|
||||||
|
{status === 'failure' && (
|
||||||
|
<svg {...common}><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>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CategoryBadge({ cat, active, onClick }) {
|
function StatusBadge({ status, count, active, onClick }) {
|
||||||
const meta = CATEGORY_META[cat] || { label: cat, color: '#6b7280' };
|
const meta = STATUS_META[status];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => onClick(cat)}
|
onClick={() => onClick(status)}
|
||||||
style={{
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
padding: '3px 10px',
|
padding: '3px 10px',
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
border: `1px solid ${meta.color}`,
|
border: `1px solid ${meta.color}`,
|
||||||
@@ -57,11 +77,56 @@ function CategoryBadge({ cat, active, onClick }) {
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<StatusIcon status={status} color={active ? '#fff' : meta.color} />
|
||||||
{meta.label}
|
{meta.label}
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
minWidth: 18, height: 18, borderRadius: 9, padding: '0 5px',
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
background: active ? 'rgba(255,255,255,.25)' : `${meta.color}22`,
|
||||||
|
color: active ? '#fff' : meta.color,
|
||||||
|
}}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTION_LABELS = {
|
||||||
|
login_success: 'Connexion réussie',
|
||||||
|
login_failed: 'Échec de connexion',
|
||||||
|
login_2fa_success: 'Connexion 2FA réussie',
|
||||||
|
user_registered: 'Auto-inscription',
|
||||||
|
user_created: 'Compte créé (admin)',
|
||||||
|
user_deleted: 'Compte supprimé',
|
||||||
|
account_self_deleted:'Compte supprimé (par l’utilisateur)',
|
||||||
|
email_verified_admin:'Email vérifié (admin)',
|
||||||
|
role_changed: 'Rôle modifié',
|
||||||
|
status_changed: 'Statut modifié',
|
||||||
|
'2fa_enabled': '2FA activé',
|
||||||
|
'2fa_disabled': '2FA désactivé',
|
||||||
|
invitation_sent: 'Invitation envoyée',
|
||||||
|
invitation_accepted: 'Invitation acceptée',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtPerson(name, email) {
|
||||||
|
if (name && email) return `${name} (${email})`;
|
||||||
|
if (email) return email;
|
||||||
|
if (name) return name;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDateTime(str) {
|
||||||
|
if (!str) return '—';
|
||||||
|
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z'));
|
||||||
|
return d.toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function DetailTooltip({ details }) {
|
function DetailTooltip({ details }) {
|
||||||
if (!details) return null;
|
if (!details) return null;
|
||||||
const entries = typeof details === 'object'
|
const entries = typeof details === 'object'
|
||||||
@@ -119,9 +184,11 @@ export default function AuditLogsSection() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [categories, setCats] = useState([]);
|
const [categories, setCats] = useState([]);
|
||||||
|
const [statusCounts, setStatusCounts] = useState({ success: 0, warning: 0, failure: 0 });
|
||||||
|
|
||||||
// Filtres
|
// Filtres
|
||||||
const [filterCat, setFilterCat] = useState('');
|
const [filterCat, setFilterCat] = useState('');
|
||||||
|
const [filterStatus, setFilterStatus] = useState('');
|
||||||
const [filterSearch, setFilterSearch] = useState('');
|
const [filterSearch, setFilterSearch] = useState('');
|
||||||
const [filterFrom, setFilterFrom] = useState('');
|
const [filterFrom, setFilterFrom] = useState('');
|
||||||
const [filterTo, setFilterTo] = useState('');
|
const [filterTo, setFilterTo] = useState('');
|
||||||
@@ -133,6 +200,7 @@ export default function AuditLogsSection() {
|
|||||||
try {
|
try {
|
||||||
const params = { page: p, limit: LIMIT };
|
const params = { page: p, limit: LIMIT };
|
||||||
if (filterCat) params.category = filterCat;
|
if (filterCat) params.category = filterCat;
|
||||||
|
if (filterStatus) params.status = filterStatus;
|
||||||
if (filterSearch) params.search = filterSearch;
|
if (filterSearch) params.search = filterSearch;
|
||||||
if (filterFrom) params.dateFrom = filterFrom;
|
if (filterFrom) params.dateFrom = filterFrom;
|
||||||
if (filterTo) params.dateTo = filterTo;
|
if (filterTo) params.dateTo = filterTo;
|
||||||
@@ -143,6 +211,7 @@ export default function AuditLogsSection() {
|
|||||||
]);
|
]);
|
||||||
setRows(data.rows);
|
setRows(data.rows);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
|
setStatusCounts(data.statusCounts || { success: 0, warning: 0, failure: 0 });
|
||||||
setPage(p);
|
setPage(p);
|
||||||
if (!categories.length) setCats(cats);
|
if (!categories.length) setCats(cats);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -150,9 +219,9 @@ export default function AuditLogsSection() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filterCat, filterSearch, filterFrom, filterTo]); // eslint-disable-line
|
}, [filterCat, filterStatus, filterSearch, filterFrom, filterTo]); // eslint-disable-line
|
||||||
|
|
||||||
useEffect(() => { load(1); }, [filterCat, filterFrom, filterTo]); // eslint-disable-line
|
useEffect(() => { load(1); }, [filterCat, filterStatus, filterFrom, filterTo]); // eslint-disable-line
|
||||||
|
|
||||||
// Recherche texte : debounce 350ms
|
// Recherche texte : debounce 350ms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -160,9 +229,10 @@ export default function AuditLogsSection() {
|
|||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [filterSearch]); // eslint-disable-line
|
}, [filterSearch]); // eslint-disable-line
|
||||||
|
|
||||||
const toggleCat = (cat) => setFilterCat(prev => prev === cat ? '' : cat);
|
const toggleStatus = (st) => setFilterStatus(prev => prev === st ? '' : st);
|
||||||
|
|
||||||
const allCats = Object.keys(CATEGORY_META);
|
const allCats = Object.keys(CATEGORY_META);
|
||||||
|
const allStatuses = Object.keys(STATUS_META);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -173,10 +243,25 @@ export default function AuditLogsSection() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtres catégories */}
|
{/* Filtres : catégorie (liste déroulante) + statut (chips avec compteurs) */}
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
|
||||||
{allCats.map(cat => (
|
<select
|
||||||
<CategoryBadge key={cat} cat={cat} active={filterCat === cat} onClick={toggleCat} />
|
value={filterCat}
|
||||||
|
onChange={e => setFilterCat(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: 'auto', minWidth: 180, flexShrink: 0,
|
||||||
|
height: 32, padding: '0 8px', borderRadius: 8, border: '1px solid var(--border)',
|
||||||
|
background: 'var(--surface)', color: 'var(--text)', fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Toutes les catégories</option>
|
||||||
|
{allCats.map(cat => (
|
||||||
|
<option key={cat} value={cat}>{CATEGORY_META[cat]?.label || cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{allStatuses.map(st => (
|
||||||
|
<StatusBadge key={st} status={st} count={statusCounts[st] ?? 0} active={filterStatus === st} onClick={toggleStatus} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,9 +334,9 @@ export default function AuditLogsSection() {
|
|||||||
{rows.map((row, i) => {
|
{rows.map((row, i) => {
|
||||||
const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' };
|
const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' };
|
||||||
const label = ACTION_LABELS[row.action] || row.action;
|
const label = ACTION_LABELS[row.action] || row.action;
|
||||||
const actor = row.actor_name || row.actor_email || '—';
|
const actor = fmtPerson(row.actor_name, row.actor_email);
|
||||||
const target = row.target_email && row.target_id !== row.actor_id
|
const target = row.target_email && row.target_id !== row.actor_id
|
||||||
? (row.target_name || row.target_email)
|
? fmtPerson(row.target_name, row.target_email)
|
||||||
: '—';
|
: '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,6 +360,7 @@ export default function AuditLogsSection() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '7px 10px' }}>
|
<td style={{ padding: '7px 10px' }}>
|
||||||
|
<StatusIcon status={ACTION_STATUS[row.action]} />
|
||||||
<span style={{ color: 'var(--text)' }}>{label}</span>
|
<span style={{ color: 'var(--text)' }}>{label}</span>
|
||||||
<DetailTooltip details={row.details} />
|
<DetailTooltip details={row.details} />
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user