feat(api) : gérer les clés de l'API externe depuis Paramètres (#570)

Nouvel onglet Paramètres > API externe (admin) : état de l'API, adresse,
création d'une clé nommée par outil (affichée une seule fois), liste avec
dernière utilisation, révocation immédiate.

- table external_api_keys (empreinte SHA-256 uniquement), créée par
  migrations.production.ts et init.sql
- l'API accepte les clés de Paramètres et toujours celles de
  EXTERNAL_API_KEYS ; 503 seulement si aucune clé active
- routes /api/external-api/keys (session + admin)
- documentation et .env.example mis à jour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrRFddV2BaqDCxGY1j52UJ
This commit is contained in:
LogiFlowandClaude Opus 5.5 authored and GitHub committed 2026-10-10 09:53:41 +02:00
1 parent b964e137bf
commit 6760f43e87
11 files changed
+576 -19

No files matched your search

+291
View File
@@ -0,0 +1,291 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient";
import { AlertTriangle, Check, Copy, KeyRound, Plug, Plus, Trash2 } from "lucide-react";
interface ApiKey {
id: number;
name: string;
keyPrefix: string;
createdAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
}
interface ApiKeysResponse {
basePath: string;
envKeyCount: number;
keys: ApiKey[];
}
const QUERY_KEY = ["/api/external-api/keys"];
function formatDateTime(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
}
// Message d'erreur lisible depuis « 400: {"error":"..."} » renvoyé par apiRequest
function errorMessage(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
const json = raw.slice(raw.indexOf(":") + 1).trim();
try {
return JSON.parse(json).error || raw;
} catch {
return raw;
}
}
function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ title: "Copie impossible", description: "Sélectionnez le texte et copiez-le à la main.", variant: "destructive" });
}
};
return (
<Button type="button" variant="outline" size="sm" onClick={copy} aria-label={label} className="shrink-0">
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
<span className="ml-1.5">{copied ? "Copié" : "Copier"}</span>
</Button>
);
}
export default function ExternalApiSettings() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [newKeyName, setNewKeyName] = useState("");
const [createdKey, setCreatedKey] = useState<{ name: string; key: string } | null>(null);
const [keyToRevoke, setKeyToRevoke] = useState<ApiKey | null>(null);
const { data, isLoading, error } = useQuery<ApiKeysResponse>({ queryKey: QUERY_KEY });
const createMutation = useMutation({
mutationFn: (name: string) => apiRequest("/api/external-api/keys", "POST", { name }),
onSuccess: (result: { key: string; apiKey: ApiKey }) => {
setCreatedKey({ name: result.apiKey.name, key: result.key });
setNewKeyName("");
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
onError: (err) => toast({ title: "Création impossible", description: errorMessage(err), variant: "destructive" }),
});
const revokeMutation = useMutation({
mutationFn: (id: number) => apiRequest(`/api/external-api/keys/${id}`, "DELETE"),
onSuccess: () => {
toast({ title: "Clé révoquée", description: "Les outils qui l'utilisaient n'ont plus accès à l'API." });
setKeyToRevoke(null);
queryClient.invalidateQueries({ queryKey: QUERY_KEY });
},
onError: (err) => toast({ title: "Révocation impossible", description: errorMessage(err), variant: "destructive" }),
});
const apiUrl = `${window.location.origin}${data?.basePath ?? "/api/ext/v1"}`;
const activeKeys = data?.keys.filter((k) => !k.revokedAt) ?? [];
const revokedKeys = data?.keys.filter((k) => k.revokedAt) ?? [];
const apiEnabled = activeKeys.length > 0 || (data?.envKeyCount ?? 0) > 0;
const handleCreate = (event: React.FormEvent) => {
event.preventDefault();
const name = newKeyName.trim();
if (name) createMutation.mutate(name);
};
return (
<div className="h-full overflow-y-auto space-y-6">
{/* Présentation et adresse */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plug className="w-5 h-5 text-blue-600" />
API externe de rapprochement
</CardTitle>
<CardDescription>
Permet à un outil externe (n8n, comptabilité, script…) de lire les fournisseurs et les numéros de BL
des livraisons, puis d'écrire le numéro de facture, les montants et l'échéance.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-gray-700">État :</span>
{isLoading ? (
<span className="text-sm text-gray-600">Chargement…</span>
) : apiEnabled ? (
<Badge className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
) : (
<Badge variant="outline" className="border-orange-300 text-orange-800">Désactivée : aucune clé active</Badge>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="api-url">Adresse de l'API</Label>
<div className="flex gap-2">
<Input id="api-url" value={apiUrl} readOnly className="font-mono text-sm" />
<CopyButton value={apiUrl} label="Copier l'adresse de l'API" />
</div>
<p className="text-sm text-gray-600">
La clé s'envoie dans l'en-tête <code className="rounded bg-gray-100 px-1">X-API-Key</code>{" "}
(ou <code className="rounded bg-gray-100 px-1">Authorization: Bearer …</code>).
</p>
</div>
<div className="rounded-lg bg-gray-50 border border-gray-200 p-3 text-sm text-gray-700 space-y-1">
<p className="font-medium text-gray-900">Principales routes</p>
<ul className="space-y-0.5 font-mono text-xs sm:text-sm">
<li>GET /stores — magasins</li>
<li>GET /suppliers — fournisseurs</li>
<li>GET /deliveries?supplierId=…&amp;hasInvoice=false — livraisons et n° de BL</li>
<li>PATCH /deliveries/:id — n° de facture, montants HT/TTC, échéance</li>
</ul>
<p className="pt-1">Documentation complète : <code className="rounded bg-white px-1">docs/API-RAPPROCHEMENT.md</code></p>
</div>
</CardContent>
</Card>
{/* Clés */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-blue-600" />
Clés d'API
</CardTitle>
<CardDescription>
Créez une clé par outil, pour pouvoir en révoquer une sans couper les autres.
La clé n'est affichée qu'une seule fois, à sa création.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2 sm:items-end">
<div className="flex-1 space-y-1.5">
<Label htmlFor="api-key-name">Nom de la nouvelle clé</Label>
<Input
id="api-key-name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Ex. : n8n comptabilité"
maxLength={100}
/>
</div>
<Button type="submit" disabled={!newKeyName.trim() || createMutation.isPending}>
<Plus className="w-4 h-4 mr-1.5" />
{createMutation.isPending ? "Création…" : "Créer une clé"}
</Button>
</form>
{error ? (
<p className="text-sm text-red-700">Impossible de charger les clés : {errorMessage(error)}</p>
) : isLoading ? (
<p className="text-sm text-gray-600">Chargement…</p>
) : activeKeys.length === 0 ? (
<p className="text-sm text-gray-600">Aucune clé active.</p>
) : (
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200">
{activeKeys.map((apiKey) => (
<li key={apiKey.id} className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-gray-900 truncate">{apiKey.name}</p>
<p className="text-sm text-gray-600">
<code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code>
{" · "}créée le {formatDateTime(apiKey.createdAt)}
{" · "}
{apiKey.lastUsedAt ? `dernière utilisation le ${formatDateTime(apiKey.lastUsedAt)}` : "jamais utilisée"}
</p>
</div>
<Button
variant="outline"
size="sm"
className="self-start sm:self-auto text-red-700 border-red-200 hover:bg-red-50"
onClick={() => setKeyToRevoke(apiKey)}
>
<Trash2 className="w-4 h-4 mr-1.5" />
Révoquer
</Button>
</li>
))}
</ul>
)}
{(data?.envKeyCount ?? 0) > 0 && (
<p className="text-sm text-gray-600">
{data!.envKeyCount === 1 ? "1 clé est aussi définie" : `${data!.envKeyCount} clés sont aussi définies`} dans
la variable d'environnement <code className="rounded bg-gray-100 px-1">EXTERNAL_API_KEYS</code> du serveur
(non modifiables ici).
</p>
)}
{revokedKeys.length > 0 && (
<details className="text-sm text-gray-600">
<summary className="cursor-pointer">Clés révoquées ({revokedKeys.length})</summary>
<ul className="mt-2 space-y-1">
{revokedKeys.map((apiKey) => (
<li key={apiKey.id}>
{apiKey.name} · <code className="rounded bg-gray-100 px-1">{apiKey.keyPrefix}…</code> · révoquée le {formatDateTime(apiKey.revokedAt)}
</li>
))}
</ul>
</details>
)}
</CardContent>
</Card>
{/* Clé créée : affichée une seule fois */}
<Dialog open={!!createdKey} onOpenChange={(open) => { if (!open) setCreatedKey(null); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Clé « {createdKey?.name} » créée</DialogTitle>
<DialogDescription>
Copiez-la maintenant et collez-la dans votre outil : elle ne sera plus jamais affichée.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input value={createdKey?.key ?? ""} readOnly className="font-mono text-sm" onFocus={(e) => e.currentTarget.select()} aria-label="Clé d'API" />
{createdKey && <CopyButton value={createdKey.key} label="Copier la clé d'API" />}
</div>
<div className="flex items-start gap-2 rounded-lg bg-amber-50 border border-amber-200 p-3 text-sm text-amber-900">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<p>Cette clé donne accès aux livraisons de tous les magasins. Ne la partagez pas ; en cas de doute, révoquez-la.</p>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>J'ai copié la clé</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Confirmation de révocation */}
<Dialog open={!!keyToRevoke} onOpenChange={(open) => { if (!open) setKeyToRevoke(null); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Révoquer la clé « {keyToRevoke?.name} » ?</DialogTitle>
<DialogDescription>
Les outils qui l'utilisent perdront immédiatement l'accès à l'API. Cette action est définitive.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setKeyToRevoke(null)}>Annuler</Button>
<Button
variant="destructive"
disabled={revokeMutation.isPending}
onClick={() => keyToRevoke && revokeMutation.mutate(keyToRevoke.id)}
>
{revokeMutation.isPending ? "Révocation…" : "Révoquer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+19 -1
View File
@@ -9,6 +9,7 @@ import {
Cloud,
Send,
Code,
Plug,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug";
import WeatherSettings from "./WeatherSettings";
import WebhookBAPConfig from "./WebhookBAPConfig";
import SQLExecutor from "./SQLExecutor";
import ExternalApiSettings from "./ExternalApiSettings";
export default function Utilities() {
const { user } = useAuthUnified();
@@ -31,6 +33,7 @@ export default function Utilities() {
if (path === '/nocodb-config') return 'nocodb';
if (path === '/database-debug') return 'debug';
if (path === '/weather-settings') return 'weather';
if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api';
return 'backups';
});
@@ -41,6 +44,7 @@ export default function Utilities() {
const canManageWeather = user?.role === 'admin';
const canManageWebhookBAP = user?.role === 'admin';
const canExecuteSQL = user?.role === 'admin';
const canManageExternalApi = user?.role === 'admin';
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
return (
@@ -78,7 +82,7 @@ export default function Utilities() {
{/* Content avec onglets */}
<div className="flex-1 p-6 overflow-hidden">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-6 mb-6">
<TabsList className="flex flex-wrap h-auto w-full justify-start gap-1 mb-6">
{canManageBackups && (
<TabsTrigger value="backups" className="flex items-center gap-2">
<Database className="w-4 h-4" />
@@ -109,6 +113,12 @@ export default function Utilities() {
Configuration BAP
</TabsTrigger>
)}
{canManageExternalApi && (
<TabsTrigger value="api" className="flex items-center gap-2">
<Plug className="w-4 h-4" />
API externe
</TabsTrigger>
)}
{canExecuteSQL && (
<TabsTrigger value="sql" className="flex items-center gap-2">
<Code className="w-4 h-4" />
@@ -158,6 +168,14 @@ export default function Utilities() {
</TabsContent>
)}
{canManageExternalApi && (
<TabsContent value="api" className="flex-1 overflow-hidden">
<div className="h-full bg-gray-50 -m-6 p-6">
<ExternalApiSettings />
</div>
</TabsContent>
)}
{canExecuteSQL && (
<TabsContent value="sql" className="flex-1 overflow-hidden">
<div className="h-full bg-gray-50 -m-6 p-6">