diff --git a/backend/src/db/index.js b/backend/src/db/index.js index cdfaae4..77b1ea8 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1870,6 +1870,13 @@ console.log('[DB] Migrations 2FA OK'); db.exec("ALTER TABLE smtp_config ADD COLUMN min_password_length INTEGER NOT NULL DEFAULT 8"); console.log('[DB] Colonne smtp_config.min_password_length ajoutée'); } + + // ── Migration : email sur investisseurs ─────────────────────────────── + const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name); + if (!invColsEmail.includes('email')) { + db.exec('ALTER TABLE investisseurs ADD COLUMN email TEXT'); + console.log('[DB] Colonne investisseurs.email ajoutée'); + } } export default db; diff --git a/backend/src/routes/investisseurs.js b/backend/src/routes/investisseurs.js index 9c8b337..c8ff0b9 100644 --- a/backend/src/routes/investisseurs.js +++ b/backend/src/routes/investisseurs.js @@ -11,12 +11,13 @@ const Schema = z.object({ type: z.enum(['famille', 'entreprise']).default('famille'), type_fiscal: z.string().optional(), notes: z.string().optional(), + email: z.string().email().optional().or(z.literal('')), }); router.get('/', (req, res) => { const rows = db .prepare( - `SELECT id, nom, prenom, type, type_fiscal, is_principal, notes, created_at + `SELECT id, nom, prenom, type, type_fiscal, is_principal, notes, email, created_at FROM investisseurs WHERE user_id = ? ORDER BY is_principal DESC, type, nom` ) @@ -29,7 +30,7 @@ router.post('/', (req, res, next) => { const body = Schema.parse(req.body); const r = db .prepare( - 'INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes) VALUES (?,?,?,?,?,?)' + 'INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes, email) VALUES (?,?,?,?,?,?,?)' ) .run( req.user.id, @@ -38,6 +39,7 @@ router.post('/', (req, res, next) => { body.type, body.type_fiscal || null, body.notes || null, + body.email || null, ); const invId = r.lastInsertRowid; // Auto-créer un compte courant pour ce nouveau profil @@ -56,7 +58,7 @@ router.put('/:id', (req, res, next) => { const body = Schema.parse(req.body); const r = db .prepare( - `UPDATE investisseurs SET nom=?, prenom=?, type=?, type_fiscal=?, notes=?, updated_at=datetime('now') + `UPDATE investisseurs SET nom=?, prenom=?, type=?, type_fiscal=?, notes=?, email=?, updated_at=datetime('now') WHERE id=? AND user_id=?` ) .run( @@ -65,6 +67,7 @@ router.put('/:id', (req, res, next) => { body.type, body.type_fiscal || null, body.notes || null, + body.email || null, req.params.id, req.user.id, ); @@ -115,10 +118,25 @@ router.delete('/:id', (req, res, next) => { .get(req.user.id).n; if (count <= 1) throw new HttpError(400, 'Impossible de supprimer le dernier profil.'); - const r = db - .prepare('DELETE FROM investisseurs WHERE id=? AND user_id=?') - .run(req.params.id, req.user.id); - if (r.changes === 0) throw new HttpError(404, 'Not found'); + // Récupérer le compte principal pour la réassignation + const principal = db + .prepare('SELECT id FROM investisseurs WHERE user_id=? AND is_principal=1') + .get(req.user.id); + if (!principal) throw new HttpError(500, 'Aucun compte principal trouvé pour la réassignation.'); + + const principalId = principal.id; + const targetId = Number(req.params.id); + + // Réassigner toutes les données liées au compte principal avant suppression + const deleteWithReassign = db.transaction(() => { + db.prepare('UPDATE investissements SET investisseur_id=? WHERE investisseur_id=?').run(principalId, targetId); + db.prepare('UPDATE depots_retraits SET investisseur_id=? WHERE investisseur_id=?').run(principalId, targetId); + db.prepare('UPDATE plateformes SET investisseur_id=? WHERE investisseur_id=? AND user_id=?').run(principalId, targetId, req.user.id); + db.prepare('UPDATE comptes SET investisseur_id=? WHERE investisseur_id=? AND user_id=?').run(principalId, targetId, req.user.id); + db.prepare('DELETE FROM investisseurs WHERE id=? AND user_id=?').run(targetId, req.user.id); + }); + + deleteWithReassign(); res.status(204).end(); } catch (e) { next(e); } }); diff --git a/backend/src/routes/plateformes.js b/backend/src/routes/plateformes.js index 00cde7d..32511ab 100644 --- a/backend/src/routes/plateformes.js +++ b/backend/src/routes/plateformes.js @@ -314,7 +314,7 @@ const zipUpload = multer({ router.get('/export', (req, res, next) => { try { let rows = db.prepare(` - SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.user_id = ? @@ -358,6 +358,7 @@ router.get('/export', (req, res, next) => { icone_filename: r.icone_filename, investisseur_nom: r.investisseur_nom, investisseur_prenom: r.investisseur_prenom, + investisseur_email: r.investisseur_email, categories: legacyMap[r.id] || [], categories_inv: (r.categories_inv || []).map(c => c.nom), secteurs_inv: (r.secteurs_inv || []).map(s => s.nom), @@ -368,7 +369,7 @@ router.get('/export', (req, res, next) => { // Export des investisseurs (détenteurs) liés aux plateformes const invIds = [...new Set(rows.map(r => r.investisseur_id).filter(Boolean))]; const invRows = invIds.length > 0 - ? db.prepare(`SELECT nom, prenom, type, type_fiscal, notes FROM investisseurs WHERE id IN (${invIds.map(() => '?').join(',')}) AND user_id = ?`).all(...invIds, req.user.id) + ? db.prepare(`SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id IN (${invIds.map(() => '?').join(',')}) AND user_id = ?`).all(...invIds, req.user.id) : []; if (invRows.length > 0) { entries.push({ name: 'investisseurs.json', data: JSON.stringify(invRows, null, 2) }); @@ -394,7 +395,7 @@ router.get('/export', (req, res, next) => { router.get('/:id/export', (req, res, next) => { try { const p = db.prepare(` - SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom + SELECT p.*, inv.nom AS investisseur_nom, inv.prenom AS investisseur_prenom, inv.email AS investisseur_email FROM plateformes p LEFT JOIN investisseurs inv ON inv.id = p.investisseur_id WHERE p.id = ? AND p.user_id = ? `).get(req.params.id, req.user.id); @@ -421,7 +422,7 @@ router.get('/:id/export', (req, res, next) => { date_ouverture: withAll.date_ouverture, type_pret_defaut: withAll.type_pret_defaut, freq_interets_defaut: withAll.freq_interets_defaut, logo_filename: withAll.logo_filename, icone_filename: withAll.icone_filename, - investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, + investisseur_nom: withAll.investisseur_nom, investisseur_prenom: withAll.investisseur_prenom, investisseur_email: withAll.investisseur_email, categories: catsLegacy, categories_inv: (withAll.categories_inv || []).map(c => c.nom), secteurs_inv: (withAll.secteurs_inv || []).map(s => s.nom), @@ -430,7 +431,7 @@ router.get('/:id/export', (req, res, next) => { // Export du détenteur lié à la plateforme if (withAll.investisseur_id) { - const inv = db.prepare('SELECT nom, prenom, type, type_fiscal, notes FROM investisseurs WHERE id = ? AND user_id = ?').get(withAll.investisseur_id, req.user.id); + const inv = db.prepare('SELECT nom, prenom, type, type_fiscal, notes, email FROM investisseurs WHERE id = ? AND user_id = ?').get(withAll.investisseur_id, req.user.id); if (inv) entries.push({ name: 'investisseurs.json', data: JSON.stringify([inv], null, 2) }); } @@ -459,26 +460,34 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { const platforms = JSON.parse(dataEntry.data.toString('utf8')); if (!Array.isArray(platforms)) throw new HttpError(400, 'data.json doit être un tableau'); - // Importer les investisseurs du ZIP (créer les manquants) + // Importer les investisseurs du ZIP (créer les manquants, dédupliquer par email puis nom) const invEntry = zipEntries.find(e => e.name === 'investisseurs.json'); if (invEntry) { const invList = JSON.parse(invEntry.data.toString('utf8')); for (const inv of (Array.isArray(invList) ? invList : [])) { if (!inv.nom) continue; - const exists = db.prepare('SELECT id FROM investisseurs WHERE nom = ? AND user_id = ?').get(inv.nom, req.user.id); - if (!exists) { - db.prepare('INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes) VALUES (?,?,?,?,?,?)') - .run(req.user.id, inv.nom, inv.prenom ?? null, inv.type || 'famille', inv.type_fiscal ?? null, inv.notes ?? null); + // Déduplication : priorité à l'email, puis correspondance insensible à la casse sur le nom + const existsByEmail = inv.email + ? db.prepare('SELECT id FROM investisseurs WHERE LOWER(email) = LOWER(?) AND user_id = ?').get(inv.email, req.user.id) + : null; + const existsByNom = db.prepare('SELECT id FROM investisseurs WHERE LOWER(nom) = LOWER(?) AND user_id = ?').get(inv.nom, req.user.id); + if (!existsByEmail && !existsByNom) { + db.prepare('INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal, notes, email) VALUES (?,?,?,?,?,?,?)') + .run(req.user.id, inv.nom, inv.prenom ?? null, inv.type || 'famille', inv.type_fiscal ?? null, inv.notes ?? null, inv.email ?? null); } } } - // Résoudre investisseur par nom+prénom (après création éventuelle) + // Résoudre investisseur — priorité email, puis nom+prénom insensible à la casse const userInvestisseurs = db.prepare('SELECT * FROM investisseurs WHERE user_id = ?').all(req.user.id); - function resolveInvestisseur(nom, prenom) { - if (!nom) return userInvestisseurs[0]?.id ?? null; + function resolveInvestisseur(nom, prenom, email) { + if (!nom && !email) return userInvestisseurs[0]?.id ?? null; + if (email) { + const byEmail = userInvestisseurs.find(i => i.email?.toLowerCase() === email.toLowerCase()); + if (byEmail) return byEmail.id; + } const match = userInvestisseurs.find(i => - i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === prenom?.toLowerCase() + i.nom?.toLowerCase() === nom?.toLowerCase() && i.prenom?.toLowerCase() === (prenom || '').toLowerCase() ) || userInvestisseurs.find(i => i.nom?.toLowerCase() === nom?.toLowerCase()); return match?.id ?? userInvestisseurs[0]?.id ?? null; } @@ -527,7 +536,7 @@ router.post('/import-zip', zipUpload.single('file'), async (req, res, next) => { if (!p.nom) continue; const fiscalite = p.domiciliation === 'FR' ? 'flat_tax' : (p.fiscalite || 'flat_tax'); const taux = fiscalite === 'avec_fiscalite_locale' ? (p.taux_fiscalite_locale ?? null) : null; - const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom); + const investisseurId = resolveInvestisseur(p.investisseur_nom, p.investisseur_prenom, p.investisseur_email); const catsInvIds = resolveTagIds(p.categories_inv, 'categories_inv'); const sectsInvIds = resolveTagIds(p.secteurs_inv, 'secteurs_inv'); const legacyCatIds = resolveLegacyCatIds(p.categories); diff --git a/frontend/public/login-bg.jpg b/frontend/public/login-bg-old.jpg similarity index 100% rename from frontend/public/login-bg.jpg rename to frontend/public/login-bg-old.jpg diff --git a/frontend/public/login-bg.png b/frontend/public/login-bg.png new file mode 100644 index 0000000..3867ce4 Binary files /dev/null and b/frontend/public/login-bg.png differ diff --git a/frontend/src/components/AuthBgCol.jsx b/frontend/src/components/AuthBgCol.jsx new file mode 100644 index 0000000..ea24235 --- /dev/null +++ b/frontend/src/components/AuthBgCol.jsx @@ -0,0 +1,21 @@ +/** + * AuthBgCol — colonne image gauche partagée entre toutes les pages d'authentification. + * + * L'image /login-bg.jpg contient les deux thèmes côte à côte (dark à gauche, light à droite). + * La classe CSS .login-bg-col utilise background-position pour focaliser sur la bonne moitié + * selon le thème actif ([data-theme="dark"] sur ). + */ +export default function AuthBgCol({ appInfo = {} }) { + return ( +
+ + ); +} diff --git a/frontend/src/pages/FamilleEntreprises.jsx b/frontend/src/pages/FamilleEntreprises.jsx index b3e2ef2..261f97c 100644 --- a/frontend/src/pages/FamilleEntreprises.jsx +++ b/frontend/src/pages/FamilleEntreprises.jsx @@ -93,8 +93,8 @@ export default function FamilleEntreprises() { const [editTarget, setEditTarget] = useState(null); // membre à éditer /* Formulaires */ - const emptyFam = { prenom: '', nom_famille: '' }; - const emptyEnt = { nom: '', type_fiscal: 'PM' }; + const emptyFam = { prenom: '', nom_famille: '', email: '' }; + const emptyEnt = { nom: '', type_fiscal: 'PM', email: '' }; const [famForm, setFamForm] = useState(emptyFam); const [entForm, setEntForm] = useState(emptyEnt); const [saving, setSaving] = useState(false); @@ -116,10 +116,10 @@ export default function FamilleEntreprises() { setEditTarget(m); if (m.type === 'famille') { const restNom = m.prenom ? m.nom.replace(m.prenom, '').trim() : m.nom; - setFamForm({ prenom: m.prenom || '', nom_famille: restNom }); + setFamForm({ prenom: m.prenom || '', nom_famille: restNom, email: m.email || '' }); setModalFamille(true); } else { - setEntForm({ nom: m.nom, type_fiscal: m.type_fiscal || 'PM' }); + setEntForm({ nom: m.nom, type_fiscal: m.type_fiscal || 'PM', email: m.email || '' }); setModalEntreprise(true); } }; @@ -142,6 +142,7 @@ export default function FamilleEntreprises() { prenom: famForm.prenom.trim() || null, type: 'famille', type_fiscal: 'PP', + email: famForm.email.trim() || null, }; if (editTarget) { await api.put(`/investisseurs/${editTarget.id}`, payload); @@ -163,6 +164,7 @@ export default function FamilleEntreprises() { prenom: null, type: 'entreprise', type_fiscal: entForm.type_fiscal, + email: entForm.email.trim() || null, }; if (editTarget) { await api.put(`/investisseurs/${editTarget.id}`, payload); @@ -279,6 +281,12 @@ export default function FamilleEntreprises() { onChange={e => setFamForm({ ...famForm, nom_famille: e.target.value })} placeholder="CROGUENNEC" />
+
+ + setFamForm({ ...famForm, email: e.target.value })} + placeholder="olivier@example.com" /> +
@@ -317,6 +325,12 @@ export default function FamilleEntreprises() { +
+ + setEntForm({ ...entForm, email: e.target.value })} + placeholder="contact@entreprise.com" /> +
- {/* ── Colonne gauche — image ───────────────────────────── */} -
- { e.target.style.display = 'none'; }} - style={{ - position: 'absolute', inset: 0, - width: '100%', height: '100%', - objectFit: 'cover', opacity: 0.85, - }} - /> -
- {appInfo.iconUrl && ( - - )} - - {appInfo.appName} - -
-
+ + {/* ── Colonne droite — formulaire ──────────────────────── */}
- - ); } diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 1520e04..f3f9170 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext.jsx'; import { api } from '../api.js'; +import AuthBgCol from '../components/AuthBgCol.jsx'; // ── Helpers ──────────────────────────────────────────────────────────────── const DEVICE_KEY = 'cl_device_token'; @@ -12,25 +13,6 @@ function fmtCountdown(sec) { return `${m}:${s}`; } -// ── Colonne image gauche (partagée) ─────────────────────────────────────── -function BgCol({ appInfo }) { - return ( -
- { e.target.style.display = 'none'; }} - style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} - /> -
- {appInfo.iconUrl && } - {appInfo.appName} -
-
- ); -} - // ── Header logo (colonne droite) ────────────────────────────────────────── function AppHeader({ appInfo }) { return ( @@ -188,7 +170,7 @@ export default function Login() { return (
- +
-
); } diff --git a/frontend/src/pages/Register.jsx b/frontend/src/pages/Register.jsx index 46dfee7..1a41cd9 100644 --- a/frontend/src/pages/Register.jsx +++ b/frontend/src/pages/Register.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext.jsx'; import PasswordStrength from '../components/PasswordStrength.jsx'; +import AuthBgCol from '../components/AuthBgCol.jsx'; export default function Register() { const { register } = useAuth(); @@ -39,39 +40,8 @@ export default function Register() { return (
- {/* ── Colonne gauche — image ───────────────────────────── */} -
- { e.target.style.display = 'none'; }} - style={{ - position: 'absolute', inset: 0, - width: '100%', height: '100%', - objectFit: 'cover', opacity: 0.85, - }} - /> -
- {appInfo.iconUrl && ( - - )} - - {appInfo.appName} - -
-
+ + {/* ── Colonne droite — formulaire ──────────────────────── */}
)} {/* fin verifyEmail ternaire */}
- - ); } diff --git a/frontend/src/pages/ResetPassword.jsx b/frontend/src/pages/ResetPassword.jsx index 9f1765e..9519c9b 100644 --- a/frontend/src/pages/ResetPassword.jsx +++ b/frontend/src/pages/ResetPassword.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import PasswordStrength from '../components/PasswordStrength.jsx'; +import AuthBgCol from '../components/AuthBgCol.jsx'; export default function ResetPassword() { const [params] = useSearchParams(); @@ -42,39 +43,8 @@ export default function ResetPassword() { return (
- {/* ── Colonne gauche — image ───────────────────────────── */} -
- { e.target.style.display = 'none'; }} - style={{ - position: 'absolute', inset: 0, - width: '100%', height: '100%', - objectFit: 'cover', opacity: 0.85, - }} - /> -
- {appInfo.iconUrl && ( - - )} - - {appInfo.appName} - -
-
+ + {/* ── Colonne droite — formulaire ──────────────────────── */}
- -
); } diff --git a/frontend/src/pages/VerifyEmail.jsx b/frontend/src/pages/VerifyEmail.jsx index 033fdf4..ff72f07 100644 --- a/frontend/src/pages/VerifyEmail.jsx +++ b/frontend/src/pages/VerifyEmail.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; +import AuthBgCol from '../components/AuthBgCol.jsx'; export default function VerifyEmail() { const [params] = useSearchParams(); @@ -26,19 +27,8 @@ export default function VerifyEmail() { return (
-
- { e.target.style.display = 'none'; }} - style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', opacity: 0.85 }} - /> -
- {appInfo.iconUrl && } - {appInfo.appName} -
-
+ +
- -
); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 8e22275..13724c0 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1899,7 +1899,10 @@ tr:hover td { background: var(--surface-2); } font-weight: 500; color: var(--text-muted); background: var(--surface-2); - bord + border-radius: 4px; + padding: 1px 5px; +} + /* ── DrillCellPanel table ── */ .drill-table { width: 100% !important; @@ -1973,42 +1976,64 @@ tr:hover td { background: var(--surface-2); } border-color: var(--primary); color: var(--primary); } -.pagination-btn:disabled { - opacity: .35; - cursor: default; -} -.pagination-pages { - font-size: var(--fs-xs); - color: var(--text-muted); - padding: 0 8px; - white-space: nowrap; + +/* ── Pages d'authentification (login, register, forgot, reset, verify) ───── */ +.login-bg-col { + flex: 1 1 50%; + display: none; + position: relative; + overflow: hidden; + + /* Image split dark/light : dark à gauche, light à droite. + background-size: 200% auto → zoom sur une seule moitié. + Par défaut (thème clair) : moitié droite. */ + background-image: url('/login-bg.png'); + background-size: 200% auto; + background-repeat: no-repeat; + background-position: 100% 50%; + background-color: #f0f2f8; } -/* ── Barre de filtres admin users ─────────────────────────────────────────── */ -.admin-filters { +[data-theme="dark"] .login-bg-col { + /* Thème sombre : moitié gauche de l'image. */ + background-position: 0% 50%; + background-color: #0d0e1a; +} + +@media (min-width: 768px) { + .login-bg-col { display: block !important; } +} + +.login-bg-overlay { + position: absolute; + inset: 0; + /* léger dégradé pour améliorer la lisibilité du badge app en bas */ + background: linear-gradient(to top, rgba(0,0,0,.35) 0%, transparent 40%); + pointer-events: none; +} + +[data-theme="dark"] .login-bg-overlay { + background: linear-gradient(to top, rgba(0,0,0,.55) 0%, transparent 40%); +} + +.login-bg-brand { + position: absolute; + bottom: 36px; + left: 36px; display: flex; align-items: center; + gap: 12px; + color: #fff; + font-size: 17px; + font-weight: 600; + letter-spacing: -.3px; + text-shadow: 0 1px 4px rgba(0,0,0,.4); +} + +.login-mobile-header { + display: flex; + flex-direction: column; + align-items: center; gap: 10px; - margin-bottom: 16px; - flex-wrap: wrap; -} -.admin-filters input[type=search] { - flex: 1; - min-width: 180px; - padding: 7px 12px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - font-size: var(--fs-sm); -} -.admin-filters input[type=search]::placeholder { color: var(--text-muted); } -.admin-filters select { - padding: 7px 12px; - border-radius: 6px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - font-size: var(--fs-sm); - min-width: 160px; + margin-bottom: 32px; }