Compare commits
3 Commits
6e58731a20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c464e6a3 | |||
| 441da1975a | |||
| 9eb19efd92 |
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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); }
|
||||
|
||||
|
||||
@@ -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: '' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Generated
+241
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
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
@@ -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 où, 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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 où
|
||||
<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, où 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 }}>Où 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user