Feature: Suppression de son compte

This commit is contained in:
2026-07-03 19:49:32 +02:00
parent 89190c4561
commit 2281894802
6 changed files with 169 additions and 5 deletions
+8
View File
@@ -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**
+70
View File
@@ -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
View File
@@ -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) =>
+6 -1
View File
@@ -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>
); );
+9 -1
View File
@@ -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.
+70 -1
View File
@@ -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>