Création de la feature API V1

This commit is contained in:
2026-07-14 16:22:34 +02:00
parent f9c5316303
commit c843464ccd
14 changed files with 1263 additions and 0 deletions
+307
View File
@@ -4,7 +4,10 @@ import PasswordInput from '../components/PasswordInput.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
import { useInvestisseur } from '../context/InvestisseurContext.jsx';
import Modal from '../components/Modal.jsx';
import { api } from '../api.js';
import { memberLabel } from '../utils/format.js';
/* ── Icônes nav ─────────────────────────────────────────────── */
function IconUser() {
@@ -13,6 +16,12 @@ function IconUser() {
function IconLock() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>;
}
function IconKey() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M21 2l-9.6 9.6"/><path d="M15.5 7.5l3 3L22 7l-3-3"/></svg>;
}
function IconTrash() {
return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>;
}
/* ── Dropdown custom style Finary ────────────────────────────── */
const LANGUES = [
@@ -782,6 +791,302 @@ function DeleteAccountSection() {
);
}
/* ── Clés API ─────────────────────────────────────────────────
Permet de générer des clés pour un accès programmatique (API publique
en lecture seule, serveur MCP local...). Chaque clé est nommée par
l'utilisateur et rattachée à un investisseur précis. La valeur en clair
n'est affichée qu'une seule fois, juste après la création. ─────────── */
function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
const [nom, setNom] = useState('');
const [investisseurId, setInvestisseurId] = useState(investisseurs[0]?.id || '');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
useEffect(() => {
if (open) {
setNom('');
setInvestisseurId(investisseurs[0]?.id || '');
setErr(null);
}
}, [open, investisseurs]);
const submit = async (e) => {
e.preventDefault();
if (!nom.trim()) return setErr('Le nom de la clé est requis');
if (!investisseurId) return setErr('Sélectionnez un investisseur');
setBusy(true);
setErr(null);
try {
const created = await api.post('/api-keys', { nom: nom.trim(), investisseur_id: Number(investisseurId) });
onCreated(created);
} catch (e) { setErr(e.message); }
finally { setBusy(false); }
};
return (
<Modal open={open} title="Nouvelle clé API" onClose={onClose}>
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{err && <div className="error">{err}</div>}
<div>
<label>Nom de la clé</label>
<input value={nom} onChange={e => setNom(e.target.value)}
placeholder="ex : MCP local, Script import…" autoFocus />
</div>
<div>
<label>Investisseur</label>
<select value={investisseurId} onChange={e => setInvestisseurId(e.target.value)}>
{investisseurs.map(i => (
<option key={i.id} value={i.id}>{memberLabel(i)}</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.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 4 }}>
<button type="button" className="ghost" onClick={onClose}>Annuler</button>
<button type="submit" className="primary" disabled={busy}>
{busy ? 'Création' : 'Créer la clé'}
</button>
</div>
</form>
</Modal>
);
}
/** URL absolue de la doc Swagger, déduite de VITE_API_URL comme le reste de l'app
* (ex: pages/settings/PlateformesSection.jsx). En dev via proxy Vite, VITE_API_URL
* n'est pas défini et l'URL reste relative ça fonctionne aussi bien. */
const DOCS_URL = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/docs';
function RevealApiKeyModal({ apiKey, onClose }) {
const [copied, setCopied] = useState(false);
const [copiedCurl, setCopiedCurl] = useState(false);
if (!apiKey) return null;
const curlCmd = `curl -H "X-API-Key: ${apiKey.key}" <URL_DE_VOTRE_API>/api/v1/dashboard`;
const copy = async () => {
try {
await navigator.clipboard.writeText(apiKey.key);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch { /* clipboard indisponible, l'utilisateur peut sélectionner manuellement */ }
};
const copyCurl = async () => {
try {
await navigator.clipboard.writeText(curlCmd);
setCopiedCurl(true);
setTimeout(() => setCopiedCurl(false), 2000);
} catch { /* clipboard indisponible */ }
};
return (
<Modal open={!!apiKey} title="Clé API créée" onClose={onClose}>
<p style={{ margin: '0 0 12px', fontSize: 'var(--fs-sm)' }}>
Copiez cette clé maintenant : elle ne sera plus jamais affichée en entier.
</p>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all',
}}>
<span style={{ flex: 1 }}>{apiKey.key}</span>
<button type="button" className="ghost" onClick={copy} style={{ flexShrink: 0 }}>
{copied ? 'Copié ✓' : 'Copier'}
</button>
</div>
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border)' }}>
<h4 style={{ margin: '0 0 8px', fontSize: 'var(--fs-sm)' }}>Tester votre clé</h4>
<p style={{ margin: '0 0 8px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Le plus simple : ouvrez la <a href={DOCS_URL} target="_blank" rel="noreferrer">documentation interactive (Swagger)</a>,
cliquez sur « Authorize » et collez la clé ci-dessus, puis testez un endpoint (ex. <code>GET /dashboard</code>).
</p>
<p style={{ margin: '0 0 6px', fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Ou en ligne de commande (remplacez <code>&lt;URL_DE_VOTRE_API&gt;</code> par l'adresse de votre backend,
ex. <code>http://localhost:4000</code> en dev) :
</p>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderRadius: 8, border: '1px solid var(--border)', background: 'var(--surface-2, #f9fafb)',
fontFamily: 'monospace', fontSize: 12, wordBreak: 'break-all',
}}>
<span style={{ flex: 1 }}>{curlCmd}</span>
<button type="button" className="ghost" onClick={copyCurl} style={{ flexShrink: 0 }}>
{copiedCurl ? 'Copié ' : 'Copier'}
</button>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
<button type="button" className="primary" onClick={onClose}>J'ai copié la clé</button>
</div>
</Modal>
);
}
function fmtKeyDate(iso) {
if (!iso) return '—';
return new Date(iso.replace(' ', 'T') + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
/** Modale d'avertissement avant suppression définitive — affichée uniquement
* quand la clé est encore active (une clé révoquée ne sert déjà plus à rien,
* pas besoin d'avertissement dans ce cas). */
function DeleteApiKeyModal({ apiKey, onCancel, onConfirm, busy }) {
if (!apiKey) return null;
return (
<Modal open={!!apiKey} title="Supprimer une clé active ?" onClose={onCancel}>
<p style={{ margin: '0 0 8px', fontSize: 'var(--fs-sm)' }}>
La clé « {apiKey.nom} » est encore <strong>active</strong>. La supprimer coupera immédiatement l'accès
à toute application qui l'utilise (API, serveur MCP...), et cette action est irréversible.
</p>
<p style={{ margin: 0, fontSize: 'var(--fs-sm)', color: 'var(--text-muted)' }}>
Si vous voulez juste bloquer l'accès sans supprimer la clé, préférez « Révoquer ».
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
<button type="button" className="ghost" onClick={onCancel} disabled={busy}>Annuler</button>
<button type="button" className="primary" onClick={onConfirm} disabled={busy}
style={{ background: 'var(--danger,#dc2626)', borderColor: 'var(--danger,#dc2626)' }}>
{busy ? 'Suppression' : 'Supprimer définitivement'}
</button>
</div>
</Modal>
);
}
function ApiKeysSection() {
const { investisseurs } = useInvestisseur();
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(null);
const [showNew, setShowNew] = useState(false);
const [revealKey, setRevealKey] = useState(null);
const [busyId, setBusyId] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null); // clé active en attente de confirmation
const load = async () => {
try {
setLoading(true);
const data = await api.get('/api-keys');
setKeys(data);
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const revoke = async (id) => {
if (!window.confirm('Révoquer cette clé ? Toute application qui l\'utilise perdra immédiatement l\'accès.')) return;
setBusyId(id);
try {
await api.del(`/api-keys/${id}`);
await load();
} catch (e) { setErr(e.message); }
finally { setBusyId(null); }
};
const purge = async (id) => {
setBusyId(id);
try {
await api.del(`/api-keys/${id}/purge`);
setDeleteTarget(null);
await load();
} catch (e) { setErr(e.message); }
finally { setBusyId(null); }
};
/** Clé active → avertissement avant suppression. Clé révoquée → suppression directe. */
const handleDeleteClick = (k) => {
if (k.revoked_at) purge(k.id);
else setDeleteTarget(k);
};
return (
<div className="card" style={{ marginTop: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<h3 style={{ margin: 0 }}>Clés API</h3>
<button className="primary" onClick={() => setShowNew(true)} disabled={!investisseurs.length}>
+ Nouvelle clé
</button>
</div>
<p className="text-muted" style={{ margin: '0 0 16px', fontSize: 'var(--fs-sm)' }}>
Utilisées pour un accès programmatique en lecture seule (API publique, serveur MCP local...). Vous pouvez créer plusieurs clés, une par usage.
{' '}Consultez la <a href={DOCS_URL} target="_blank" rel="noreferrer">documentation de l'API (Swagger)</a> pour la liste des endpoints disponibles.
</p>
{err && <div className="error" style={{ marginBottom: 12 }}>{err}</div>}
{!loading && keys.length === 0 && (
<p style={{ color: 'var(--text-muted)', fontSize: 13 }}>Aucune clé API pour l'instant.</p>
)}
{keys.length > 0 && (
<table style={{ width: '100%' }}>
<thead>
<tr>
<th>Nom</th>
<th>Investisseur</th>
<th>Clé</th>
<th>Créée le</th>
<th>Dernière utilisation</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
{keys.map(k => (
<tr key={k.id}>
<td>{k.nom}</td>
<td>{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>
<td>
{k.revoked_at
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--danger-bg, #fef2f2)', color: 'var(--danger, #dc2626)' }}>Révoquée</span>
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)' }}>Active</span>}
</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 4 }}>
{!k.revoked_at && (
<button className="ghost" onClick={() => revoke(k.id)} disabled={busyId === k.id}
style={{ color: 'var(--danger,#dc2626)' }}>
Révoquer
</button>
)}
<button className="ghost" onClick={() => handleDeleteClick(k)} disabled={busyId === k.id}
title="Supprimer définitivement"
style={{ color: 'var(--text-muted)', padding: '6px 8px' }}>
<IconTrash />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
<NewApiKeyModal
open={showNew}
onClose={() => setShowNew(false)}
investisseurs={investisseurs}
onCreated={(created) => { setShowNew(false); setRevealKey(created); load(); }}
/>
<RevealApiKeyModal apiKey={revealKey} onClose={() => setRevealKey(null)} />
<DeleteApiKeyModal
apiKey={deleteTarget}
busy={busyId === deleteTarget?.id}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => purge(deleteTarget.id)}
/>
</div>
);
}
/* ── Page principale ─────────────────────────────────────────── */
export default function MonCompte() {
const { search } = useLocation();
@@ -794,6 +1099,7 @@ export default function MonCompte() {
const SECTIONS = [
{ id: 'profil', label: 'Mon compte', icon: <IconUser /> },
{ id: 'securite', label: 'Sécurité', icon: <IconLock /> },
{ id: 'api-keys', label: 'Clés API', icon: <IconKey /> },
];
return (
@@ -818,6 +1124,7 @@ export default function MonCompte() {
<div className="account-content-narrow">
{section === 'profil' && <><AccountForm /><DeleteAccountSection /></>}
{section === 'securite' && <><SecurityForm /><TwoFASection user={user} /><TrustedDevicesSection /></>}
{section === 'api-keys' && <ApiKeysSection />}
</div>
</div>