mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-11 17:27:31 +02:00
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:
1 parent
b964e137bf
commit
6760f43e87
11 files changed
+575
-18
No files matched your search
+2
-1
@@ -32,6 +32,7 @@ ENCRYPTION_KEY=
|
|||||||
EMERGENCY_SECRET=
|
EMERGENCY_SECRET=
|
||||||
|
|
||||||
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
# API externe de rapprochement BL / factures (docs/API-RAPPROCHEMENT.md).
|
||||||
# Une ou plusieurs clés séparées par des virgules ; vide = API désactivée.
|
# Les clés se créent de préférence dans Paramètres > API externe. Optionnel :
|
||||||
|
# une ou plusieurs clés supplémentaires ici, séparées par des virgules.
|
||||||
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
# Générer une clé : node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
EXTERNAL_API_KEYS=
|
EXTERNAL_API_KEYS=
|
||||||
@@ -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=…&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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Cloud,
|
Cloud,
|
||||||
Send,
|
Send,
|
||||||
Code,
|
Code,
|
||||||
|
Plug,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -21,6 +22,7 @@ import DatabaseDebug from "./DatabaseDebug";
|
|||||||
import WeatherSettings from "./WeatherSettings";
|
import WeatherSettings from "./WeatherSettings";
|
||||||
import WebhookBAPConfig from "./WebhookBAPConfig";
|
import WebhookBAPConfig from "./WebhookBAPConfig";
|
||||||
import SQLExecutor from "./SQLExecutor";
|
import SQLExecutor from "./SQLExecutor";
|
||||||
|
import ExternalApiSettings from "./ExternalApiSettings";
|
||||||
|
|
||||||
export default function Utilities() {
|
export default function Utilities() {
|
||||||
const { user } = useAuthUnified();
|
const { user } = useAuthUnified();
|
||||||
@@ -31,6 +33,7 @@ export default function Utilities() {
|
|||||||
if (path === '/nocodb-config') return 'nocodb';
|
if (path === '/nocodb-config') return 'nocodb';
|
||||||
if (path === '/database-debug') return 'debug';
|
if (path === '/database-debug') return 'debug';
|
||||||
if (path === '/weather-settings') return 'weather';
|
if (path === '/weather-settings') return 'weather';
|
||||||
|
if (new URLSearchParams(window.location.search).get('tab') === 'api') return 'api';
|
||||||
return 'backups';
|
return 'backups';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ export default function Utilities() {
|
|||||||
const canManageWeather = user?.role === 'admin';
|
const canManageWeather = user?.role === 'admin';
|
||||||
const canManageWebhookBAP = user?.role === 'admin';
|
const canManageWebhookBAP = user?.role === 'admin';
|
||||||
const canExecuteSQL = 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é
|
// Si l'utilisateur n'a aucune permission, afficher le message d'accès refusé
|
||||||
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
if (!canManageBackups && !canManageNocoDB && !canDebugDatabase && !canManageWeather && !canManageWebhookBAP && !canExecuteSQL) {
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +82,7 @@ export default function Utilities() {
|
|||||||
{/* Content avec onglets */}
|
{/* Content avec onglets */}
|
||||||
<div className="flex-1 p-6 overflow-hidden">
|
<div className="flex-1 p-6 overflow-hidden">
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
<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 && (
|
{canManageBackups && (
|
||||||
<TabsTrigger value="backups" className="flex items-center gap-2">
|
<TabsTrigger value="backups" className="flex items-center gap-2">
|
||||||
<Database className="w-4 h-4" />
|
<Database className="w-4 h-4" />
|
||||||
@@ -109,6 +113,12 @@ export default function Utilities() {
|
|||||||
Configuration BAP
|
Configuration BAP
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{canManageExternalApi && (
|
||||||
|
<TabsTrigger value="api" className="flex items-center gap-2">
|
||||||
|
<Plug className="w-4 h-4" />
|
||||||
|
API externe
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{canExecuteSQL && (
|
{canExecuteSQL && (
|
||||||
<TabsTrigger value="sql" className="flex items-center gap-2">
|
<TabsTrigger value="sql" className="flex items-center gap-2">
|
||||||
<Code className="w-4 h-4" />
|
<Code className="w-4 h-4" />
|
||||||
@@ -158,6 +168,14 @@ export default function Utilities() {
|
|||||||
</TabsContent>
|
</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 && (
|
{canExecuteSQL && (
|
||||||
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
<TabsContent value="sql" className="flex-1 overflow-hidden">
|
||||||
<div className="h-full bg-gray-50 -m-6 p-6">
|
<div className="h-full bg-gray-50 -m-6 p-6">
|
||||||
|
|||||||
@@ -8,8 +8,15 @@ Code : `server/externalApi.ts`. Préfixe : `/api/ext/v1`.
|
|||||||
|
|
||||||
## Activation et authentification
|
## Activation et authentification
|
||||||
|
|
||||||
Définir une ou plusieurs clés (séparées par des virgules) dans l'environnement,
|
**Depuis LogiFlow (recommandé)** : *Paramètres > API externe* (admin). On y
|
||||||
puis redémarrer :
|
crée une clé par outil (nommée, ex. « n8n comptabilité »), affichée une seule
|
||||||
|
fois à sa création, avec sa date de dernière utilisation ; une clé se révoque
|
||||||
|
d'un clic, sans redémarrage. Seule l'empreinte SHA-256 de la clé est stockée
|
||||||
|
(table `external_api_keys`). L'onglet s'ouvre directement par
|
||||||
|
`/utilities?tab=api`.
|
||||||
|
|
||||||
|
**Par l'environnement (optionnel)** : des clés peuvent aussi être définies,
|
||||||
|
séparées par des virgules, puis le serveur redémarré :
|
||||||
|
|
||||||
```
|
```
|
||||||
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
EXTERNAL_API_KEYS=3f9c…a1,7b20…e4
|
||||||
@@ -21,7 +28,8 @@ Générer une clé :
|
|||||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
```
|
```
|
||||||
|
|
||||||
Variable vide ou absente : l'API répond `503` (désactivée).
|
Aucune clé active (ni dans Paramètres, ni dans l'environnement) : l'API répond
|
||||||
|
`503` (désactivée).
|
||||||
|
|
||||||
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
Chaque requête envoie la clé dans l'un de ces en-têtes :
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,18 @@ CREATE TABLE IF NOT EXISTS "supplier_mail_logs" (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||||
|
|
||||||
|
-- Clés de l'API externe de rapprochement (empreinte SHA-256, jamais la clé en clair)
|
||||||
|
CREATE TABLE IF NOT EXISTS "external_api_keys" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"key_prefix" varchar(16) NOT NULL,
|
||||||
|
"key_hash" varchar(64) NOT NULL UNIQUE,
|
||||||
|
"created_by" varchar,
|
||||||
|
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"last_used_at" timestamp,
|
||||||
|
"revoked_at" timestamp
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- FOREIGN KEY CONSTRAINTS
|
-- FOREIGN KEY CONSTRAINTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
+74
-12
@@ -6,8 +6,10 @@
|
|||||||
// compris déjà validée, pour les fournisseurs en rapprochement automatique).
|
// compris déjà validée, pour les fournisseurs en rapprochement automatique).
|
||||||
//
|
//
|
||||||
// Authentification par clé d'API, indépendante des sessions du webUI :
|
// Authentification par clé d'API, indépendante des sessions du webUI :
|
||||||
// - variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
// - clés créées par un admin dans Paramètres > API externe (server/externalApiKeys.ts) ;
|
||||||
// séparées par des virgules) ; vide => API désactivée ;
|
// - et/ou variable d'environnement EXTERNAL_API_KEYS (une ou plusieurs clés
|
||||||
|
// séparées par des virgules) ;
|
||||||
|
// - aucune clé active => API désactivée ;
|
||||||
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
// - clé transmise dans l'en-tête "X-API-Key" ou "Authorization: Bearer <clé>".
|
||||||
//
|
//
|
||||||
// Documentation : docs/API-RAPPROCHEMENT.md
|
// Documentation : docs/API-RAPPROCHEMENT.md
|
||||||
@@ -18,6 +20,14 @@ import { z } from "zod";
|
|||||||
import { storage } from "./storage";
|
import { storage } from "./storage";
|
||||||
import { normalizeDateString } from "./dateUtils";
|
import { normalizeDateString } from "./dateUtils";
|
||||||
import { invoiceVerificationService } from "./invoiceVerification";
|
import { invoiceVerificationService } from "./invoiceVerification";
|
||||||
|
import {
|
||||||
|
countActiveApiKeys,
|
||||||
|
countEnvApiKeys,
|
||||||
|
createApiKey,
|
||||||
|
listApiKeys,
|
||||||
|
revokeApiKey,
|
||||||
|
verifyStoredApiKey,
|
||||||
|
} from "./externalApiKeys";
|
||||||
|
|
||||||
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
export const EXTERNAL_API_PREFIX = "/api/ext/v1";
|
||||||
|
|
||||||
@@ -50,22 +60,27 @@ function extractKey(req: Request): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
async function requireApiKey(req: Request, res: Response, next: NextFunction) {
|
||||||
const keys = configuredKeyDigests();
|
try {
|
||||||
if (keys.length === 0) {
|
const provided = extractKey(req);
|
||||||
return res.status(503).json({ error: "API externe désactivée (EXTERNAL_API_KEYS non défini)" });
|
if (provided) {
|
||||||
|
// Clés de l'environnement : comparaison à temps constant sur les empreintes
|
||||||
|
const providedDigest = digest(provided);
|
||||||
|
if (configuredKeyDigests().some((k) => timingSafeEqual(k, providedDigest))) return next();
|
||||||
|
// Clés créées dans Paramètres (recherche par empreinte)
|
||||||
|
if (await verifyStoredApiKey(provided)) return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const provided = extractKey(req);
|
if (countEnvApiKeys() === 0 && (await countActiveApiKeys()) === 0) {
|
||||||
// Comparaison à temps constant sur les empreintes (longueur fixe)
|
return res.status(503).json({ error: "API externe désactivée : aucune clé d'API active (Paramètres > API externe)" });
|
||||||
const providedDigest = provided ? digest(provided) : null;
|
}
|
||||||
const valid = providedDigest !== null && keys.some((k) => timingSafeEqual(k, providedDigest));
|
|
||||||
|
|
||||||
if (!valid) {
|
|
||||||
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
console.warn(`🚨 [EXT-API] Clé d'API invalide ou absente : ${req.method} ${req.path} depuis ${req.ip}`);
|
||||||
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
return res.status(401).json({ error: "Clé d'API invalide ou absente" });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [EXT-API] vérification de la clé:", error);
|
||||||
|
return res.status(500).json({ error: "Erreur lors de la vérification de la clé d'API" });
|
||||||
}
|
}
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
// Montant : nombre, ou chaîne au format "1234.56", "1234,56" ou "1 234,56"
|
||||||
@@ -351,3 +366,50 @@ export function registerExternalApi(app: Express) {
|
|||||||
res.status(404).json({ error: "Route inconnue" });
|
res.status(404).json({ error: "Route inconnue" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gestion des clés depuis Paramètres > API externe : session webUI, admin
|
||||||
|
// uniquement. À enregistrer après la mise en place de l'authentification.
|
||||||
|
export function registerExternalApiKeyAdminRoutes(app: Express, requireSessionAdmin: Array<(req: any, res: any, next: any) => void>) {
|
||||||
|
const createKeySchema = z.object({ name: z.string().trim().min(1, "Nom requis").max(100) });
|
||||||
|
|
||||||
|
app.get("/api/external-api/keys", ...requireSessionAdmin, async (_req, res) => {
|
||||||
|
try {
|
||||||
|
res.json({
|
||||||
|
basePath: EXTERNAL_API_PREFIX,
|
||||||
|
envKeyCount: countEnvApiKeys(),
|
||||||
|
keys: await listApiKeys(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [EXT-API] list keys:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la lecture des clés d'API" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/external-api/keys", ...requireSessionAdmin, async (req: any, res) => {
|
||||||
|
const parsed = createKeySchema.safeParse(req.body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.issues[0]?.message || "Nom invalide" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await createApiKey(parsed.data.name, req.user?.id ?? null);
|
||||||
|
console.log(`🔑 [EXT-API] Clé « ${parsed.data.name} » créée par ${req.user?.username ?? "?"}`);
|
||||||
|
res.status(201).json(created);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [EXT-API] create key:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la création de la clé d'API" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/api/external-api/keys/:id", ...requireSessionAdmin, async (req: any, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: "Identifiant invalide" });
|
||||||
|
try {
|
||||||
|
if (!(await revokeApiKey(id))) return res.status(404).json({ error: "Clé introuvable ou déjà révoquée" });
|
||||||
|
console.log(`🔑 [EXT-API] Clé #${id} révoquée par ${req.user?.username ?? "?"}`);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [EXT-API] revoke key:", error);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la révocation de la clé d'API" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Clés de l'API externe de rapprochement créées depuis Paramètres > API externe.
|
||||||
|
//
|
||||||
|
// La clé n'est montrée qu'une fois, à sa création : seule son empreinte SHA-256
|
||||||
|
// est stockée (table external_api_keys), avec un préfixe lisible pour la
|
||||||
|
// reconnaître dans la liste. Une clé révoquée est conservée pour l'historique.
|
||||||
|
// Les clés de la variable d'environnement EXTERNAL_API_KEYS restent acceptées.
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from "crypto";
|
||||||
|
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { externalApiKeys, type ExternalApiKey } from "@shared/schema";
|
||||||
|
|
||||||
|
const KEY_PREFIX = "lf_";
|
||||||
|
const DISPLAY_PREFIX_LENGTH = 10; // "lf_" + 7 caractères
|
||||||
|
// last_used_at n'est réécrit qu'au plus une fois par minute et par clé
|
||||||
|
const LAST_USED_THROTTLE_MS = 60_000;
|
||||||
|
|
||||||
|
export type PublicApiKey = Omit<ExternalApiKey, "keyHash">;
|
||||||
|
|
||||||
|
// Sans base (développement), les clés sont gardées en mémoire
|
||||||
|
const hasDatabase = !!process.env.DATABASE_URL;
|
||||||
|
const memoryKeys: ExternalApiKey[] = [];
|
||||||
|
let memoryNextId = 1;
|
||||||
|
|
||||||
|
export function hashApiKey(key: string): string {
|
||||||
|
return createHash("sha256").update(key).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublic({ keyHash: _keyHash, ...key }: ExternalApiKey): PublicApiKey {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listApiKeys(): Promise<PublicApiKey[]> {
|
||||||
|
if (!hasDatabase) return [...memoryKeys].reverse().map(toPublic);
|
||||||
|
const rows: ExternalApiKey[] = await db.select().from(externalApiKeys).orderBy(desc(externalApiKeys.createdAt));
|
||||||
|
return rows.map(toPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crée une clé et la renvoie en clair : c'est la seule fois où elle est lisible
|
||||||
|
export async function createApiKey(name: string, createdBy: string | null): Promise<{ key: string; apiKey: PublicApiKey }> {
|
||||||
|
const key = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
|
||||||
|
const values = {
|
||||||
|
name,
|
||||||
|
keyPrefix: key.slice(0, DISPLAY_PREFIX_LENGTH),
|
||||||
|
keyHash: hashApiKey(key),
|
||||||
|
createdBy,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasDatabase) {
|
||||||
|
const row: ExternalApiKey = { id: memoryNextId++, ...values, createdAt: new Date(), lastUsedAt: null, revokedAt: null };
|
||||||
|
memoryKeys.push(row);
|
||||||
|
return { key, apiKey: toPublic(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row]: ExternalApiKey[] = await db.insert(externalApiKeys).values(values).returning();
|
||||||
|
return { key, apiKey: toPublic(row) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Révoque une clé active ; false si elle n'existe pas ou l'était déjà
|
||||||
|
export async function revokeApiKey(id: number): Promise<boolean> {
|
||||||
|
if (!hasDatabase) {
|
||||||
|
const row = memoryKeys.find((k) => k.id === id && !k.revokedAt);
|
||||||
|
if (!row) return false;
|
||||||
|
row.revokedAt = new Date();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.update(externalApiKeys)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(and(eq(externalApiKeys.id, id), isNull(externalApiKeys.revokedAt)))
|
||||||
|
.returning({ id: externalApiKeys.id });
|
||||||
|
return rows.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countActiveApiKeys(): Promise<number> {
|
||||||
|
if (!hasDatabase) return memoryKeys.filter((k) => !k.revokedAt).length;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(externalApiKeys)
|
||||||
|
.where(isNull(externalApiKeys.revokedAt));
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifie une clé reçue ; met à jour sa date de dernière utilisation
|
||||||
|
export async function verifyStoredApiKey(key: string): Promise<boolean> {
|
||||||
|
const keyHash = hashApiKey(key);
|
||||||
|
|
||||||
|
if (!hasDatabase) {
|
||||||
|
const row = memoryKeys.find((k) => k.keyHash === keyHash && !k.revokedAt);
|
||||||
|
if (row) row.lastUsedAt = new Date();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row]: ExternalApiKey[] = await db
|
||||||
|
.select()
|
||||||
|
.from(externalApiKeys)
|
||||||
|
.where(and(eq(externalApiKeys.keyHash, keyHash), isNull(externalApiKeys.revokedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!row) return false;
|
||||||
|
|
||||||
|
const lastUsed = row.lastUsedAt ? new Date(row.lastUsedAt).getTime() : 0;
|
||||||
|
if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
|
||||||
|
// Sans attendre : une erreur d'écriture ne doit pas refuser la requête
|
||||||
|
db.update(externalApiKeys)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(externalApiKeys.id, row.id))
|
||||||
|
.catch((error: any) => console.error("❌ [EXT-API] last_used_at:", error?.message));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre de clés fournies par la variable d'environnement EXTERNAL_API_KEYS
|
||||||
|
export function countEnvApiKeys(): number {
|
||||||
|
return (process.env.EXTERNAL_API_KEYS || "").split(",").filter((k) => k.trim().length > 0).length;
|
||||||
|
}
|
||||||
@@ -378,6 +378,22 @@ export async function runProductionMigrations() {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ MIGRATION: supplier_mail_logs table is present');
|
console.log('✅ MIGRATION: supplier_mail_logs table is present');
|
||||||
|
|
||||||
|
// Clés de l'API externe de rapprochement, gérées depuis Paramètres
|
||||||
|
console.log('🔄 MIGRATION: Ensuring external_api_keys table...');
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
created_by VARCHAR,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ MIGRATION: external_api_keys table is present');
|
||||||
|
|
||||||
// Chiffrement au repos des secrets encore stockés en clair
|
// Chiffrement au repos des secrets encore stockés en clair
|
||||||
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
|
// (mots de passe SMTP des magasins, jetons API NocoDB). Idempotent :
|
||||||
// les valeurs déjà au format enc:v1: sont ignorées.
|
// les valeurs déjà au format enc:v1: sont ignorées.
|
||||||
|
|||||||
@@ -106,6 +106,21 @@ WHERE NOT EXISTS (SELECT 1 FROM webhook_bap_config);`
|
|||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_delivery ON supplier_mail_logs(delivery_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
CREATE INDEX IF NOT EXISTS idx_supplier_mail_logs_group ON supplier_mail_logs(group_id);
|
||||||
`
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
filename: '20261010000000_create_external_api_keys.sql',
|
||||||
|
content: `
|
||||||
|
CREATE TABLE IF NOT EXISTS external_api_keys (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
key_prefix VARCHAR(16) NOT NULL,
|
||||||
|
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
created_by VARCHAR,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TIMESTAMP,
|
||||||
|
revoked_at TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -5,7 +5,7 @@ import { setupLocalAuth, requireAuth } from "./localAuth";
|
|||||||
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
import { requireModulePermission, requireAdmin, requirePermission } from "./permissions";
|
||||||
import { stripSmtpPassword } from "./sanitize";
|
import { stripSmtpPassword } from "./sanitize";
|
||||||
import { normalizeDateString } from "./dateUtils";
|
import { normalizeDateString } from "./dateUtils";
|
||||||
import { registerExternalApi } from "./externalApi";
|
import { registerExternalApi, registerExternalApiKeyAdminRoutes } from "./externalApi";
|
||||||
|
|
||||||
// Corps de requête sans les champs secrets : pour les logs uniquement
|
// Corps de requête sans les champs secrets : pour les logs uniquement
|
||||||
function redactBody(body: any): any {
|
function redactBody(body: any): any {
|
||||||
@@ -126,6 +126,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
// Auth middleware
|
// Auth middleware
|
||||||
await setupAuth(app);
|
await setupAuth(app);
|
||||||
|
|
||||||
|
// Clés de l'API externe (Paramètres > API externe), admin uniquement
|
||||||
|
registerExternalApiKeyAdminRoutes(app, [isAuthenticated, requireAdmin]);
|
||||||
|
|
||||||
// Routes pour configuration webhook BAP
|
// Routes pour configuration webhook BAP
|
||||||
app.get('/api/webhook-bap-config', isAuthenticated, async (req: any, res) => {
|
app.get('/api/webhook-bap-config', isAuthenticated, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1023,6 +1023,21 @@ export const insertWebhookBapConfigSchema = createInsertSchema(webhookBapConfig)
|
|||||||
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
|
export type WebhookBapConfig = typeof webhookBapConfig.$inferSelect;
|
||||||
export type InsertWebhookBapConfig = z.infer<typeof insertWebhookBapConfigSchema>;
|
export type InsertWebhookBapConfig = z.infer<typeof insertWebhookBapConfigSchema>;
|
||||||
|
|
||||||
|
// Clés de l'API externe de rapprochement (docs/API-RAPPROCHEMENT.md), créées
|
||||||
|
// depuis Paramètres. Seule l'empreinte SHA-256 de la clé est stockée.
|
||||||
|
export const externalApiKeys = pgTable("external_api_keys", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
keyPrefix: varchar("key_prefix", { length: 16 }).notNull(), // début de la clé, pour la reconnaître
|
||||||
|
keyHash: varchar("key_hash", { length: 64 }).notNull().unique(),
|
||||||
|
createdBy: varchar("created_by"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
lastUsedAt: timestamp("last_used_at"),
|
||||||
|
revokedAt: timestamp("revoked_at"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ExternalApiKey = typeof externalApiKeys.$inferSelect;
|
||||||
|
|
||||||
// Supplier mail logs
|
// Supplier mail logs
|
||||||
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
|
export const insertSupplierMailLogSchema = createInsertSchema(supplierMailLogs).omit({
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
Reference in new issue
Block a user