From 2281894802be4ea4156f6e2d3f2205aaf957ec25 Mon Sep 17 00:00:00 2001 From: Olivier Date: Fri, 3 Jul 2026 19:49:32 +0200 Subject: [PATCH] Feature: Suppression de son compte --- MEMORY.md | 8 ++++ backend/src/routes/auth.js | 70 +++++++++++++++++++++++++++ frontend/src/api.js | 8 +++- frontend/src/context/AuthContext.jsx | 7 ++- frontend/src/pages/Login.jsx | 10 +++- frontend/src/pages/MonCompte.jsx | 71 +++++++++++++++++++++++++++- 6 files changed, 169 insertions(+), 5 deletions(-) diff --git a/MEMORY.md b/MEMORY.md index 680fc99..33802b7 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 - **Règle à respecter** : tout nouveau champ mot de passe doit utiliser `` plutôt que `` 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 - **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** diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 78cdb1e..522a935 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -252,6 +252,76 @@ router.put('/me', requireAuth, async (req, res, next) => { } 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 ────────────────────────────────────────── router.get('/verify-email', (req, res, next) => { try { diff --git a/frontend/src/api.js b/frontend/src/api.js index ba51ade..c4017c6 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -54,8 +54,12 @@ export const api = { headers: { 'Content-Type': 'application/json', ...authHeaders() }, body: JSON.stringify(body), }).then(handle), - del: (path) => - fetch(BASE + path, { method: 'DELETE', headers: authHeaders() }).then(handle), + del: (path, body) => + fetch(BASE + path, { + method: 'DELETE', + headers: body ? { 'Content-Type': 'application/json', ...authHeaders() } : authHeaders(), + ...(body ? { body: JSON.stringify(body) } : {}), + }).then(handle), upload: (path, formData) => fetch(BASE + path, { method: 'POST', body: formData, headers: authHeaders() }).then(handle), postForm: (path, formData) => diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 55a4866..5db4ecf 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -67,10 +67,15 @@ export function AuthProvider({ children }) { return r.user; }; + const deleteAccount = async (password) => { + await api.del('/auth/me', { password }); + logout(); + }; + const isAdmin = user?.role === 'admin'; return ( - + {children} ); diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index f39709d..28e7b38 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -1,5 +1,5 @@ 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 { api } from '../api.js'; import AuthBgCol from '../components/AuthBgCol.jsx'; @@ -28,6 +28,8 @@ function AppHeader({ appInfo }) { export default function Login() { const { login, completeLogin } = useAuth(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const accountDeleted = searchParams.get('deleted') === '1'; const [appInfo, setAppInfo] = useState({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true }); @@ -193,6 +195,12 @@ export default function Login() {
+ {accountDeleted && !err && ( +
+ Votre compte a été supprimé avec succès. +
+ )} + {unverified && (
Email non vérifié. Vérifiez votre boîte mail et cliquez sur le lien reçu. diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx index 62af227..83be1f2 100644 --- a/frontend/src/pages/MonCompte.jsx +++ b/frontend/src/pages/MonCompte.jsx @@ -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 ( +
+

Supprimer mon compte

+

+ Cette action est définitive et irréversible. 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. +

+ + {err &&
{err}
} + + {!confirming && ( +
+ +
+ )} + + {confirming && ( + +

+ Confirmez votre mot de passe pour supprimer définitivement votre compte. +

+
+ + setPassword(e.target.value)} /> +
+
+ + +
+ + )} +
+ ); +} + /* ── Page principale ─────────────────────────────────────────── */ export default function MonCompte() { const { search } = useLocation(); @@ -747,7 +816,7 @@ export default function MonCompte() { {/* ── Contenu ─────────────────────────────────────── */}
- {section === 'profil' && } + {section === 'profil' && <>} {section === 'securite' && <>}