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
|
- **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**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user