Feature: Suppression de son compte
This commit is contained in:
@@ -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 `<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
|
||||
- **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**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+6
-2
@@ -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) =>
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<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 && (
|
||||
<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.
|
||||
|
||||
@@ -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 ─────────────────────────────────────────── */
|
||||
export default function MonCompte() {
|
||||
const { search } = useLocation();
|
||||
@@ -747,7 +816,7 @@ export default function MonCompte() {
|
||||
{/* ── Contenu ─────────────────────────────────────── */}
|
||||
<div className="account-content account-content-center">
|
||||
<div className="account-content-narrow">
|
||||
{section === 'profil' && <AccountForm />}
|
||||
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
|
||||
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user