diff --git a/MEMORY.md b/MEMORY.md index 1a3738f..680fc99 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1,5 +1,5 @@ # MEMORY.md — Crowdlending Tracker -*Dernière mise à jour: 2026-06-15 (session 7)* +*Dernière mise à jour: 2026-07-03 (session 8)* --- @@ -425,3 +425,29 @@ const isBonus = BONUS_VALUES.includes(form.investissement_id); - `useState(8)` comme valeur initiale sur tous les écrans → jamais bloquant si l'API est lente - Register/ResetPassword/InvitationRegister : via `appInfo.minPasswordLength || 8` (appInfo déjà fetché) - MonCompte (SecurityForm) et UsersSection (CreateUserModal) : state local `minPasswordLength` + useEffect fetch dédié + +--- + +## Session 8 — UX empty state + visibilité mot de passe (2026-07-03) + +### EmptyState → ouverture directe de l'ajout de plateforme +- `EmptyState.jsx` : prop `to` par défaut passée de `/settings?section=plateformes` à `/settings?section=plateformes&openAdd=1` +- `PlateformesSection.jsx` : `useSearchParams` + `useEffect` détecte `openAdd=1` au montage → ouvre directement `showAddPicker` (modale "Ajouter une plateforme") → nettoie le paramètre de l'URL (`replace: true`) pour éviter la réouverture au refresh +- Évite l'étape intermédiaire où l'utilisateur devait cliquer une seconde fois sur "+ Ajouter" après avoir été redirigé depuis un état vide (Dashboard, Investissements, Remboursements, DepotsRetraits) +- **Pattern réutilisable** : pour toute redirection "action directe" similaire depuis un état vide, ajouter un query param dédié + `useEffect` de consommation/nettoyage dans la section cible + +### Composant PasswordInput — afficher/masquer mot de passe +- Nouveau composant `frontend/src/components/PasswordInput.jsx` : wrapper autour d'un `` avec bouton œil (SVG inline, pas de lib externe) togglant `type` entre `password`/`text` +- API : toutes les props (`className`, `value`, `onChange`, `required`, `autoComplete`, `minLength`, `style`, etc.) sont transmises telles quelles à l'`` interne ; `wrapperStyle` optionnel pour le `
` englobant +- **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 + +### 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** + - `POST /api/invitations/:token/register` (invitations.js, `InvitationRegister.jsx`) : ne créait **aucun investisseur ni compte courant** +- **Cas réel trouvé en base** : `marine@croguennec.net` (user #2, créée par invitation le 2026-06-18) n'avait **aucun** investisseur ; `newargus@gmail.com` (user #3, créé par l'admin le 2026-07-03) avait un investisseur mais `is_principal=0` et zéro compte +- **Fix appliqué** : + 1. `admin.js` (`POST /users`) et `invitations.js` (`POST /:token/register`) répliquent maintenant exactement la logique de `auth.js` : `INSERT INTO investisseurs (..., is_principal) VALUES (..., 1)` + `INSERT INTO comptes (user_id, nom, type, investisseur_id)` avec `nom = 'Compte courant — ' + fullName` + 2. Backfill idempotent ajouté en fin de `backend/src/db/index.js` (avant `export default db`) qui tourne à chaque démarrage : (1) crée un investisseur principal pour tout user qui n'en a aucun, (2) marque principal le plus ancien investisseur `famille` pour tout user qui n'a pas de principal, (3) crée le compte courant manquant pour tout investisseur principal qui n'en a pas. Toutes les requêtes utilisent `NOT EXISTS` → sans effet une fois les données corrigées. +- **Règle à retenir** : toute nouvelle voie de création de compte utilisateur doit répliquer les 2 inserts de `auth.js` (`investisseurs` avec `is_principal=1` + `comptes` type `compte_courant`) — ne pas dupliquer seulement l'insert `investisseurs`. diff --git a/backend/src/db/index.js b/backend/src/db/index.js index deafeaf..8da3af9 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2012,4 +2012,54 @@ console.log('[DB] Migrations 2FA OK'); } } +// ── Backfill : garantir un profil principal + un compte courant par utilisateur ── +// Certains parcours de création de compte (admin, invitation) ne créaient pas +// systématiquement le profil investisseur principal et/ou son compte courant, +// contrairement à /auth/register. Ce backfill est idempotent (NOT EXISTS partout) +// et s'exécute à chaque démarrage pour rattraper les comptes existants. +{ + // 1) Utilisateurs sans aucun investisseur (ex : comptes créés via invitation) + const usersWithoutInvestisseur = db.prepare(` + SELECT id, email, display_name FROM users u + WHERE NOT EXISTS (SELECT 1 FROM investisseurs i WHERE i.user_id = u.id) + `).all(); + for (const u of usersWithoutInvestisseur) { + const fullName = u.display_name || u.email.split('@')[0]; + const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null; + db.prepare( + `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)` + ).run(u.id, fullName, prenom); + console.log(`[DB] backfill: profil investisseur principal créé pour user #${u.id} (${u.email})`); + } + + // 2) Utilisateurs ayant des investisseurs mais aucun marqué principal (ex : comptes créés par un admin) + const usersWithoutPrincipal = db.prepare(` + SELECT DISTINCT user_id FROM investisseurs i + WHERE NOT EXISTS (SELECT 1 FROM investisseurs p WHERE p.user_id = i.user_id AND p.is_principal = 1) + `).all(); + for (const { user_id } of usersWithoutPrincipal) { + const candidate = db.prepare(` + SELECT id FROM investisseurs WHERE user_id = ? ORDER BY (type = 'famille') DESC, id ASC LIMIT 1 + `).get(user_id); + if (candidate) { + db.prepare('UPDATE investisseurs SET is_principal = 1 WHERE id = ?').run(candidate.id); + console.log(`[DB] backfill: investisseur #${candidate.id} marqué principal pour user #${user_id}`); + } + } + + // 3) Investisseurs principaux sans compte courant + const principalsWithoutCompte = db.prepare(` + SELECT i.id AS investisseur_id, i.user_id, i.nom + FROM investisseurs i + WHERE i.is_principal = 1 + AND NOT EXISTS (SELECT 1 FROM comptes c WHERE c.investisseur_id = i.id) + `).all(); + for (const inv of principalsWithoutCompte) { + db.prepare( + 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' + ).run(inv.user_id, `Compte courant — ${inv.nom}`, 'compte_courant', inv.investisseur_id); + console.log(`[DB] backfill: compte courant créé pour l'investisseur principal #${inv.investisseur_id} (user #${inv.user_id})`); + } +} + export default db; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 547472d..822be6f 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -107,10 +107,17 @@ router.post('/users', (req, res, next) => { const userId = result.lastInsertRowid; const fullName = body.displayName || body.email.split('@')[0]; const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null; - db.prepare( - `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal) VALUES (?, ?, ?, 'famille', 'PP')` + + // Auto-créer le profil principal (= l'utilisateur lui-même), comme dans /auth/register + const invResult = db.prepare( + `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)` ).run(userId, fullName, prenom); + // Auto-créer un compte courant pour le profil principal, comme dans /auth/register + db.prepare( + 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' + ).run(userId, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid); + audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } }); res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role }); } catch (e) { next(e); } diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js index 61a8a2b..c86badf 100644 --- a/backend/src/routes/invitations.js +++ b/backend/src/routes/invitations.js @@ -167,7 +167,8 @@ router.post('/:token/register', async (req, res, next) => { const pendingUser = db.prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND email_verified = 0').get(inv.email); if (!pendingUser) throw new HttpError(409, 'Ce compte a déjà été activé ou supprimé.'); - const hash = await bcrypt.hash(password, 12); + const hash = await bcrypt.hash(password, 12); + const fullName = displayName || nameFromEmail(inv.email); // Finaliser le compte : mot de passe, nom, vérification db.prepare(` @@ -177,7 +178,18 @@ router.post('/:token/register', async (req, res, next) => { email_verified = 1, updated_at = datetime('now') WHERE id = ? - `).run(hash, displayName || nameFromEmail(inv.email), pendingUser.id); + `).run(hash, fullName, pendingUser.id); + + // Auto-créer le profil principal (= l'utilisateur lui-même) + son compte courant, + // comme dans /auth/register — sans quoi l'utilisateur invité se retrouve sans + // aucun investisseur ni compte courant à sa première connexion. + const prenom = fullName.includes(' ') ? fullName.split(' ')[0] : null; + const invResult = db.prepare( + `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, is_principal) VALUES (?, ?, ?, 'famille', 'PP', 1)` + ).run(pendingUser.id, fullName, prenom); + db.prepare( + 'INSERT INTO comptes (user_id, nom, type, investisseur_id) VALUES (?,?,?,?)' + ).run(pendingUser.id, `Compte courant — ${fullName}`, 'compte_courant', invResult.lastInsertRowid); // Marquer l'invitation comme utilisée db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id); diff --git a/frontend/src/pages/admin/CreateUserSection.jsx b/frontend/src/pages/admin/CreateUserSection.jsx index 30eefd3..ddae4e0 100644 --- a/frontend/src/pages/admin/CreateUserSection.jsx +++ b/frontend/src/pages/admin/CreateUserSection.jsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { api } from '../../api.js'; +import PasswordInput from '../../components/PasswordInput.jsx'; export default function CreateUserSection({ onCreated }) { const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' }); @@ -48,7 +49,7 @@ export default function CreateUserSection({ onCreated }) {
- set('password', e.target.value)} placeholder="8 caractères minimum" /> + set('password', e.target.value)} placeholder="8 caractères minimum" />