Compare commits

..

3 Commits

Author SHA1 Message Date
ocroguennec 41c464e6a3 Améliorations diverses 2026-07-20 22:12:49 +02:00
ocroguennec 441da1975a Possibilité d'avoir une clé API Globale par famille 2026-07-20 18:47:58 +02:00
ocroguennec 9eb19efd92 MAj MCP Serveur 2026-07-20 17:30:08 +02:00
34 changed files with 1353 additions and 90 deletions
+22
View File
@@ -1960,6 +1960,10 @@ 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');
}
if (!cols.includes('mcp_url')) {
db.exec("ALTER TABLE smtp_config ADD COLUMN mcp_url TEXT NOT NULL DEFAULT ''");
console.log('[DB] Colonne smtp_config.mcp_url ajoutée');
}
// ── Migration : email sur investisseurs ───────────────────────────────
const invColsEmail = db.prepare('PRAGMA table_info(investisseurs)').all().map(c => c.name);
@@ -2279,4 +2283,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;
+16 -7
View File
@@ -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();
}
+16 -8
View File
@@ -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 = ?
+6 -2
View File
@@ -29,10 +29,11 @@ function ensureRow() {
router.get('/', (_req, res, next) => {
try {
ensureRow();
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
res.json({
appName: row.app_name || 'Crowdlending Tracker',
appUrl: row.app_url || '',
mcpUrl: row.mcp_url || '',
allowRegistration: row.allow_registration !== 0,
minPasswordLength: row.min_password_length || 8,
});
@@ -42,6 +43,7 @@ router.get('/', (_req, res, next) => {
const PatchSchema = z.object({
appName: z.string().min(1).max(100).optional(),
appUrl: z.string().max(500).optional(),
mcpUrl: z.string().max(500).optional(),
allowRegistration: z.boolean().optional(),
minPasswordLength: z.number().int().min(6).max(64).optional(),
});
@@ -50,18 +52,20 @@ router.patch('/', (req, res, next) => {
try {
ensureRow();
const body = PatchSchema.parse(req.body);
const row = db.prepare('SELECT app_name, app_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT app_name, app_url, mcp_url, allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
db.prepare(`
UPDATE smtp_config SET
app_name = ?,
app_url = ?,
mcp_url = ?,
allow_registration = ?,
min_password_length = ?
WHERE id = 1
`).run(
body.appName !== undefined ? body.appName : (row.app_name || 'Crowdlending Tracker'),
body.appUrl !== undefined ? body.appUrl : (row.app_url || ''),
body.mcpUrl !== undefined ? body.mcpUrl : (row.mcp_url || ''),
body.allowRegistration !== undefined ? (body.allowRegistration ? 1 : 0) : (row.allow_registration !== 0 ? 1 : 0),
body.minPasswordLength !== undefined ? body.minPasswordLength : (row.min_password_length || 8),
);
+15 -7
View File
@@ -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 });
});
+9 -2
View File
@@ -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);
});
+13 -4
View File
@@ -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(`
+16 -2
View File
@@ -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 = ?
+6 -2
View File
@@ -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); }
+3 -2
View File
@@ -94,15 +94,16 @@ app.get('/api/app-info', (_, res) => {
try {
const cfg = getSmtpConfig();
const icon = db.prepare(`SELECT filename FROM app_icons WHERE name = 'logo-app' LIMIT 1`).get();
const row = db.prepare('SELECT allow_registration, min_password_length FROM smtp_config WHERE id = 1').get();
const row = db.prepare('SELECT allow_registration, min_password_length, mcp_url FROM smtp_config WHERE id = 1').get();
res.json({
appName: cfg.appName || 'Crowdlending Tracker',
iconUrl: icon ? `/api/icons-files/${icon.filename}` : null,
allowRegistration: row ? row.allow_registration !== 0 : true,
minPasswordLength: row ? (row.min_password_length || 8) : 8,
mcpUrl: row ? (row.mcp_url || '') : '',
});
} catch {
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8 });
res.json({ appName: 'Crowdlending Tracker', iconUrl: null, allowRegistration: true, minPasswordLength: 8, mcpUrl: '' });
}
});
+241
View File
@@ -8,6 +8,7 @@
"name": "crowdlending-frontend",
"version": "0.1.0",
"dependencies": {
"html2pdf.js": "^0.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
@@ -252,6 +253,15 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -1159,6 +1169,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
"license": "MIT"
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1189,6 +1219,15 @@
"node": ">=0.8"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
@@ -1257,6 +1296,26 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
@@ -1286,6 +1345,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
@@ -1298,6 +1369,15 @@
"node": ">=0.8"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1316,6 +1396,15 @@
}
}
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.349",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
@@ -1372,6 +1461,23 @@
"node": ">=6"
}
},
"node_modules/fast-png": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
"license": "MIT",
"dependencies": {
"@types/pako": "^2.0.3",
"iobuffer": "^5.3.2",
"pako": "^2.1.0"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
@@ -1406,6 +1512,36 @@
"node": ">=6.9.0"
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/html2pdf.js": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/html2pdf.js/-/html2pdf.js-0.14.0.tgz",
"integrity": "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ==",
"license": "MIT",
"dependencies": {
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0",
"jspdf": "^4.0.0"
}
},
"node_modules/iobuffer": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1438,6 +1574,23 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz",
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.6",
"fast-png": "^6.2.0",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.11",
"core-js": "^3.6.0",
"dompurify": "^3.3.1",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1493,6 +1646,29 @@
"dev": true,
"license": "MIT"
},
"node_modules/pako": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "(MIT AND Zlib)"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1529,6 +1705,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"license": "MIT",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1596,6 +1782,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.60.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
@@ -1682,6 +1885,35 @@
"node": ">=0.8"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -1713,6 +1945,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"html2pdf.js": "^0.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
+112
View File
@@ -0,0 +1,112 @@
# Power Query — exemples de connexion à l'API v1
Exemples de requêtes [Power Query](https://learn.microsoft.com/fr-fr/power-query/) (Excel)
pour interroger le portefeuille crowdlending depuis un classeur Excel, en s'appuyant sur
la même API v1 (lecture seule) que le [serveur MCP](../mcp-server/README.md).
Aucune écriture : ces requêtes ne font que lire des données, comme le serveur MCP.
## Prérequis
- Une clé API générée dans l'app : **Mon compte → Clés API → Nouvelle clé**
- Le backend accessible (en local `http://localhost:4000`, ou l'URL de votre instance en
production, ex. `https://crowdlending.croguennec.net`)
- Excel avec Power Query (Excel 365 / 2016+ sous Windows ou Mac — Power Query est intégré
nativement, rien à installer)
## Option 1 — Classeur prêt à l'emploi (`crowdlending-powerquery-exemple.xlsb`)
Le plus rapide : ouvrez `crowdlending-powerquery-exemple.xlsb` directement dans Excel.
> **Pourquoi un `.xlsb` et pas un `.xlsx` ?** Le format binaire d'Excel (`.xlsb`) est celui
> à partir duquel ce classeur a pu être construit et vérifié de façon fiable en dehors
> d'Excel. Il s'ouvre et se comporte exactement comme un `.xlsx` — Power Query, tableaux,
> actualisation, tout fonctionne à l'identique. Si vous préférez un `.xlsx`, ouvrez le
> fichier puis **Fichier → Enregistrer sous** et changez le format ; les requêtes suivent.
Le classeur contient :
| Requête | Rôle |
|---|---|
| `ApiBaseUrl` | URL de l'API à interroger (à modifier) |
| `ApiKey` | Votre clé API (à modifier — voir ci-dessous) |
| `fnApiGet` | Fonction utilitaire partagée (appel HTTP + parsing JSON), utilisée par toutes les requêtes de données |
| `Investisseur` | Profil investisseur (ou liste des membres si clé « Famille et entreprises ») |
| `Dashboard` | KPIs du portefeuille (capital investi, capital en risque, intérêts, cash), au format « Indicateur / Valeur » |
| `Investissements` | Liste des investissements |
| `Remboursements` | Historique des remboursements |
| `DepotsRetraits` | Historique des mouvements de cash |
| `fnDetailInvestissement` | Fonction avancée : détail + remboursements d'un investissement par id |
| `Test` | Table d'instructions (« À lire avant de commencer ») — pas une donnée métier |
Étapes :
1. Ouvrez le classeur. La feuille affiche par défaut un exemple mis en cache (pas encore
vos données) — c'est normal, Excel n'a pas encore appelé l'API.
2. **Données → Requêtes et connexions**. Repérez `ApiKey` dans le volet à droite.
3. Clic droit sur `ApiKey`**Modifier**. Dans l'éditeur, remplacez
`"clk_live_VOTRE_CLE_API"` par votre vraie clé (entre guillemets), puis
**Fermer et charger**.
4. Vérifiez `ApiBaseUrl` de la même façon : `http://localhost:4000/api/v1` en local, ou
`https://crowdlending.croguennec.net/api/v1` en production (adaptez à votre domaine).
5. **Données → Actualiser tout** (ou Ctrl+Alt+F5). La table « Test » se met à jour avec les
instructions, signe que la connexion fonctionne.
6. Pour chaque requête de données qui vous intéresse (`Investissements`, `Remboursements`…) :
clic droit dans le volet **Requêtes et connexions****Charger dans…** → choisissez
Tableau (ou Tableau croisé dynamique) et la feuille de destination.
## Option 2 — Coller les requêtes manuellement (`queries/*.pq`)
Utile si vous préférez tout construire vous-même dans un classeur existant, ou si une
requête du classeur ne s'affiche pas correctement chez vous.
Pour chaque fichier, dans l'ordre ci-dessous : **Données → Obtenir des données → À partir
d'autres sources → Requête vide**, renommez la requête (volet de droite, ou après double-clic
sur son nom) avec **exactement** le nom du fichier (sans `.pq`), puis **Accueil → Éditeur
avancé**, effacez le contenu par défaut, collez le contenu du fichier, **Terminé**.
Ordre à respecter (chaque requête réutilise les précédentes par leur nom) :
1. `ApiBaseUrl.pq` — modifiez l'URL avant de coller si besoin
2. `ApiKey.pq` — remplacez `VOTRE_CLE_API` par votre clé avant de coller
3. `fnApiGet.pq`
4. `Investisseur.pq`, `Dashboard.pq`, `Investissements.pq`, `Remboursements.pq`,
`DepotsRetraits.pq` — dans l'ordre que vous voulez
5. `fnDetailInvestissement.pq` (optionnel, usage avancé — voir plus bas)
Une fois `fnApiGet` créée, **Fermer et charger** chaque requête de données individuellement
(clic droit → Charger dans…) pour l'ajouter comme tableau.
## Filtres
Les requêtes `Dashboard`, `Investissements` et `Remboursements` acceptent des filtres côté
API (année, statut, période). Par défaut elles ramènent tout (`null`). Pour filtrer,
ouvrez la requête dans l'éditeur avancé et remplacez `null` par le paramètre indiqué en
commentaire en tête du fichier `.pq` correspondant, par exemple :
```
Source = fnApiGet("/investissements", [statut = "rembourse"])
```
Statuts possibles : `en_cours`, `rembourse`, `en_retard`, `procedure`, `cloture`.
## Usage avancé — détail d'un investissement par ligne
`fnDetailInvestissement` permet de ramener, pour chaque ligne de la table `Investissements`,
le détail complet (dont les remboursements) sans requête séparée : sur la table
`Investissements`, **Ajout de colonne → Colonne personnalisée**, formule
`= fnDetailInvestissement([id])`. Attention : ceci déclenche un appel API par ligne — à
réserver à un nombre raisonnable d'investissements (quelques dizaines).
## Dépannage
- **`Impossible de se connecter au service distant`** — le backend n'est pas démarré, ou
`ApiBaseUrl` est incorrecte (vérifiez le port et le suffixe `/api/v1`).
- **`Erreur API (401)` / `Clé API invalide ou révoquée`** — régénérez une clé dans
Mon compte → Clés API et remplacez la valeur de `ApiKey`.
- **Une requête du classeur n'apparaît pas dans le volet, ou affiche une erreur au premier
chargement** — repartez de l'Option 2 pour cette requête précise : créez une requête
vide portant son nom et collez le contenu du `.pq` correspondant.
- **Avertissement de sécurité / niveau de confidentialité au premier chargement** — normal
pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou
« Public » selon votre contexte, ce n'est pas spécifique à ce classeur.
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,3 @@
// URL de base de l'API v1. En local : http://localhost:4000/api/v1
// En production : https://votre-domaine/api/v1
"http://localhost:4000/api/v1"
@@ -0,0 +1,2 @@
// Clé API générée dans l'app : Mon compte > Clés API > Nouvelle clé
"clk_live_VOTRE_CLE_API"
@@ -0,0 +1,14 @@
let
// Pour filtrer sur une année, remplacez null par [annee = "2026"]
Source = fnApiGet("/dashboard", null),
VersLignes = (enregistrement as record, nomSection as text) =>
Table.AddColumn(Record.ToTable(enregistrement), "Section", each nomSection),
Combine = Table.Combine({
VersLignes(Source[investissements], "Investissements"),
VersLignes(Source[interets], "Interets"),
VersLignes(Source[cash], "Cash")
}),
Reordonne = Table.ReorderColumns(Combine, {"Section", "Name", "Value"}),
Resultat = Table.RenameColumns(Reordonne, {{"Name", "Indicateur"}, {"Value", "Valeur"}})
in
Resultat
@@ -0,0 +1,5 @@
let
Source = fnApiGet("/depots-retraits", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,7 @@
let
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
Source = fnApiGet("/investissements", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,6 @@
let
Source = fnApiGet("/investisseur", null),
// Clé "Famille et entreprises" -> liste ; clé mono-investisseur -> objet unique
Resultat = if Value.Is(Source, type list) then Table.FromRecords(Source) else Table.FromRecords({Source})
in
Resultat
@@ -0,0 +1,6 @@
let
// Pour filtrer par période, remplacez null par [date_debut = "2026-01-01", date_fin = "2026-12-31"]
Source = fnApiGet("/remboursements", null),
Resultat = Table.FromRecords(Source)
in
Resultat
@@ -0,0 +1,19 @@
(chemin as text, optional parametres as nullable record) as any =>
let
parametresBruts = if parametres = null then [] else parametres,
champsUtiles = List.Select(
Record.FieldNames(parametresBruts),
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
),
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
reponse = Web.Contents(
ApiBaseUrl,
[
RelativePath = chemin,
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
Query = parametresNettoyes
]
),
resultat = Json.Document(reponse)
in
resultat
@@ -0,0 +1,5 @@
// Astuce avancée : sur la table Investissements, colonne personnalisée
// "= fnDetailInvestissement([id])" pour ramener le détail + les remboursements
// de chaque prêt (Ajout de colonne > Colonne personnalisée).
(id as number) as record =>
fnApiGet("/investissements/" & Text.From(id), null)
+499 -12
View File
@@ -1,21 +1,107 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { withDevOverrides } from '../utils/devOverrides.js';
/** Nom de fichier à partir de la question (slug, sans accents). */
function slugify(text) {
return text
.toLowerCase()
.normalize('NFD').replace(new RegExp('[\\u0300-\\u036f]', 'g'), '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
/* ── Accordéon FAQ ───────────────────────────────────────────── */
function FaqItem({ question, children }) {
const [open, setOpen] = useState(false);
/* `openQuestion`/`setOpenQuestion` sont partagés par toutes les FaqItem d'une
même page (état remonté dans le composant parent) : une seule question
ouverte à la fois, ouvrir la suivante referme automatiquement les autres. */
function FaqItem({ question, children, openQuestion, setOpenQuestion }) {
const open = openQuestion === question;
const toggle = () => setOpenQuestion(open ? null : question);
const [pdfState, setPdfState] = useState('idle'); // idle | generating | error
const contentRef = useRef(null);
const handleDownloadPdf = async () => {
if (!contentRef.current || pdfState === 'generating') return;
setPdfState('generating');
// Insère le titre directement dans le bloc réel (contentRef), le temps de
// la capture uniquement, puis le retire — évite de le dupliquer en
// permanence à l'écran tout en réutilisant l'élément réellement rendu
// (un conteneur hors-écran séparé est capturé vide par html2canvas :
// sa zone de rendu ne suit pas un élément poussé loin hors du viewport).
let heading = null;
try {
const { default: html2pdf } = await import('html2pdf.js');
// Fond capturé = fond réel du bloc (var(--surface)) : reste cohérent
// que le thème actif soit clair ou sombre, plutôt qu'un blanc forcé
// qui casserait le contraste du texte en mode sombre.
const bgColor = getComputedStyle(contentRef.current).backgroundColor || '#ffffff';
heading = document.createElement('h3');
heading.textContent = question;
heading.style.margin = '0 0 12px';
heading.style.color = 'var(--text)';
heading.style.fontSize = '1.1rem';
contentRef.current.insertBefore(heading, contentRef.current.firstChild);
await html2pdf()
.set({
margin: 28,
filename: `faq-${slugify(question) || 'crowdlending'}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, backgroundColor: bgColor },
jsPDF: { unit: 'pt', format: 'a4', orientation: 'portrait' },
// 'avoid-all' : ne coupe jamais un élément (image, bloc de code,
// ligne de tableau…) au milieu — le pousse entièrement sur la page
// suivante à la place. C'est ce qui manquait avec le moteur HTML
// interne de jsPDF (doc.html()), qui tranchait au hasard.
pagebreak: { mode: ['avoid-all', 'css'] },
})
.from(contentRef.current)
.toPdf()
.get('pdf')
.then((pdf) => {
// Numérotation en bas à droite, une fois le nombre total de pages connu.
const total = pdf.internal.getNumberOfPages();
for (let i = 1; i <= total; i++) {
pdf.setPage(i);
pdf.setFontSize(9);
pdf.setTextColor(150);
pdf.text(
`${i} / ${total}`,
pdf.internal.pageSize.getWidth() - 28,
pdf.internal.pageSize.getHeight() - 16,
{ align: 'right' }
);
}
})
.save();
setPdfState('idle');
} catch (e) {
console.error('Échec de la génération du PDF :', e);
setPdfState('error');
} finally {
if (heading && heading.parentNode) heading.parentNode.removeChild(heading);
}
};
return (
<div style={{
borderBottom: '1px solid var(--border)',
padding: '0',
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 10,
boxShadow: 'var(--shadow)',
padding: '0 20px',
marginBottom: 12,
}}>
<button
onClick={() => setOpen(o => !o)}
onClick={toggle}
style={{
width: '100%', textAlign: 'left', background: 'none', border: 'none',
padding: '14px 0', cursor: 'pointer', display: 'flex',
alignItems: 'center', justifyContent: 'space-between', gap: 12,
color: 'var(--text)', fontSize: 'var(--fs-base)', fontWeight: 500,
color: 'var(--text)', fontSize: '1.05rem', fontWeight: 600,
}}
>
<span>{question}</span>
@@ -29,16 +115,81 @@ function FaqItem({ question, children }) {
</button>
{open && (
<div style={{
paddingBottom: 16, color: 'var(--text-muted)',
paddingBottom: 20, color: 'var(--text-muted)',
fontSize: 'var(--fs-sm)', lineHeight: 1.7,
}}>
{children}
<div ref={contentRef} style={{ background: 'var(--surface)' }}>
{children}
</div>
<button
onClick={handleDownloadPdf}
disabled={pdfState === 'generating'}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 16,
padding: '7px 14px', border: '1px solid var(--border)', borderRadius: 8,
background: 'var(--surface-2)', color: 'var(--text)',
fontSize: 'var(--fs-sm)', fontWeight: 500,
cursor: pdfState === 'generating' ? 'default' : 'pointer',
opacity: pdfState === 'generating' ? 0.6 : 1,
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{pdfState === 'generating' ? 'Génération du PDF…' : 'Télécharger cette FAQ en PDF'}
</button>
{pdfState === 'error' && (
<p style={{ color: 'var(--danger)', fontSize: 'var(--fs-sm)', margin: '8px 0 0' }}>
La génération du PDF a échoué. Réessayez, ou imprimez la page (Ctrl+P / Cmd+P) et choisissez
« Enregistrer en PDF ».
</p>
)}
</div>
)}
</div>
);
}
/* ── Titre de section (regroupement thématique de la FAQ) ──────── */
function FaqSectionTitle({ children, first }) {
return (
<h3 style={{
margin: first ? '0 0 12px' : '32px 0 12px',
fontSize: '0.8rem', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.06em', color: 'var(--text-muted)',
}}>
{children}
</h3>
);
}
/* ── Lien de téléchargement (fichiers statiques dans public/) ──── */
function DownloadLink({ href, children }) {
return (
<a
href={href}
download
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
color: 'var(--primary)', fontSize: 'var(--fs-sm)', fontWeight: 500,
textDecoration: 'none', margin: '0 0 12px',
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<path d="M12 3v12" />
<polyline points="7 10 12 15 17 10" />
<path d="M5 21h14" />
</svg>
{children}
</a>
);
}
/* ── Navigation ─────────────────────────────────────────────── */
const NAV = [
{
@@ -59,6 +210,17 @@ const NAV = [
export default function Aide() {
const { search } = useLocation();
const navigate = useNavigate();
const [openQuestion, setOpenQuestion] = useState(null); // question ouverte dans la FAQ (une seule à la fois)
const [appInfo, setAppInfo] = useState({}); // { appUrl, mcpUrl } — valeurs brutes de la base, telles quelles
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(setAppInfo).catch(() => {});
}, []);
// URL MCP annoncée comme « déjà pré-remplie » : doit refléter ce que
// Mon compte → Serveur MCP affichera réellement, y compris en dev (où
// cette page-là retombe elle-même sur localhost via withDevOverrides).
const mcpConfigUrl = withDevOverrides(appInfo).mcpUrl || 'https://mcp.<votre domaine>/mcp';
const section = new URLSearchParams(search).get('section') || 'faq';
const setSection = (s) => navigate(`/aide?section=${s}`, { replace: true });
@@ -86,9 +248,11 @@ export default function Aide() {
{section === 'faq' && (
<div>
<h2 style={{ marginTop: 0, marginBottom: 24 }}>Questions fréquentes</h2>
<h2 style={{ marginTop: 0, marginBottom: 24, fontSize: '1.6rem', fontWeight: 700 }}>Questions fréquentes</h2>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?">
<FaqSectionTitle first>Comprendre la plateforme</FaqSectionTitle>
<FaqItem question="Comment est calculé le solde du porte-monnaie d'une plateforme ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le solde du porte-monnaie représente les liquidités disponibles sur une plateforme,
c'est-à-dire l'argent que vous pouvez retirer ou réinvestir. Il est calculé comme suit :
@@ -137,7 +301,7 @@ export default function Aide() {
permettant de réconcilier de micro-écarts de calcul (par exemple un arrondi de centimes sur la fiscalité).</p>
</FaqItem>
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?">
<FaqItem question="Comment mettre en place un réinvestissement automatique des intérêts ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le réinvestissement automatique permet de capitaliser les intérêts perçus après chaque remboursement,
sans aucune saisie manuelle. Les intérêts sont automatiquement réinjectés dans le capital du prêt,
@@ -179,6 +343,329 @@ export default function Aide() {
</p>
</FaqItem>
<FaqSectionTitle>API et Serveur MCP pour l'IA</FaqSectionTitle>
<FaqItem question="Comment configurer le serveur MCP pour l'utiliser dans Claude Desktop" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Le serveur MCP tourne déjà en continu (service Docker <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending-mcp</code>,
exposé via Traefik) — vous n'avez rien à installer ni à laisser tourner sur votre machine. Il
suffit de connecter Claude Desktop à l'URL publique.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étapes</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Générez une clé API : <strong style={{ color: 'var(--text)' }}>Mon compte → Clés API → Nouvelle clé</strong> (par exemple nommée « Claude Desktop »).</li>
<li>Allez dans <strong style={{ color: 'var(--text)' }}>Mon compte → Serveur MCP</strong> et renseignez l'URL publique
(<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>{mcpConfigUrl}</code>{appInfo.mcpUrl ? ' — déjà pré-rempli si vous ne l\'avez pas modifié' : ''}) ainsi que la clé générée.</li>
<li>Copiez la configuration générée dans <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>claude_desktop_config.json</code> (Réglages
Développeur Serveurs MCP locaux Modifier la config) le mécanisme passe par
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> mcp-remote</code> (avec le wrapper <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>cmd /c</code> sous
Windows), généré automatiquement pour vous.</li>
<li>Redémarrez complètement Claude Desktop.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Sécurité</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Le point d'entrée public n'a <strong style={{ color: 'var(--text)' }}>volontairement aucune restriction d'IP</strong> (accessible
depuis n'importe , pour un usage nomade) : la clé API est donc la seule barrière d'accès. Traitez-la comme un
mot de passe, et révoquez-la immédiatement en cas de doute (Mon compte → Clés API).</li>
<li>Limite de 60 requêtes/minute par IP (au-delà, erreur 429) et fermeture automatique des sessions inactives
depuis plus de 30 minutes — aucune donnée de session n'est conservée entre deux connexions.</li>
<li>L'outil <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> (lecture d'une page web arbitraire) reste
<strong style={{ color: 'var(--text)' }}> désactivé</strong> sur ce serveur.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Si la connexion reste bloquée sans erreur visible, la cause est presque toujours la même :
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}> npx</code> qui échoue silencieusement à joindre le registre npm voir la
section Dépannage de <strong style={{ color: 'var(--text)' }}>Mon compte Serveur MCP</strong>.
</p>
</FaqItem>
<FaqItem question="Comment utiliser le serveur MCP au quotidien ? (fonctions disponibles et exemples)" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Une fois connecté, Claude (Desktop ou tout autre client MCP) peut consulter votre portefeuille en langage
naturel il choisit lui-même le bon outil selon votre question. Le serveur est <strong style={{ color: 'var(--text)' }}>strictement
en lecture seule</strong> : aucune donnée n'est jamais créée, modifiée ou supprimée depuis une conversation. Toute
saisie (nouvel investissement, remboursement…) reste manuelle dans l'application.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Fonctions disponibles</h4>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<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>
</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 style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille : capital investi, capital en risque, montant remboursé, intérêts bruts/nets, dépôts/retraits — filtrable par année.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_investissements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Liste des investissements (projet, émetteur, plateforme, montant, taux, durée, statut), filtrable par statut.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_get_investissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Détail complet d'un investissement (par id), y compris la liste de ses remboursements réels perçus.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements perçus (toutes plateformes), filtrable par période.</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_list_depots_retraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash (dépôts et retraits), du plus récent au plus ancien.</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
<em>Développement local uniquement</em>, désactivé par défaut. Lit une page web (ex. annonce de projet sur
une plateforme) et en extrait le texte propre c'est à vous d'en reprendre les informations utiles pour
créer l'investissement manuellement, l'outil ne saisit rien lui-même.
</td>
</tr>
</tbody>
</table>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Exemples de questions</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 2 }}>
<li>« Quel est mon encours de crowdlending actuellement ? »</li>
<li>« Quel a été mon rendement (intérêts nets) en 2026 ? »</li>
<li>« Liste-moi les investissements en retard ou en procédure. »</li>
<li>« Donne-moi le détail de l'investissement 42, avec ses remboursements. »</li>
<li>« Quels remboursements ai-je reçus entre le 1er et le 30 juin 2026 ? »</li>
<li>« Quels ont été mes derniers dépôts et retraits ? »</li>
<li>« Regarde cette annonce de projet et propose-moi les infos pour créer l'investissement : [URL] »
(développement local, avec <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>crowdlending_fetch_url</code> activé)</li>
</ul>
<p style={{ marginBottom: 0 }}>
Ces exemples fonctionnent aussi bien en dev qu'en prod dès lors que le serveur correspondant est connecté
(voir les FAQ de configuration ci-dessus) — les six premiers outils sont identiques dans les deux environnements.
</p>
</FaqItem>
<FaqItem question="Comment interroger mon portefeuille depuis Excel avec Power Query ?" openQuestion={openQuestion} setOpenQuestion={setOpenQuestion}>
<p style={{ marginTop: 0 }}>
Comme le serveur MCP, Power Query s'appuie sur l'API v1 (lecture seule) via une clé API.
Power Query est intégré nativement à Excel (365 / 2016 et plus, Windows ou Mac) — rien à
installer. Chaque étape ci-dessous correspond à un écran que vous pouvez capturer pour
illustrer votre propre guide.
</p>
<div>
<DownloadLink href="/powerquery/crowdlending-powerquery-exemple.xlsb">
Télécharger le classeur complet (.xlsb)
</DownloadLink>
</div>
<p style={{ marginTop: 0, marginBottom: 16 }}>
Le classeur ci-dessus contient déjà les 7 requêtes ci-dessous, prêtes à charger — il ne
reste qu'à renseigner votre clé (étape 1). Vous pouvez aussi tout reconstruire à la main
avec les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> individuels
proposés à chaque étape ci-dessous.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 1 Générer une clé API</h4>
<ol style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>Mon compte Clés API Nouvelle clé</strong>.</li>
<li>Donnez-lui un nom explicite (ex. « Excel »), validez, puis copiez immédiatement la clé
affichée elle ne sera plus visible en clair ensuite.</li>
</ol>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 2 Créer les deux requêtes de connexion</h4>
<p>
Dans Excel : <strong style={{ color: 'var(--text)' }}>Données Obtenir des données À partir d'autres
sources → Requête vide</strong>. Une nouvelle requête « Requête1 » apparaît dans l'éditeur Power Query.
</p>
<ol style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Renommez-la <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> (double-clic
sur son nom dans le volet Requêtes), puis <strong style={{ color: 'var(--text)' }}>Affichage Éditeur avancé</strong>,
effacez le contenu par défaut et collez :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"http://localhost:4000/api/v1"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiBaseUrl.pq">Télécharger ApiBaseUrl.pq</DownloadLink>
<img
src="/powerquery/apibaseurl-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiBaseUrl avec l'URL de l'API en production"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<ol start={2} style={{ margin: '0 0 8px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li>Cliquez <strong style={{ color: 'var(--text)' }}>Terminé</strong>, puis <strong style={{ color: 'var(--text)' }}>Accueil Fermer et charger dans Uniquement créer la connexion</strong> (pas besoin d'un tableau pour celle-ci).</li>
<li>Répétez l'opération pour une seconde requête vide nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code> :</li>
</ol>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`"clk_live_VOTRE_CLE_API"`}
</pre>
<DownloadLink href="/powerquery/queries/ApiKey.pq">Télécharger ApiKey.pq</DownloadLink>
<img
src="/powerquery/apikey-editeur-avance.png"
alt="Éditeur avancé Power Query — requête ApiKey avec la clé API renseignée"
style={{ width: '100%', maxWidth: 600, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Remplacez par la clé copiée à l'étape 1, entre guillemets. Même chose : Terminé → Uniquement créer la connexion.</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 3 — La fonction technique fnApiGet</h4>
<p>
Une troisième requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnApiGet</code>,
fait l'appel HTTP et transmet la clé dans l'en-tête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>X-API-Key</code> —
les requêtes suivantes la réutiliseront par son nom, sans la récrire :
</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`(chemin as text, optional parametres as nullable record) as any =>
let
parametresBruts = if parametres = null then [] else parametres,
champsUtiles = List.Select(
Record.FieldNames(parametresBruts),
each Record.Field(parametresBruts, _) <> null and Record.Field(parametresBruts, _) <> ""
),
parametresNettoyes = Record.SelectFields(parametresBruts, champsUtiles),
reponse = Web.Contents(
ApiBaseUrl,
[
RelativePath = chemin,
Headers = [#"X-API-Key" = ApiKey, #"Accept" = "application/json"],
Query = parametresNettoyes
]
),
resultat = Json.Document(reponse)
in
resultat`}
</pre>
<DownloadLink href="/powerquery/queries/fnApiGet.pq">Télécharger fnApiGet.pq</DownloadLink>
<img
src="/powerquery/fnapiget-editeur-avance.png"
alt="Éditeur avancé Power Query — fonction fnApiGet"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>Terminé → Fermer et charger dans… → Uniquement créer la connexion (ce n'est pas une donnée à afficher).</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 4 Première requête de données : Investissements</h4>
<p>Une nouvelle requête vide, nommée <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investissements</code> :</p>
<pre style={{ margin: '0 0 12px', padding: '12px 16px', background: 'var(--surface-2)', borderRadius: 8, fontFamily: 'monospace', fontSize: 'var(--fs-sm)', color: 'var(--text)', whiteSpace: 'pre-wrap', overflowX: 'auto' }}>
{`let
// Pour filtrer par statut, remplacez null par [statut = "rembourse"]
// Statuts possibles : en_cours, rembourse, en_retard, procedure, cloture
Source = fnApiGet("/investissements", null),
Resultat = Table.FromRecords(Source)
in
Resultat`}
</pre>
<DownloadLink href="/powerquery/queries/Investissements.pq">Télécharger Investissements.pq</DownloadLink>
<img
src="/powerquery/investissements-editeur-avance.png"
alt="Éditeur avancé Power Query — requête Investissements avec aperçu des données chargées"
style={{ width: '100%', maxWidth: 700, borderRadius: 8, border: '1px solid var(--border)', display: 'block', margin: '0 0 16px' }}
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
<p>
Cette fois-ci, <strong style={{ color: 'var(--text)' }}>Terminé Fermer et charger</strong> (le bouton
simple, pas « ») : la requête s'ajoute comme tableau dans une nouvelle feuille, avec vos
investissements en ligne et en colonnes.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Étape 5 — Actualiser</h4>
<p style={{ marginBottom: 0 }}>
<strong style={{ color: 'var(--text)' }}>Données → Actualiser tout</strong> (ou Ctrl+Alt+F5) à tout moment
pour recharger les données depuis l'API utile après un nouvel investissement ou remboursement
saisi dans l'app.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Aller plus loin — les autres requêtes</h4>
<p>Même principe (requête vide → nom exact → éditeur avancé → coller → charger) pour :</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--fs-sm)', margin: '0 0 14px' }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Investisseur</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Profil investisseur (ou liste des membres si clé « Famille et entreprises »).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Investisseur.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Dashboard</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>KPIs du portefeuille, au format « Indicateur / Valeur » (facile à croiser dans un TCD).</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Dashboard.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>Remboursements</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des remboursements, filtrable par période.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/Remboursements.pq">.pq</DownloadLink>
</td>
</tr>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>DépôtsRetraits</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>Historique des mouvements de cash.</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/DepotsRetraits.pq">.pq</DownloadLink>
</td>
</tr>
<tr>
<td style={{ padding: '8px 10px 8px 0', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>fnDetailInvestissement</code>
</td>
<td style={{ padding: '8px 0', verticalAlign: 'top' }}>
Fonction avancée : sur la table <em>Investissements</em>, colonne personnalisée{' '}
<code style={{ fontFamily: 'monospace' }}>= fnDetailInvestissement([id])</code>{' '}
pour ramener le détail + les remboursements de chaque prêt.
</td>
<td style={{ padding: '8px 0 8px 10px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<DownloadLink href="/powerquery/queries/fnDetailInvestissement.pq">.pq</DownloadLink>
</td>
</tr>
</tbody>
</table>
<p>
Toutes ces requêtes sont déjà incluses dans le classeur téléchargeable en haut de cette fiche —
les fichiers <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>.pq</code> ci-dessus
ne sont utiles que si vous préférez les coller vous-même dans un classeur existant.
</p>
<h4 style={{ margin: '16px 0 8px', color: 'var(--text)' }}>Dépannage</h4>
<ul style={{ margin: '0 0 12px 16px', paddingLeft: 0, lineHeight: 1.8 }}>
<li><strong style={{ color: 'var(--text)' }}>« Impossible de se connecter au service distant »</strong> — le
backend n'est pas démarré, ou <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiBaseUrl</code> est
incorrecte (vérifiez le port et le suffixe <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>/api/v1</code>).</li>
<li><strong style={{ color: 'var(--text)' }}>Erreur 401 / clé invalide</strong> régénérez une clé dans
Mon compte Clés API et mettez à jour la requête <code style={{ background: 'var(--surface-2)', padding: '1px 5px', borderRadius: 4 }}>ApiKey</code>.</li>
<li><strong style={{ color: 'var(--text)' }}>Avertissement de confidentialité au premier chargement</strong>
normal pour toute nouvelle source Web dans Power Query ; choisissez « Organisationnel » ou « Public »
selon votre contexte.</li>
</ul>
<p style={{ marginBottom: 0 }}>
Comme le serveur MCP, ces requêtes sont strictement en lecture seule : aucune saisie n'est possible
depuis Excel, toute modification reste à faire dans l'application.
</p>
</FaqItem>
</div>
)}
+184 -25
View File
@@ -8,6 +8,7 @@ import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import { api } from '../api.js';
import { memberLabel } from '../utils/format.js';
import { withDevOverrides } from '../utils/devOverrides.js';
/* ── Icônes nav ─────────────────────────────────────────────── */
function IconUser() {
@@ -805,6 +806,12 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
const [busy, setBusy] = useState(false);
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(() => {
if (open) {
setNom('');
@@ -813,6 +820,8 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
}
}, [open, investisseurs]);
const isScopeAll = investisseurId === 'all';
const submit = async (e) => {
e.preventDefault();
if (!nom.trim()) return setErr('Le nom de la clé est requis');
@@ -820,7 +829,10 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
setBusy(true);
setErr(null);
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);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
@@ -841,10 +853,13 @@ function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
{investisseurs.map(i => (
<option key={i.id} value={i.id}>{memberLabel(i)}</option>
))}
{principal && <option value="all">Famille et entreprises</option>}
</select>
</div>
<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>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="ghost" onClick={onClose}>Annuler</button>
@@ -1043,7 +1058,7 @@ function ApiKeysSection() {
{keys.map(k => (
<tr key={k.id}>
<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>{fmtKeyDate(k.created_at)}</td>
<td>{fmtKeyDate(k.last_used_at)}</td>
@@ -1149,9 +1164,27 @@ function CopyBlock({ text }) {
}
function McpServerSection({ goToApiKeys }) {
const navigate = useNavigate();
const [mcpUrl, setMcpUrl] = useState(guessMcpUrl());
const [apiKey, setApiKey] = useState('');
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 [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 mcpUrlEditedRef = useRef(false); // true dès que l'utilisateur touche le champ — n'écrase plus la valeur saisie
// Pré-remplit avec l'URL renseignée par l'admin (Administration → Général)
// si elle existe, plutôt que de laisser la seule devinette basée sur
// l'hôte courant (utile notamment quand l'app est servie derrière un nom
// de domaine différent du sous-domaine mcp.<hostname> par défaut).
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
// En dev local, la base est régulièrement une copie de la prod : la
// valeur stockée pointerait encore vers mcp.<domaine de prod> tant
// qu'on ne la corrige pas à la main — voir utils/devOverrides.js.
const info = withDevOverrides(d);
if (info.mcpUrl && !mcpUrlEditedRef.current) setMcpUrl(info.mcpUrl);
}).catch(() => {});
}, []);
const detectedLabel = detectLabelFromUrl(mcpUrl);
const label = manualLabel ?? detectedLabel;
@@ -1164,14 +1197,32 @@ function McpServerSection({ goToApiKeys }) {
// API passe en variable d'environnement plutôt que directement dans args
// (bug connu de Claude Desktop Windows qui tronque les valeurs à espaces).
const envVarName = `${label.toUpperCase()}_API_KEY`;
const mcpRemoteArgs = [
'-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`,
...(debugFlag ? ['--debug'] : []),
];
// Sur Windows, npx est en réalité npx.cmd (un script) : child_process.spawn,
// utilisé par Claude Desktop, ne sait pas l'exécuter directement sans passer
// par l'interpréteur de commandes — le serveur reste bloqué sur "running"
// sans jamais répondre. Il faut donc l'appeler via cmd /c. Sans risque sur
// 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 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({
mcpServers: {
[serverKey]: {
command: 'npx',
args: ['-y', 'mcp-remote', mcpUrl, '--header', `X-API-Key:\${${envVarName}}`],
env: { [envVarName]: apiKey || '<VOTRE_CLE_API>' },
},
[serverKey]: isWindows
? { command: 'cmd', args: ['/c', 'npx', ...mcpRemoteArgs], env: envBlock }
: { command: 'npx', args: mcpRemoteArgs, env: envBlock },
},
}, null, 2);
@@ -1199,7 +1250,7 @@ function McpServerSection({ goToApiKeys }) {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 20, maxWidth: 520 }}>
<div>
<label>URL du serveur MCP</label>
<input value={mcpUrl} onChange={e => setMcpUrl(e.target.value)} />
<input value={mcpUrl} onChange={e => { mcpUrlEditedRef.current = true; setMcpUrl(e.target.value); }} />
</div>
<div>
<label>Clé API</label>
@@ -1251,28 +1302,64 @@ function McpServerSection({ goToApiKeys }) {
action nécessaire au-delà de renseigner l'URL et votre clé API.
</p>
)}
<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
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 (<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement,
Claude Desktop ne confondra jamais les deux portefeuilles.
</p>
{isLocal && (
<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 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
(<code>{serverKey}</code> ci-dessous) s'ajuste automatiquement, Claude Desktop ne confondra jamais
les deux connexions.
</p>
)}
<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)' }}>
Dans Claude Desktop : Réglages → Développeur → Serveurs MCP locaux → <strong>Modifier la config</strong>.
Ce bouton ouvre le bon fichier quelle que soit votre installation — le chemin diffère en effet
selon que Claude Desktop vient de claude.ai (<code>%APPDATA%\Claude\claude_desktop_config.json</code>)
ou du Microsoft Store (dossier virtualisé sous <code>...\Packages\Claude_*\LocalCache\Roaming\Claude\</code>).
Si le fichier contient déjà une clé <code>"mcpServers"</code>, ajoutez-y seulement l'entrée
<code>"{serverKey}"</code> ci-dessous sans écraser le reste ; sinon collez le bloc entier.
Selon l'installation (même téléchargée directement depuis anthropic.com l'origine ne garantit
rien), Claude Desktop peut être packagé en MSIX et virtualiser ce fichier : le bouton ouvre parfois
une copie sous <code>...\Packages\Claude_*\LocalCache\Roaming\Claude\claude_desktop_config.json</code>
alors que l'app tourne réellement avec <code>%APPDATA%\Claude\claude_desktop_config.json</code> (ou
l'inverse). Si vos outils <code>crowdlending_*</code> n'apparaissent jamais après configuration,
vérifiez les <strong>deux emplacements</strong> et éditez celui qui correspond au dossier
<code>logs\mcp.log</code> se met réellement à jour quand vous relancez l'app (voir Dépannage
ci-dessous). Si le fichier contient déjà une clé <code>"mcpServers"</code>, ajoutez-y seulement
l'entrée <code>"{serverKey}"</code> sans écraser le reste ; sinon collez le bloc entier.
</p>
{isWindows && (
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
La config ci-dessous passe par <code>cmd /c npx</code> plutôt que <code>npx</code> directement :
nécessaire sous Windows, Claude Desktop ne sait pas lancer <code>npx</code> (script
<code>.cmd</code>) sans passer par l'interpréteur de commandes — sinon le serveur reste bloqué
sur « running » sans jamais répondre.
</p>
)}
<img
src="/mcp/claude-desktop-developer-settings.png"
alt="Claude Desktop — Réglages → Développeur → Serveurs MCP locaux → Modifier la config"
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'; }}
/>
<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' }} />
Ajouter <code>--debug</code>
</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} />
<h4 style={{ margin: '20px 0 6px', fontSize: 'var(--fs-sm)' }}>4. Redémarrer Claude Desktop</h4>
@@ -1281,12 +1368,84 @@ function McpServerSection({ goToApiKeys }) {
</p>
<h4 style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)' }}>5. Vérifier</h4>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Dans la liste des outils MCP de Claude Desktop, les outils <code>crowdlending_*</code> doivent
apparaître (6 en production, 7 en développement local si <code>crowdlending_fetch_url</code> est
activé — voir <code>mcp-server/README.md</code>). Testez avec une question du type « Quel est mon
encours de crowdlending actuellement ? ».
<p style={{ margin: '0 0 10px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Demandez à Claude : « <strong style={{ color: 'var(--text)' }}>Peux-tu me lister les outils auxquels
tu as accès ?</strong> » — 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 <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 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>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}>
Le serveur reste sur « running » indéfiniment, aucun outil n'apparaît, aucune erreur visible
</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Cause la plus fréquente sous Windows, même avec <code>cmd /c</code> déjà en place : <code>npx</code>
recontacte le registre npm (<code>registry.npmjs.org</code>) à chaque lancement pour vérifier la
version, et un antivirus ou un proxy avec inspection HTTPS (Avast, Kaspersky, ESET, proxy
d'entreprise) fait échouer cette requête avec une erreur de certificat invisible depuis Claude
Desktop, qui attend simplement une réponse jamais reçue jusqu'à expirer au bout d'une minute.
Confirmez en cherchant <code>UNABLE_TO_VERIFY_LEAF_SIGNATURE</code> dans les logs (voir plus bas).
Solution : installez <code>mcp-remote</code> une bonne fois pour toutes (<code>npm install -g
mcp-remote</code>) puis redémarrez Claude Desktop <code>npx</code> utilisera alors le binaire déjà
installé sans repasser par le registre à chaque fois.
</p>
<p style={{ margin: '0 0 4px', fontSize: 'var(--fs-sm)', fontWeight: 600 }}> trouver les logs</p>
<p style={{ margin: '0 0 14px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
<code>logs\mcp.log</code> trace les échanges entre Claude Desktop et le process local (tous serveurs
confondus) ; <code>logs\mcp-server-{serverKey}.log</code> contient la sortie détaillée de ce serveur
précis, uniquement si <code>--debug</code> est activé ci-dessus (case à cocher, étape 3). Le bouton
« Afficher les journaux » de Claude Desktop peut ne pas s'ouvrir sous Windows (bug connu) — allez
chercher directement dans le dossier <code>logs</code>, à l'un des deux emplacements mentionnés à
l'étape 3 (essayez l'autre si l'un des deux est vide ou ne se met pas à jour).
</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, 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
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é
<code>npx</code>/<code>mcp-remote</code> (voir ci-dessus), pas dans <code>server.js</code>.
</p>
</>
)}
</div>
);
}
+29 -7
View File
@@ -5,6 +5,7 @@
import { useState, useEffect } from 'react';
import { api } from '../../api.js';
import { withDevOverrides } from '../../utils/devOverrides.js';
function SettingRow({ label, description, children }) {
return (
@@ -34,6 +35,7 @@ function SectionHeader({ title, description }) {
const DEFAULT = {
appName: 'Crowdlending Tracker',
appUrl: '',
mcpUrl: '',
allowRegistration: true,
minPasswordLength: 8,
};
@@ -46,12 +48,19 @@ export default function GeneralSection() {
useEffect(() => {
api.get('/admin/general')
.then(d => setForm({
appName: d.appName || 'Crowdlending Tracker',
appUrl: d.appUrl || '',
allowRegistration: d.allowRegistration !== false,
minPasswordLength: d.minPasswordLength || 8,
}))
.then(d => {
// En dev local, la base est régulièrement une copie de la prod :
// appUrl/mcpUrl y pointeraient encore vers l'environnement de prod
// tant qu'on ne les corrige pas — voir utils/devOverrides.js.
const dd = withDevOverrides(d);
setForm({
appName: dd.appName || 'Crowdlending Tracker',
appUrl: dd.appUrl || '',
mcpUrl: dd.mcpUrl || '',
allowRegistration: dd.allowRegistration !== false,
minPasswordLength: dd.minPasswordLength || 8,
});
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
@@ -64,6 +73,7 @@ export default function GeneralSection() {
await api.patch('/admin/general', {
appName: form.appName.trim(),
appUrl: form.appUrl.trim(),
mcpUrl: form.mcpUrl.trim(),
allowRegistration: form.allowRegistration,
minPasswordLength: form.minPasswordLength,
});
@@ -103,7 +113,7 @@ export default function GeneralSection() {
/>
</SettingRow>
<SettingRow label="URL de la plateforme" description="Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).">
<SettingRow label="URL de la plateforme" description={`Utilisée pour les boutons de redirection dans les emails. Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
@@ -114,6 +124,18 @@ export default function GeneralSection() {
style={{ width: '100%' }}
/>
</SettingRow>
<SettingRow label="URL du serveur MCP" description={`Point d'entrée public du serveur MCP (endpoint /mcp inclus). Inclure le protocole (https://).${import.meta.env.DEV ? ' Valeur forcée en développement local (ignore celle de la base, potentiellement une copie de la prod).' : ''}`}>
<input
className="form-input"
type="url"
maxLength={500}
value={form.mcpUrl}
placeholder="https://mcp.mon-app.example.com/mcp"
onChange={e => set('mcpUrl', e.target.value)}
style={{ width: '100%' }}
/>
</SettingRow>
</div>
{/* Accès */}
+27
View File
@@ -0,0 +1,27 @@
/**
* devOverrides.js — Neutralise les paramètres généraux venant de la base en
* développement local.
*
* Contexte : la base SQLite locale est régulièrement remplacée par une copie
* de la base de production (Admin → Export complet, puis restauration en
* local) — ce qui inclut les paramètres généraux (URL de la plateforme, URL
* du serveur MCP). Une fois rejouée en local, cette copie pointe encore vers
* l'environnement de prod tant qu'elle n'a pas été corrigée à la main.
*
* `import.meta.env.DEV` est une constante figée par Vite AU MOMENT DU BUILD :
* `true` uniquement quand le code tourne via `vite` (npm run dev), toujours
* `false` dans un build de production (`vite build`), quel que soit le
* contenu de la base utilisée à l'exécution. Aucun effet possible en
* production, donc.
*/
export const DEV_APP_URL = 'http://localhost:5173';
export const DEV_MCP_URL = 'http://localhost:4100/mcp';
/** Retourne `info` (forme de /api/app-info ou /api/admin/general) avec
* `appUrl`/`mcpUrl` forcés aux valeurs locales en développement. Inchangé
* tel quel en production. */
export function withDevOverrides(info) {
if (!import.meta.env.DEV) return info;
return { ...info, appUrl: DEV_APP_URL, mcpUrl: DEV_MCP_URL };
}
+43 -2
View File
@@ -41,6 +41,7 @@ Connectez ensuite Claude Desktop : Réglages → Développeur → Serveurs MCP
locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
```json
// macOS / Linux
{
"mcpServers": {
"crowdlending-dev": {
@@ -60,6 +61,28 @@ locaux → **Modifier la config**, puis ajoutez dans `mcpServers` :
}
```
```json
// Windows
{
"mcpServers": {
"crowdlending-dev": {
"command": "cmd",
"args": [
"/c", "npx",
"-y",
"mcp-remote",
"http://localhost:4100/mcp",
"--header",
"X-API-Key:${CROWDLENDING_API_KEY}"
],
"env": {
"CROWDLENDING_API_KEY": "clk_live_..."
}
}
}
}
```
**Important :** `claude_desktop_config.json` n'accepte que des entrées
`command`/`args` — il n'existe pas de champ `url`/`headers` natif dans ce
fichier (contrairement à d'autres clients MCP). Pour un serveur HTTP comme
@@ -70,7 +93,16 @@ qui se charge de parler HTTP à notre serveur en coulisses, en-tête
`X-API-Key` inclus. Aucune installation manuelle requise, `npx` le télécharge
à la volée.
Notez l'absence d'espace autour du `:` dans `--header` : Claude Desktop
**Sous Windows, le wrapper `cmd /c` est obligatoire** (voir le second bloc
ci-dessus) : `npx` y est en réalité `npx.cmd` (un script), et la façon dont
Claude Desktop lance les process (`spawn` sans interpréteur de commandes) ne
sait pas l'exécuter directement — sans ce wrapper, le serveur reste affiché
comme « running » dans Claude Desktop mais ne répond jamais (blocage
silencieux, pas d'erreur explicite). C'est un problème Node.js/Windows
connu, pas spécifique à ce serveur — la config générée automatiquement dans
Mon compte → Serveur MCP l'applique déjà si elle détecte Windows.
Notez aussi l'absence d'espace autour du `:` dans `--header` : Claude Desktop
(Windows) a un bug connu qui tronque les arguments contenant un espace — on
passe donc la valeur réelle (avec l'espace éventuel) via une variable
d'environnement dans `env` plutôt que directement dans `args`.
@@ -123,6 +155,9 @@ possible, même si les noms d'outils sont identiques des deux côtés.
}
```
Sous Windows, remplacez `"command": "npx"` par `"command": "cmd", "args": ["/c", "npx", ...]`
sur chacune des deux entrées (voir Développement local plus haut).
Utilisez deux clés API différentes (une par instance) : ça permet de
révoquer l'une sans affecter l'autre. Réglez `MCP_LABEL=dev` (côté serveur
local, dans votre `.env` ou variable d'environnement au lancement) pour que
@@ -172,7 +207,7 @@ Tous en lecture seule (`readOnlyHint: true`) :
| 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_list_investissements` | Liste des investissements, filtrable par statut |
| `crowdlending_get_investissement` | Détail d'un investissement + ses remboursements |
@@ -216,6 +251,12 @@ Garde-fous :
votre config utilise un bloc `url`/`headers` directement, non supporté par
`claude_desktop_config.json` (voir Développement local plus haut) : il faut
passer par `command: "npx"` + `mcp-remote`.
- **Le serveur reste sur « running » indéfiniment, « Preparing session… » puis
« Could not attach »** — sous Windows, il manque le wrapper `cmd /c` (voir
Développement local). C'est un problème de fond très courant : `npx` y est
un script `.cmd` que `spawn()` ne sait pas exécuter directement, donc le
process est lancé mais ne communique jamais réellement. Symptôme
caractéristique : aucune erreur explicite, juste un blocage silencieux.
- **`Clé API invalide ou révoquée`** — régénérez une clé dans Mon compte →
Clés API et mettez à jour la config.
- **`Impossible de joindre l'API`** — le backend n'est pas démarré, ou
+24 -7
View File
@@ -17,10 +17,11 @@
* des outils de CETTE session uniquement (voir createSession). Deux sessions
* ne partagent jamais d'état ni de données.
*
* Connexion depuis Claude Desktop (dev comme prod) :
* { "url": "http://localhost:4100/mcp", "headers": { "X-API-Key": "..." } }
* (remplacer l'URL par https://mcp.crowdlending.croguennec.net/mcp pour la
* prod). Voir README.md pour le détail.
* Connexion depuis Claude Desktop (dev comme prod) : ce fichier expose du
* HTTP pur, mais claude_desktop_config.json n'accepte que des entrées
* command/args — la connexion passe donc par le pont mcp-remote
* (https://github.com/geelen/mcp-remote), voir README.md pour la config
* exacte (et le wrapper cmd /c obligatoire sous Windows).
*/
import { randomUUID } from 'node:crypto';
@@ -63,6 +64,17 @@ const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] démarrage — API cible : ${API_BASE}, hôtes autorisés : ${ALLOWED_HOSTS.join(', ')}, fetch_url : ${ENABLE_FETCH_URL ? 'activé' : 'désactivé'}`);
/** Préfixe commun à toutes les lignes de log applicatif. */
const LOG_PREFIX = `[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}]`;
/** Résumé lisible d'une requête JSON-RPC entrante (nom d'outil pour un
* tools/call, méthode sinon), pour le log applicatif — jamais la clé API. */
function describeRequest(body) {
if (!body || typeof body !== 'object') return 'requête inconnue';
if (body.method === 'tools/call') return `tools/call ${body.params?.name || '?'}`;
return body.method || 'requête inconnue';
}
/** Ajoute le libellé d'environnement au titre d'un outil (ex. "[PROD]"). */
const withLabel = (title) => LABEL ? `${title} [${LABEL.toUpperCase()}]` : title;
@@ -118,7 +130,7 @@ setInterval(() => {
const now = Date.now();
for (const [sessionId, s] of sessions.entries()) {
if (now - s.lastActivity > SESSION_IDLE_TIMEOUT_MS) {
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] session ${sessionId} inactive depuis plus de 30 min, fermeture.`);
console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)} inactive depuis plus de 30 min, fermeture.`);
s.transport.close();
sessions.delete(sessionId);
}
@@ -179,7 +191,10 @@ app.post('/mcp', async (req, res) => {
return;
}
touchSession(sessionId);
const label = describeRequest(req.body);
const startedAt = Date.now();
await session.transport.handleRequest(req, res, req.body);
console.error(`${LOG_PREFIX} session ${sessionId.slice(0, 8)}${label}${res.statusCode} (${Date.now() - startedAt}ms)`);
return;
}
@@ -195,9 +210,11 @@ app.post('/mcp', async (req, res) => {
}
const transport = await createSession(apiKey);
const startedAt = Date.now();
await transport.handleRequest(req, res, req.body);
console.error(`${LOG_PREFIX} nouvelle session ${transport.sessionId ? transport.sessionId.slice(0, 8) : '?'} — initialize — ${res.statusCode} (${Date.now() - startedAt}ms)`);
} catch (e) {
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] erreur /mcp POST :`, e);
console.error(`${LOG_PREFIX} erreur /mcp POST :`, e);
if (!res.headersSent) res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: e.message }, id: null });
}
});
@@ -220,5 +237,5 @@ app.get('/mcp', handleExistingSession);
app.delete('/mcp', handleExistingSession);
app.listen(PORT, '0.0.0.0', () => {
console.error(`[crowdlending-mcp${LABEL ? `-${LABEL}` : ''}] à l'écoute sur le port ${PORT}`);
console.error(`${LOG_PREFIX} à l'écoute sur le port ${PORT}`);
});
+4 -1
View File
@@ -51,7 +51,10 @@ export function registerDataTools(server, { apiGet, withLabel = (t) => t, withSo
description: withSource(
"Retourne le profil de l'investisseur associé à la clé API utilisée " +
"(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: {},
annotations: READ_ONLY_ANNOTATIONS,
},