Possibilité d'avoir une clé API Globale par famille
This commit is contained in:
@@ -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_inv ON api_keys(investisseur_id)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
|
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;
|
export default db;
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import db from '../db/index.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentification par clé API (X-API-Key), distincte du JWT utilisé par le
|
* Authentification par clé API (X-API-Key), distincte du JWT utilisé par le
|
||||||
* frontend (requireAuth). Une clé API est toujours scopée à un seul
|
* frontend (requireAuth). Une clé API est scopée soit à un seul investisseur,
|
||||||
* investisseur — pas de notion de "scope=all" ici, contrairement aux routes
|
* soit à "Famille et entreprises" (scope_all=1, réservé au profil principal
|
||||||
* internes. Réservé aux routes /api/v1 (lecture seule, Phase 1).
|
* — 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) {
|
export function requireApiKey(req, res, next) {
|
||||||
const key = req.header('X-API-Key');
|
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 hash = crypto.createHash('sha256').update(key).digest('hex');
|
||||||
const row = db.prepare(`
|
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
|
FROM api_keys k
|
||||||
WHERE k.key_hash = ?
|
WHERE k.key_hash = ?
|
||||||
`).get(hash);
|
`).get(hash);
|
||||||
@@ -27,7 +34,9 @@ export function requireApiKey(req, res, next) {
|
|||||||
db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id);
|
db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id);
|
||||||
|
|
||||||
req.apiKeyId = row.id;
|
req.apiKeyId = row.id;
|
||||||
req.investisseurId = row.investisseur_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());
|
req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim());
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function generateKey() {
|
|||||||
/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
|
/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const rows = db.prepare(`
|
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
|
i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
|
||||||
FROM api_keys k
|
FROM api_keys k
|
||||||
JOIN investisseurs i ON i.id = k.investisseur_id
|
JOIN investisseurs i ON i.id = k.investisseur_id
|
||||||
@@ -27,29 +27,37 @@ router.get('/', (req, res) => {
|
|||||||
res.json(rows);
|
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) => {
|
router.post('/', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const nom = (req.body?.nom || '').trim();
|
const nom = (req.body?.nom || '').trim();
|
||||||
const investisseur_id = Number(req.body?.investisseur_id);
|
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) 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 (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');
|
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);
|
.get(investisseur_id, req.user.id);
|
||||||
if (!inv) throw new HttpError(404, 'Investisseur introuvable');
|
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 { full, hash, prefix } = generateKey();
|
||||||
|
|
||||||
const info = db.prepare(`
|
const info = db.prepare(`
|
||||||
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes)
|
INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes, scope_all)
|
||||||
VALUES (?, ?, ?, ?, ?, 'read')
|
VALUES (?, ?, ?, ?, ?, 'read', ?)
|
||||||
`).run(req.user.id, investisseur_id, nom, prefix, hash);
|
`).run(req.user.id, investisseur_id, nom, prefix, hash, scope_all ? 1 : 0);
|
||||||
|
|
||||||
const saved = db.prepare(`
|
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
|
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
|
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
|
||||||
WHERE k.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);
|
db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id);
|
||||||
|
|
||||||
const saved = db.prepare(`
|
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
|
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
|
FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
|
||||||
WHERE k.id = ?
|
WHERE k.id = ?
|
||||||
|
|||||||
@@ -27,9 +27,17 @@ const router = Router();
|
|||||||
* 200: { description: Synthèse KPI }
|
* 200: { description: Synthèse KPI }
|
||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const invId = req.investisseurId;
|
|
||||||
const annee = req.query.annee ? Number(req.query.annee) : null;
|
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
|
// ── Investissements : mêmes formules que le KPI "Capital investi" / "Capital
|
||||||
// en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours +
|
// en risque" de l'app interne (Dashboard.jsx → capitalDeploye = encours +
|
||||||
// en_defaut). "capital_investi" et "capital_en_risque" sont des soldes
|
// 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)
|
- 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,
|
END), 0) AS capital_en_risque,
|
||||||
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse
|
COALESCE(SUM(CASE WHEN i.statut='rembourse' THEN i.montant_investi END), 0) AS rembourse
|
||||||
FROM investissements i WHERE i.investisseur_id = ?
|
FROM investissements i WHERE ${invCond('i.investisseur_id')}
|
||||||
`).get(invId);
|
`).get(invParam);
|
||||||
|
|
||||||
const interetsConds = ['i.investisseur_id = ?'];
|
const interetsConds = [invCond('i.investisseur_id')];
|
||||||
const interetsParams = [invId];
|
const interetsParams = [invParam];
|
||||||
if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); }
|
if (annee) { interetsConds.push(`strftime('%Y', r.date_remb) = ?`); interetsParams.push(String(annee)); }
|
||||||
|
|
||||||
const interets = db.prepare(`
|
const interets = db.prepare(`
|
||||||
@@ -73,8 +81,8 @@ router.get('/', (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots,
|
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
|
COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits
|
||||||
FROM depots_retraits WHERE investisseur_id = ?
|
FROM depots_retraits WHERE ${invCond('investisseur_id')}
|
||||||
`).get(invId);
|
`).get(invParam);
|
||||||
|
|
||||||
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
|
res.json({ investissements, interets: { ...interets, annee: annee || null }, cash });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,14 +14,21 @@ const router = Router();
|
|||||||
* 200: { description: Liste des mouvements }
|
* 200: { description: Liste des mouvements }
|
||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
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(`
|
const rows = db.prepare(`
|
||||||
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
|
SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
|
||||||
dr.montant, dr.libelle
|
dr.montant, dr.libelle
|
||||||
FROM depots_retraits dr
|
FROM depots_retraits dr
|
||||||
JOIN plateformes p ON p.id = dr.plateforme_id
|
JOIN plateformes p ON p.id = dr.plateforme_id
|
||||||
WHERE dr.investisseur_id = ?
|
WHERE ${invCond}
|
||||||
ORDER BY dr.date_operation DESC
|
ORDER BY dr.date_operation DESC
|
||||||
`).all(req.investisseurId);
|
`).all(invParam);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ const LIST_COLUMNS = `
|
|||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const { statut } = req.query;
|
const { statut } = req.query;
|
||||||
const conds = ['i.investisseur_id = ?'];
|
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
||||||
const args = [req.investisseurId];
|
// 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); }
|
if (statut) { conds.push('i.statut = ?'); args.push(statut); }
|
||||||
|
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
@@ -59,12 +63,17 @@ router.get('/', (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/:id', (req, res, next) => {
|
router.get('/:id', (req, res, next) => {
|
||||||
try {
|
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(`
|
const inv = db.prepare(`
|
||||||
SELECT ${LIST_COLUMNS}, i.notes
|
SELECT ${LIST_COLUMNS}, i.notes
|
||||||
FROM investissements i
|
FROM investissements i
|
||||||
JOIN plateformes p ON p.id = i.plateforme_id
|
JOIN plateformes p ON p.id = i.plateforme_id
|
||||||
WHERE i.id = ? AND i.investisseur_id = ?
|
WHERE i.id = ? AND ${invCond}
|
||||||
`).get(req.params.id, req.investisseurId);
|
`).get(req.params.id, invParam);
|
||||||
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
if (!inv) throw new HttpError(404, 'Investissement introuvable');
|
||||||
|
|
||||||
const remboursements = db.prepare(`
|
const remboursements = db.prepare(`
|
||||||
|
|||||||
@@ -7,16 +7,30 @@ const router = Router();
|
|||||||
* @openapi
|
* @openapi
|
||||||
* /investisseur:
|
* /investisseur:
|
||||||
* get:
|
* 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]
|
* tags: [Investisseur]
|
||||||
* security: [{ ApiKeyAuth: [] }]
|
* security: [{ ApiKeyAuth: [] }]
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Profil investisseur
|
* description: Profil investisseur, ou liste de profils si scope_all
|
||||||
* 401:
|
* 401:
|
||||||
* description: Clé API invalide ou manquante
|
* description: Clé API invalide ou manquante
|
||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
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(`
|
const inv = db.prepare(`
|
||||||
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
|
SELECT id, nom, prenom, type, type_fiscal, notes, created_at
|
||||||
FROM investisseurs WHERE id = ?
|
FROM investisseurs WHERE id = ?
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ const router = Router();
|
|||||||
*/
|
*/
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
const { date_debut, date_fin } = req.query;
|
const { date_debut, date_fin } = req.query;
|
||||||
const conds = ['i.investisseur_id = ?'];
|
// Clé "Famille et entreprises" (scope_all) → tous les investisseurs du
|
||||||
const args = [req.investisseurId];
|
// 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_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
|
||||||
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
|
if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
|
||||||
|
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export default function Aide() {
|
|||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investisseur</code>
|
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investisseur</code>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil de l'investisseur lié à la clé API (nom, type famille/entreprise, régime fiscal).</td>
|
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil de l'investisseur lié à la clé API (nom, type famille/entreprise, régime fiscal). Avec une clé « Famille et entreprises », renvoie la liste des profils du foyer.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
|
||||||
|
|||||||
@@ -805,6 +805,12 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [err, setErr] = useState(null);
|
const [err, setErr] = useState(null);
|
||||||
|
|
||||||
|
// La clé "Famille et entreprises" (scope_all) n'est autorisée que depuis le
|
||||||
|
// profil principal — cohérent avec l'enforcement côté serveur (routes/apiKeys.js).
|
||||||
|
// Si aucun principal n'est trouvé (ne devrait pas arriver), on masque
|
||||||
|
// simplement l'option plutôt que de proposer un choix qui échouera au submit.
|
||||||
|
const principal = investisseurs.find(i => i.is_principal);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setNom('');
|
setNom('');
|
||||||
@@ -813,6 +819,8 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
|
|||||||
}
|
}
|
||||||
}, [open, investisseurs]);
|
}, [open, investisseurs]);
|
||||||
|
|
||||||
|
const isScopeAll = investisseurId === 'all';
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!nom.trim()) return setErr('Le nom de la clé est requis');
|
if (!nom.trim()) return setErr('Le nom de la clé est requis');
|
||||||
@@ -820,7 +828,10 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const created = await api.post('/api-keys', { nom: nom.trim(), investisseur_id: Number(investisseurId) });
|
const payload = isScopeAll
|
||||||
|
? { nom: nom.trim(), investisseur_id: principal.id, scope_all: true }
|
||||||
|
: { nom: nom.trim(), investisseur_id: Number(investisseurId) };
|
||||||
|
const created = await api.post('/api-keys', payload);
|
||||||
onCreated(created);
|
onCreated(created);
|
||||||
} catch (e) { setErr(e.message); }
|
} catch (e) { setErr(e.message); }
|
||||||
finally { setBusy(false); }
|
finally { setBusy(false); }
|
||||||
@@ -841,10 +852,13 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
|
|||||||
{investisseurs.map(i => (
|
{investisseurs.map(i => (
|
||||||
<option key={i.id} value={i.id}>{memberLabel(i)}</option>
|
<option key={i.id} value={i.id}>{memberLabel(i)}</option>
|
||||||
))}
|
))}
|
||||||
|
{principal && <option value="all">Famille et entreprises</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted" style={{ margin: 0, fontSize: 'var(--fs-sm)' }}>
|
<p className="text-muted" style={{ margin: 0, fontSize: 'var(--fs-sm)' }}>
|
||||||
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."}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
|
||||||
<button type="button" className="ghost" onClick={onClose}>Annuler</button>
|
<button type="button" className="ghost" onClick={onClose}>Annuler</button>
|
||||||
@@ -1043,7 +1057,7 @@ function ApiKeysSection() {
|
|||||||
{keys.map(k => (
|
{keys.map(k => (
|
||||||
<tr key={k.id}>
|
<tr key={k.id}>
|
||||||
<td>{k.nom}</td>
|
<td>{k.nom}</td>
|
||||||
<td>{k.investisseur_nom}</td>
|
<td>{k.scope_all ? 'Famille et entreprises' : k.investisseur_nom}</td>
|
||||||
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{k.key_prefix}…</td>
|
<td style={{ fontFamily: 'monospace', fontSize: 12 }}>{k.key_prefix}…</td>
|
||||||
<td>{fmtKeyDate(k.created_at)}</td>
|
<td>{fmtKeyDate(k.created_at)}</td>
|
||||||
<td>{fmtKeyDate(k.last_used_at)}</td>
|
<td>{fmtKeyDate(k.last_used_at)}</td>
|
||||||
@@ -1149,10 +1163,12 @@ function CopyBlock({ text }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function McpServerSection({ goToApiKeys }) {
|
function McpServerSection({ goToApiKeys }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
|
const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel
|
const [manualLabel, setManualLabel] = useState(null); // null = auto-détecté depuis mcpUrl, sinon override manuel
|
||||||
const [debugFlag, setDebugFlag] = useState(false); // ajoute --debug : génère un fichier mcp-server-<nom>.log dédié (voir Dépannage)
|
const [debugFlag, setDebugFlag] = useState(false); // ajoute --debug : génère un fichier mcp-server-<nom>.log dédié (voir Dépannage)
|
||||||
|
const [systemCaFlag, setSystemCaFlag] = useState(true); // ajoute NODE_OPTIONS=--use-system-ca : contourne un antivirus/proxy qui intercepte le HTTPS (voir Dépannage) — coché par défaut, ne s'applique qu'en prod (voir envBlock)
|
||||||
|
|
||||||
const detectedLabel = detectLabelFromUrl(mcpUrl);
|
const detectedLabel = detectLabelFromUrl(mcpUrl);
|
||||||
const label = manualLabel ?? detectedLabel;
|
const label = manualLabel ?? detectedLabel;
|
||||||
@@ -1177,11 +1193,20 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
// macOS/Linux, qui n'ont pas ce problème (npx s'exécute nativement).
|
// macOS/Linux, qui n'ont pas ce problème (npx s'exécute nativement).
|
||||||
const isWindows = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '');
|
const isWindows = typeof navigator !== 'undefined' && /win/i.test(navigator.platform || navigator.userAgent || '');
|
||||||
|
|
||||||
|
const envBlock = {
|
||||||
|
[envVarName]: apiKey || '<VOTRE_CLE_API>',
|
||||||
|
// Uniquement pertinent pour une URL distante (prod) : force Node à utiliser
|
||||||
|
// le magasin de certificats du système plutôt que le sien, pour accepter
|
||||||
|
// les connexions re-signées par un antivirus/proxy à inspection HTTPS
|
||||||
|
// (Avast, Kaspersky, proxy d'entreprise…) — voir Dépannage.
|
||||||
|
...(!isLocal && systemCaFlag ? { NODE_OPTIONS: '--use-system-ca' } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
const configJson = JSON.stringify({
|
const configJson = JSON.stringify({
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
[serverKey]: isWindows
|
[serverKey]: isWindows
|
||||||
? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' } }
|
? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: envBlock }
|
||||||
: { command: 'npx', args: mcpRemoteArgs, env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' } },
|
: { command: 'npx', args: mcpRemoteArgs, env: envBlock },
|
||||||
},
|
},
|
||||||
}, null, 2);
|
}, null, 2);
|
||||||
|
|
||||||
@@ -1261,12 +1286,15 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
action nécessaire au-delà de renseigner l'URL et votre clé API.
|
action nécessaire au-delà de renseigner l'URL et votre clé API.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isLocal && (
|
||||||
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir dev et
|
Claude Desktop peut se connecter à plusieurs serveurs MCP en même temps : pour avoir aussi votre
|
||||||
prod accessibles simultanément, répétez ces étapes une deuxième fois avec l'autre URL (et une clé
|
portefeuille en production accessible depuis Claude Desktop, répétez ces étapes une deuxième fois
|
||||||
API distincte) — la clé de config (<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement,
|
avec l'URL de production (et une clé API distincte) — la clé de config
|
||||||
Claude Desktop ne confondra jamais les deux portefeuilles.
|
(<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement, Claude Desktop ne confondra jamais
|
||||||
|
les deux connexions.
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
|
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>3. Copier la configuration</h4>
|
||||||
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
@@ -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' }}
|
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'; }}
|
onError={(e) => { e.currentTarget.style.display = 'none'; }}
|
||||||
/>
|
/>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '0 0 10px', fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
|
<div style={{ margin: '0 0 10px' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
|
||||||
<input type="checkbox" checked={debugFlag} onChange={e => setDebugFlag(e.target.checked)} style={{ width: 'auto' }} />
|
<input type="checkbox" checked={debugFlag} onChange={e => setDebugFlag(e.target.checked)} style={{ width: 'auto' }} />
|
||||||
Ajouter <code>--debug</code> (recommandé en cas de problème — voir Dépannage ci-dessous)
|
Ajouter <code>--debug</code>
|
||||||
</label>
|
</label>
|
||||||
|
<p style={{ margin: '2px 0 0 22px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
|
Recommandé en cas de problème — voir Dépannage ci-dessous.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isLocal && (
|
||||||
|
<div style={{ margin: '0 0 10px' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontWeight: 400, fontSize: 'var(--fs-sm)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={systemCaFlag} onChange={e => setSystemCaFlag(e.target.checked)} style={{ width: 'auto' }} />
|
||||||
|
Ajouter <code>NODE_OPTIONS=--use-system-ca</code>
|
||||||
|
</label>
|
||||||
|
<p style={{ margin: '2px 0 0 22px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CopyBlock text={configJson} />
|
<CopyBlock text={configJson} />
|
||||||
|
|
||||||
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
|
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
|
||||||
@@ -1307,12 +1352,22 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
|
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
|
||||||
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
Dans la liste des outils MCP de Claude Desktop, les outils <code>crowdlending_*</code> doivent
|
Demandez à Claude : « <strong style={{ color: 'var(--text)' }}>Peux-tu me lister les outils auxquels
|
||||||
apparaître (6 en production, 7 en développement local si <code>crowdlending_fetch_url</code> est
|
tu as accès ?</strong> » — c'est la façon la plus fiable de vérifier que la connexion fonctionne
|
||||||
activé — voir <code>mcp-server/README.md</code>). Testez avec une question du type « Quel est mon
|
vraiment (plutôt qu'une question sur vos données, qui peut échouer pour d'autres raisons même si la
|
||||||
encours de crowdlending actuellement ? ».
|
connexion est bonne). Les outils <code>crowdlending_*</code> doivent apparaître dans sa réponse
|
||||||
|
{isLocal
|
||||||
|
? <> (6, ou 7 si <code>crowdlending_fetch_url</code> est activé — voir <code>mcp-server/README.md</code>)</>
|
||||||
|
: ' (6 au total)'}.
|
||||||
</p>
|
</p>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="ghost" onClick={() => navigate('/aide?section=faq')} style={{ marginTop: -12, marginBottom: 20 }}>
|
||||||
|
Voir la FAQ →
|
||||||
|
</button>
|
||||||
|
|
||||||
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>6. Dépannage</h4>
|
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>6. Dépannage</h4>
|
||||||
|
|
||||||
@@ -1341,6 +1396,31 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
l'étape 3 (essayez l'autre si l'un des deux est vide ou ne se met pas à jour).
|
l'étape 3 (essayez l'autre si l'un des deux est vide ou ne se met pas à jour).
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{!isLocal && (
|
||||||
|
<>
|
||||||
|
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
|
||||||
|
Connexion refusée avec une erreur de certificat, alors que le serveur répond bien
|
||||||
|
</p>
|
||||||
|
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
|
Symptôme différent du précédent : la config est correcte, mais <code>mcp-remote</code> échoue à
|
||||||
|
joindre l'URL de production elle-même (pas le registre npm cette fois) avec une erreur
|
||||||
|
<code> UNABLE_TO_VERIFY_LEAF_SIGNATURE</code> 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 <code>NODE_OPTIONS=--use-system-ca</code>
|
||||||
|
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 <code>node -v</code>) ; sur une version
|
||||||
|
plus ancienne, il faut exporter le certificat racine de l'antivirus (<code>certmgr.msc</code> →
|
||||||
|
Autorités de certification racines de confiance → Exporter en Base-64 X.509) et le référencer via
|
||||||
|
<code> NODE_EXTRA_CA_CERTS</code> à la place. Évitez de désactiver complètement la vérification
|
||||||
|
TLS (<code>NODE_TLS_REJECT_UNAUTHORIZED=0</code>) : cette connexion sort sur internet avec votre
|
||||||
|
clé API et vos données, contrairement au développement local en <code>localhost</code>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLocal && (
|
||||||
|
<>
|
||||||
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Vérifier côté serveur</p>
|
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>Vérifier côté serveur</p>
|
||||||
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
|
||||||
La console du serveur (le terminal où tourne <code>npm run dev</code>) affiche désormais une ligne
|
La console du serveur (le terminal où tourne <code>npm run dev</code>) affiche désormais une ligne
|
||||||
@@ -1348,6 +1428,8 @@ function McpServerSection({ goToApiKeys }) {
|
|||||||
été fait depuis Claude Desktop, la requête n'arrive jamais jusqu'ici : le problème est côté
|
été fait depuis Claude Desktop, la requête n'arrive jamais jusqu'ici : le problème est côté
|
||||||
<code>npx</code>/<code>mcp-remote</code> (voir ci-dessus), pas dans <code>server.js</code>.
|
<code>npx</code>/<code>mcp-remote</code> (voir ci-dessus), pas dans <code>server.js</code>.
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ Tous en lecture seule (`readOnlyHint: true`) :
|
|||||||
|
|
||||||
| Outil | Description |
|
| Outil | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `crowdlending_get_investisseur` | Profil de l'investisseur lié à la clé |
|
| `crowdlending_get_investisseur` | Profil de l'investisseur lié à la clé (liste des membres si clé « Famille et entreprises ») |
|
||||||
| `crowdlending_get_dashboard` | Synthèse KPI : capital investi, capital en risque, intérêts perçus (filtrable par année), cash |
|
| `crowdlending_get_dashboard` | Synthèse KPI : capital investi, capital en risque, intérêts perçus (filtrable par année), cash |
|
||||||
| `crowdlending_list_investissements` | Liste des investissements, filtrable par statut |
|
| `crowdlending_list_investissements` | Liste des investissements, filtrable par statut |
|
||||||
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
|
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
|
||||||
|
|||||||
+4
-1
@@ -51,7 +51,10 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
|
|||||||
description: withSource(
|
description: withSource(
|
||||||
"Retourne le profil de l'investisseur associé à la clé API utilisée " +
|
"Retourne le profil de l'investisseur associé à la clé API utilisée " +
|
||||||
"(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " +
|
"(nom, type famille/entreprise, régime fiscal). Utile pour savoir sur " +
|
||||||
"quel portefeuille portent les autres outils."),
|
"quel portefeuille portent les autres outils. Avec une clé « Famille et " +
|
||||||
|
"entreprises » (scope agrégé), retourne une liste de profils (un par " +
|
||||||
|
"membre du foyer) au lieu d'un profil unique — dans ce cas, les autres " +
|
||||||
|
"outils portent sur l'ensemble des membres, pas un seul."),
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
annotations: READ_ONLY_ANNOTATIONS,
|
annotations: READ_ONLY_ANNOTATIONS,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user