Création de la feature API V1
This commit is contained in:
@@ -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><URL_DE_VOTRE_API></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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user