diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 0919672..6fe09e2 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -2279,4 +2279,22 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)'); +// ── Migration : clés API à scope "Famille et entreprises" ─────────────── +// Une clé peut désormais couvrir tous les investisseurs du foyer plutôt +// qu'un seul (agrégation, en miroir du scope=all déjà utilisé par le +// frontend JWT). `investisseur_id` reste NOT NULL pour ne pas toucher à la +// contrainte existante : quand scope_all=1, la route de création force +// investisseur_id à pointer vers l'investisseur principal (ancrage FK), +// mais les routes /api/v1/* ignorent alors cette valeur au profit d'un +// filtre "tous les investisseurs de ce user_id" — voir apiKey.js et +// routes/v1/*.js. Seul le profil principal peut créer une clé scope_all=1 +// (enforcement côté serveur dans routes/apiKeys.js, pas seulement l'UI). +{ + const cols = db.prepare('PRAGMA table_info(api_keys)').all().map(c => c.name); + if (!cols.includes('scope_all')) { + db.exec('ALTER TABLE api_keys ADD COLUMN scope_all INTEGER NOT NULL DEFAULT 0'); + console.log('[DB] api_keys: colonne scope_all ajoutée'); + } +} + export default db; diff --git a/backend/src/middleware/apiKey.js b/backend/src/middleware/apiKey.js index b102bfd..89ee1b8 100644 --- a/backend/src/middleware/apiKey.js +++ b/backend/src/middleware/apiKey.js @@ -3,9 +3,16 @@ import db from '../db/index.js'; /** * Authentification par clé API (X-API-Key), distincte du JWT utilisé par le - * frontend (requireAuth). Une clé API est toujours scopée à un seul - * investisseur — pas de notion de "scope=all" ici, contrairement aux routes - * internes. Réservé aux routes /api/v1 (lecture seule, Phase 1). + * frontend (requireAuth). Une clé API est scopée soit à un seul investisseur, + * soit à "Famille et entreprises" (scope_all=1, réservé au profil principal + * — voir routes/apiKeys.js) — en miroir du scope=all des routes internes. + * Réservé aux routes /api/v1 (lecture seule, Phase 1). + * + * Expose sur `req` : + * - req.investisseurId : id de l'investisseur si scope unique, sinon null + * - req.investisseurScopeAll: true si la clé couvre tout le foyer + * - req.userId : user_id du titulaire de la clé (toujours défini, + * utile pour le filtre "tous les investisseurs" quand scope_all) */ export function requireApiKey(req, res, next) { const key = req.header('X-API-Key'); @@ -15,7 +22,7 @@ export function requireApiKey(req, res, next) { const hash = crypto.createHash('sha256').update(key).digest('hex'); const row = db.prepare(` - SELECT k.id, k.investisseur_id, k.scopes, k.revoked_at + SELECT k.id, k.user_id, k.investisseur_id, k.scope_all, k.scopes, k.revoked_at FROM api_keys k WHERE k.key_hash = ? `).get(hash); @@ -26,8 +33,10 @@ export function requireApiKey(req, res, next) { db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id); - req.apiKeyId = row.id; - req.investisseurId = row.investisseur_id; - req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim()); + req.apiKeyId = row.id; + req.userId = row.user_id; + req.investisseurScopeAll = !!row.scope_all; + req.investisseurId = req.investisseurScopeAll ? null : row.investisseur_id; + req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim()); next(); } diff --git a/backend/src/routes/apiKeys.js b/backend/src/routes/apiKeys.js index cd2fa58..e7102a9 100644 --- a/backend/src/routes/apiKeys.js +++ b/backend/src/routes/apiKeys.js @@ -17,7 +17,7 @@ function generateKey() { /* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */ router.get('/', (req, res) => { const rows = db.prepare(` - SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, + SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all, i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id @@ -27,29 +27,37 @@ router.get('/', (req, res) => { res.json(rows); }); -/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur) ──── */ +/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur, ou + scope_all pour "Famille et entreprises") ────────────────────────────── + scope_all=true n'est autorisé que si investisseur_id désigne le profil + principal — enforcement serveur, indépendant de ce que montre l'UI, pour + qu'un appel direct à l'API ne puisse pas contourner cette règle. */ router.post('/', (req, res, next) => { try { const nom = (req.body?.nom || '').trim(); const investisseur_id = Number(req.body?.investisseur_id); + const scope_all = !!req.body?.scope_all; if (!nom) throw new HttpError(400, 'Le nom de la clé est requis'); if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)'); if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis'); - const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?') + const inv = db.prepare('SELECT id, is_principal FROM investisseurs WHERE id = ? AND user_id = ?') .get(investisseur_id, req.user.id); if (!inv) throw new HttpError(404, 'Investisseur introuvable'); + if (scope_all && !inv.is_principal) { + throw new HttpError(403, 'Seul le profil principal peut créer une clé « Famille et entreprises »'); + } const { full, hash, prefix } = generateKey(); const info = db.prepare(` - INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes) - VALUES (?, ?, ?, ?, ?, 'read') - `).run(req.user.id, investisseur_id, nom, prefix, hash); + INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all) + VALUES (?, ?, ?, ?, ?, 'read', ?) + `).run(req.user.id, investisseur_id, nom, prefix, hash, scope_all ? 1 : 0); const saved = db.prepare(` - SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, + SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all, i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id WHERE k.id = ? @@ -74,7 +82,7 @@ router.patch('/:id', (req, res, next) => { db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id); const saved = db.prepare(` - SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, + SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id, k.scope_all, i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id WHERE k.id = ? diff --git a/backend/src/routes/v1/dashboard.js b/backend/src/routes/v1/dashboard.js index df144e1..349d2c5 100644 --- a/backend/src/routes/v1/dashboard.js +++ b/backend/src/routes/v1/dashboard.js @@ -27,9 +27,17 @@ const router = Router(); * 200: { description: Synthèse KPI } */ router.get('/', (req, res) => { - const invId = req.investisseurId; const annee = req.query.annee ? Number(req.query.annee) : null; + // Clé "Famille et entreprises" (scope_all) → agrège tous les investisseurs + // du foyer (req.userId) ; clé mono-investisseur → filtre sur req.investisseurId. + // Dans les deux cas un seul paramètre suffit : soit l'id investisseur, soit + // le user_id pour la sous-requête IN (…) — en miroir de ?scope=all côté JWT. + const invCond = (col) => req.investisseurScopeAll + ? `${col} IN (SELECT id FROM investisseurs WHERE user_id = ?)` + : `${col} = ?`; + const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId; + // ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital // en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours + // en_defaut). "capital_investi" et "capital_en_risque" sont des soldes @@ -51,11 +59,11 @@ router.get('/', (req, res) => { - COALESCE((SELECT SUM(rb.capital) FROM remboursements rb WHERE rb.investissement_id = i.id AND rb.type = 'normal'), 0) END), 0) AS capital_en_risque, COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse - FROM investissements i WHERE i.investisseur_id = ? - `).get(invId); + FROM investissements i WHERE ${invCond('i.investisseur_id')} + `).get(invParam); - const interetsConds = ['i.investisseur_id = ?']; - const interetsParams = [invId]; + const interetsConds = [invCond('i.investisseur_id')]; + const interetsParams = [invParam]; if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); } const interets = db.prepare(` @@ -73,8 +81,8 @@ router.get('/', (req, res) => { SELECT COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots, COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits - FROM depots_retraits WHERE investisseur_id = ? - `).get(invId); + FROM depots_retraits WHERE ${invCond('investisseur_id')} + `).get(invParam); res.json({ investissements, interets: { ...interets, annee: annee || null }, cash }); }); diff --git a/backend/src/routes/v1/depotsRetraits.js b/backend/src/routes/v1/depotsRetraits.js index 49098cf..95b12d8 100644 --- a/backend/src/routes/v1/depotsRetraits.js +++ b/backend/src/routes/v1/depotsRetraits.js @@ -14,14 +14,21 @@ const router = Router(); * 200: { description: Liste des mouvements } */ router.get('/', (req, res) => { + // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du + // foyer ; clé mono-investisseur → filtre sur req.investisseurId. + const invCond = req.investisseurScopeAll + ? 'dr.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'dr.investisseur_id = ?'; + const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId; + const rows = db.prepare(` SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type, dr.montant, dr.libelle FROM depots_retraits dr JOIN plateformes p ON p.id = dr.plateforme_id - WHERE dr.investisseur_id = ? + WHERE ${invCond} ORDER BY dr.date_operation DESC - `).all(req.investisseurId); + `).all(invParam); res.json(rows); }); diff --git a/backend/src/routes/v1/investissements.js b/backend/src/routes/v1/investissements.js index c8bd28d..bf190bf 100644 --- a/backend/src/routes/v1/investissements.js +++ b/backend/src/routes/v1/investissements.js @@ -27,8 +27,12 @@ const LIST_COLUMNS = ` */ router.get('/', (req, res) => { const { statut } = req.query; - const conds = ['i.investisseur_id = ?']; - const args = [req.investisseurId]; + // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du + // foyer ; clé mono-investisseur → filtre sur req.investisseurId. + const conds = [req.investisseurScopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?']; + const args = [req.investisseurScopeAll ? req.userId : req.investisseurId]; if (statut) { conds.push('i.statut = ?'); args.push(statut); } const rows = db.prepare(` @@ -59,12 +63,17 @@ router.get('/', (req, res) => { */ router.get('/:id', (req, res, next) => { try { + const invCond = req.investisseurScopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?'; + const invParam = req.investisseurScopeAll ? req.userId : req.investisseurId; + const inv = db.prepare(` SELECT ${LIST_COLUMNS}, i.notes FROM investissements i JOIN plateformes p ON p.id = i.plateforme_id - WHERE i.id = ? AND i.investisseur_id = ? - `).get(req.params.id, req.investisseurId); + WHERE i.id = ? AND ${invCond} + `).get(req.params.id, invParam); if (!inv) throw new HttpError(404, 'Investissement introuvable'); const remboursements = db.prepare(` diff --git a/backend/src/routes/v1/investisseur.js b/backend/src/routes/v1/investisseur.js index af01930..1f2f173 100644 --- a/backend/src/routes/v1/investisseur.js +++ b/backend/src/routes/v1/investisseur.js @@ -7,16 +7,30 @@ const router = Router(); * @openapi * /investisseur: * get: - * summary: Profil de l'investisseur lié à la clé API + * summary: Profil investisseur (ou liste, pour une clé "Famille et entreprises") + * description: > + * Avec une clé scopée à un seul investisseur, renvoie son profil (objet). + * Avec une clé "Famille et entreprises" (scope_all), renvoie la liste des + * investisseurs du foyer (tableau) — il n'y a plus un profil unique à + * renvoyer. * tags: [Investisseur] * security: [{ ApiKeyAuth: [] }] * responses: * 200: - * description: Profil investisseur + * description: Profil investisseur, ou liste de profils si scope_all * 401: * description: Clé API invalide ou manquante */ router.get('/', (req, res) => { + if (req.investisseurScopeAll) { + const investisseurs = db.prepare(` + SELECT id, nom, prenom, type, type_fiscal, notes, created_at + FROM investisseurs WHERE user_id = ? + ORDER BY is_principal DESC, id ASC + `).all(req.userId); + return res.json(investisseurs); + } + const inv = db.prepare(` SELECT id, nom, prenom, type, type_fiscal, notes, created_at FROM investisseurs WHERE id = ? diff --git a/backend/src/routes/v1/remboursements.js b/backend/src/routes/v1/remboursements.js index a71bf01..75cb057 100644 --- a/backend/src/routes/v1/remboursements.js +++ b/backend/src/routes/v1/remboursements.js @@ -22,8 +22,12 @@ const router = Router(); */ router.get('/', (req, res) => { const { date_debut, date_fin } = req.query; - const conds = ['i.investisseur_id = ?']; - const args = [req.investisseurId]; + // Clé "Famille et entreprises" (scope_all) → tous les investisseurs du + // foyer ; clé mono-investisseur → filtre sur req.investisseurId. + const conds = [req.investisseurScopeAll + ? 'i.investisseur_id IN (SELECT id FROM investisseurs WHERE user_id = ?)' + : 'i.investisseur_id = ?']; + const args = [req.investisseurScopeAll ? req.userId : req.investisseurId]; if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); } if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); } diff --git a/frontend/src/pages/Aide.jsx b/frontend/src/pages/Aide.jsx index 65a52fe..38920a4 100644 --- a/frontend/src/pages/Aide.jsx +++ b/frontend/src/pages/Aide.jsx @@ -247,7 +247,7 @@ export default function Aide() {
crowdlending_get_investisseur
- La clé donne un accès en lecture seule aux données de cet investisseur. Elle ne sera affichée en clair qu'une seule fois. + {isScopeAll + ? "La clé donnera un accès en lecture seule agrégé à tous les membres du foyer (comme la vue « Famille et entreprises » de l'app). Elle ne sera affichée en clair qu'une seule fois." + : "La clé donne un accès en lecture seule aux données de cet investisseur. Elle ne sera affichée en clair qu'une seule fois."}
- Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir dev et
- prod accessibles simultanément, répétez ces étapes une deuxième fois avec l'autre URL (et une clé
- API distincte) — la clé de config ({serverKey} ci-dessous) s'ajuste automatiquement,
- Claude Desktop ne confondra jamais les deux portefeuilles.
-
+ Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir aussi votre
+ portefeuille en production accessible depuis Claude Desktop, répétez ces étapes une deuxième fois
+ avec l'URL de production (et une clé API distincte) — la clé de config
+ ({serverKey} ci-dessous) s'ajuste automatiquement, Claude Desktop ne confondra jamais
+ les deux connexions.
+
@@ -1295,10 +1323,27 @@ function McpServerSection({ goToApiKeys }) { style={{ width: '100%', maxWidth: 520, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 auto 16px' }} onError={(e) => { e.currentTarget.style.display = 'none'; }} /> - +
+ Recommandé en cas de problème — voir Dépannage ci-dessous. +
++ Coché par défaut : nécessaire si un antivirus ou un proxy intercepte le HTTPS — voir Dépannage + ci-dessous. Décochez uniquement si vous savez que ce n'est pas votre cas. +
+
- Dans la liste des outils MCP de Claude Desktop, les outils crowdlending_* doivent
- apparaître (6 en production, 7 en développement local si crowdlending_fetch_url est
- activé — voir mcp-server/README.md). Testez avec une question du type « Quel est mon
- encours de crowdlending actuellement ? ».
+
+ Demandez à Claude : « Peux-tu me lister les outils auxquels
+ tu as accès ? » — c'est la façon la plus fiable de vérifier que la connexion fonctionne
+ vraiment (plutôt qu'une question sur vos données, qui peut échouer pour d'autres raisons même si la
+ connexion est bonne). Les outils crowdlending_* doivent apparaître dans sa réponse
+ {isLocal
+ ? <> (6, ou 7 si crowdlending_fetch_url est activé — voir mcp-server/README.md)>
+ : ' (6 au total)'}.
+ Une fois la connexion confirmée, consultez la FAQ « Comment utiliser le serveur MCP » pour des + exemples de questions et le détail de chaque fonction. +
+Vérifier côté serveur
-
- La console du serveur (le terminal où tourne npm run dev) affiche désormais une ligne
- par requête reçue — session, outil appelé, statut, durée. Si rien n'y apparaît alors qu'un appel a
- été fait depuis Claude Desktop, la requête n'arrive jamais jusqu'ici : le problème est côté
- npx/mcp-remote (voir ci-dessus), pas dans server.js.
-
+ Connexion refusée avec une erreur de certificat, alors que le serveur répond bien +
+
+ Symptôme différent du précédent : la config est correcte, mais mcp-remote échoue à
+ joindre l'URL de production elle-même (pas le registre npm cette fois) avec une erreur
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE dans les logs. Même cause de fond — un antivirus ou
+ un proxy avec inspection HTTPS (Avast, Kaspersky, ESET…) re-signe le trafic avec son propre
+ certificat, que Node.js ne reconnaît pas (contrairement à votre navigateur, qui lui fait
+ confiance via le magasin Windows). Solution : cochez la case NODE_OPTIONS=--use-system-ca
+ ci-dessus (étape 3) — Node utilisera alors le magasin de certificats Windows plutôt que le sien.
+ Nécessite Node.js 22.16 ou plus récent (vérifiable avec node -v) ; sur une version
+ plus ancienne, il faut exporter le certificat racine de l'antivirus (certmgr.msc →
+ Autorités de certification racines de confiance → Exporter en Base-64 X.509) et le référencer via
+ NODE_EXTRA_CA_CERTS à la place. Évitez de désactiver complètement la vérification
+ TLS (NODE_TLS_REJECT_UNAUTHORIZED=0) : cette connexion sort sur internet avec votre
+ clé API et vos données, contrairement au développement local en localhost.
+
Vérifier côté serveur
+
+ La console du serveur (le terminal où tourne npm run dev) affiche désormais une ligne
+ par requête reçue — session, outil appelé, statut, durée. Si rien n'y apparaît alors qu'un appel a
+ été fait depuis Claude Desktop, la requête n'arrive jamais jusqu'ici : le problème est côté
+ npx/mcp-remote (voir ci-dessus), pas dans server.js.
+