mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-14 18:58:17 +02:00
feat(ui): lot 3 — toutes les pages lisibles, cohérentes et en français clair
Chaque page reprend le socle du lot 2 : en-tête avec une phrase d'explication, couleurs du thème (plus de pages figées en blanc), textes de 12 px minimum, noms de magasins, infobulles sur le jargon, retours par toast / confirmation, états vides et d'erreur expliqués. - Accueil : CA, tickets et panier moyen d'hier vs N-1, par magasin, top 10 avec lien vers la fiche produit. - Révision d'assortiment : un seul endroit pour enregistrer et exporter (menu Export unique, mêmes droits pour tous), annulation confirmée, sens des gammes affiché partout, magasin choisi en un clic, colonnes nommées en clair, bandeau de progression au lieu de la fenêtre floue, droits lus côté serveur, ligne sélectionnée opaque sur les colonnes figées. - Recherche produit, Stocks à surveiller (export FF inchangé), Meilleures ventes, Ventes par mois (dernier mois complet par défaut), Publicités, Commandes fournisseurs, Historique, Paramètres (onglets), Synchronisation et Connexion migrés sur DataTable / Tabs / Card. - Le nom de l'utilisateur est transmis à la session (affiché dans l'en-tête). - Composants devenus inutiles supprimés (anciennes fenêtres modales, export-dropdown, financial-cell, supplier-selection). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn
This commit is contained in:
61 files changed
+6101
-6864
No files matched your search
@@ -8,31 +8,42 @@
|
||||
* déduite de l'origine courante, pour rester juste quel que soit le déploiement.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Check, ExternalLink, Bot } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Copy, Check, ExternalLink, Bot, ChevronRight } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/components/ui/feedback";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copier = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.erreur("La copie automatique a échoué : sélectionnez le texte et copiez-le à la main.");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }}
|
||||
className="shrink-0 rounded-lg px-2 py-1.5"
|
||||
style={{ background: "var(--bg-surface)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={copier}
|
||||
aria-label="Copier"
|
||||
title="Copier"
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
{copied ? <Check className="text-[var(--accent-success)]" /> : <Copy />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeLine({ children, copy }: { children: string; copy?: string }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<code
|
||||
className="flex-1 rounded-lg px-2.5 py-1.5 text-[11px] font-mono break-all whitespace-pre-wrap"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
<code className="flex-1 whitespace-pre-wrap break-all rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] px-3 py-1.5 font-mono text-[13px] text-[var(--text-primary)]">
|
||||
{children}
|
||||
</code>
|
||||
<CopyButton text={copy ?? children} />
|
||||
@@ -40,25 +51,47 @@ function CodeLine({ children, copy }: { children: string; copy?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Bloc({ titre, children }: { titre: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{titre}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Référence technique repliée : utile aux développeurs, superflue pour les autres. */
|
||||
function Repli({ titre, children }: { titre: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<details className="group rounded-lg border border-[var(--border)]">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-2.5 text-sm font-semibold text-[var(--text-primary)] hover:bg-[var(--bg-elevated)] [&::-webkit-details-marker]:hidden">
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--text-muted)] transition-transform group-open:rotate-90" aria-hidden />
|
||||
{titre}
|
||||
</summary>
|
||||
<div className="space-y-2 border-t border-[var(--border)] p-3">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
const ENDPOINTS: Array<{ method: string; path: string; desc: string }> = [
|
||||
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la fraîcheur de leur instantané" },
|
||||
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de grille d'un fournisseur" },
|
||||
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nb d'articles et CA" },
|
||||
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche transversale, tous fournisseurs" },
|
||||
{ method: "GET", path: "/fournisseurs", desc: "Fournisseurs, avec la date de calcul de leur grille" },
|
||||
{ method: "GET", path: "/grid?fournisseur=CODE", desc: "Lignes de la grille d'un fournisseur" },
|
||||
{ method: "GET", path: "/nomenclatures?fournisseur=CODE", desc: "Postes de nomenclature d'un fournisseur, avec nombre d'articles et chiffre d'affaires" },
|
||||
{ method: "GET", path: "/products/search?q=terme", desc: "Recherche de produits, tous fournisseurs confondus" },
|
||||
{ method: "GET", path: "/products/{codein}", desc: "Fiche complète d'un produit" },
|
||||
{ method: "GET", path: "/network/{codeCentrale}", desc: "Métriques réseau Qlik + courbe 12 mois" },
|
||||
{ method: "GET", path: "/openapi.json", desc: "Spécification lisible par machine" },
|
||||
{ method: "GET", path: "/network/{codeCentrale}", desc: "Ventes du réseau (Qlik) et courbe sur 12 mois" },
|
||||
{ method: "GET", path: "/openapi.json", desc: "Description de l'API lisible par un programme (format OpenAPI)" },
|
||||
];
|
||||
|
||||
const PARAMS: Array<{ name: string; desc: string }> = [
|
||||
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100" },
|
||||
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc" },
|
||||
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale" },
|
||||
{ name: "nomenclature", desc: "Préfixe de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)" },
|
||||
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature" },
|
||||
{ name: "fields", desc: "Champs à conserver, séparés par des virgules — allège fortement la réponse" },
|
||||
{ name: "enrich", desc: "1 par défaut : métriques Qlik + gamme serveur relues à l'appel. 0 pour s'en dispenser" },
|
||||
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer vite" },
|
||||
{ name: "page, limit", desc: "Pagination, sans plafond. Sur /grid, omettre limit renvoie TOUT le fournisseur en un appel ; ailleurs le défaut est 100." },
|
||||
{ name: "sort, order", desc: "Tri, ex. sort=totalCa&order=desc." },
|
||||
{ name: "search", desc: "Libellé, codein, GTIN, référence ou code centrale." },
|
||||
{ name: "nomenclature", desc: "Début du code de nomenclature : 32 (univers), 3202 (famille), 320211 (sous-famille)." },
|
||||
{ name: "gamme, code1..code3", desc: "Filtres sur la gamme et les codes exacts de nomenclature." },
|
||||
{ name: "fields", desc: "Champs à conserver, séparés par des virgules : allège fortement la réponse." },
|
||||
{ name: "enrich", desc: "1 par défaut : ventes du réseau (Qlik) et gamme enregistrée relues à chaque appel. 0 pour s'en dispenser." },
|
||||
{ name: "compute", desc: "1 par défaut : calcule le fournisseur s'il n'a jamais été ouvert (premier appel plus lent). 0 pour échouer tout de suite." },
|
||||
];
|
||||
|
||||
export function ApiConnectionInfo() {
|
||||
@@ -70,128 +103,150 @@ export function ApiConnectionInfo() {
|
||||
const base = `${origin || "https://votre-domaine"}/api/v1`;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Connexion */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>URL de base</p>
|
||||
<CodeLine>{base}</CodeLine>
|
||||
<p className="text-[12px] font-semibold pt-1" style={{ color: "var(--text-primary)" }}>Authentification</p>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Une clé créée ci-dessous, dans l'un ou l'autre de ces en-têtes. Depuis un navigateur
|
||||
connecté, la session suffit — aucune clé n'est nécessaire.
|
||||
</p>
|
||||
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
|
||||
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Se connecter à l'API"
|
||||
description="L'API permet à un script ou à un outil externe de lire les données de CollectFlow : grilles des fournisseurs, recherche de produits, ventes du réseau. Elle ne permet aucune modification."
|
||||
/>
|
||||
<CardContent className="space-y-6">
|
||||
<Bloc titre="Adresse de base">
|
||||
<CodeLine>{base}</CodeLine>
|
||||
</Bloc>
|
||||
|
||||
{/* Endpoints */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Endpoints</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{ENDPOINTS.map((e, i) => (
|
||||
<tr key={e.path} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 font-mono font-bold align-top" style={{ color: "var(--accent)", width: 40 }}>{e.method}</td>
|
||||
<td className="px-2.5 py-1.5 font-mono align-top" style={{ color: "var(--text-primary)" }}>{e.path}</td>
|
||||
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{e.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<Bloc titre="Identification">
|
||||
<p className="text-[13px] text-[var(--text-secondary)]">
|
||||
Chaque appel doit porter une clé (à créer dans le cadre « Clés d'accès à l'API » ci-dessous),
|
||||
dans l'un ou l'autre de ces en-têtes. Depuis un navigateur déjà connecté à CollectFlow,
|
||||
aucune clé n'est nécessaire.
|
||||
</p>
|
||||
<CodeLine>X-API-Key: VOTRE_CLE</CodeLine>
|
||||
<CodeLine>Authorization: Bearer VOTRE_CLE</CodeLine>
|
||||
</Bloc>
|
||||
|
||||
{/* Paramètres */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Paramètres communs</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[11px]">
|
||||
<tbody>
|
||||
{PARAMS.map((p, i) => (
|
||||
<tr key={p.name} style={{ borderTop: i === 0 ? undefined : "1px solid var(--border)" }}>
|
||||
<td className="px-2.5 py-1.5 font-mono align-top whitespace-nowrap" style={{ color: "var(--text-primary)" }}>{p.name}</td>
|
||||
<td className="px-2.5 py-1.5 align-top" style={{ color: "var(--text-muted)" }}>{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exemples */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>Exemples</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
||||
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Tout un fournisseur en un seul appel — il suffit d'omettre <code className="font-mono">limit</code>,
|
||||
et <code className="font-mono">meta.complet</code> confirme qu'il ne reste rien à lire :
|
||||
</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
|
||||
<p className="text-[11px] pt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Gros fournisseur (plusieurs dizaines de milliers d'articles) : lister d'abord les
|
||||
postes de nomenclature, puis les traiter un par un.
|
||||
</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
|
||||
</div>
|
||||
|
||||
{/* Branchement d'une IA externe (ChatGPT) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] font-semibold flex items-center gap-1.5" style={{ color: "var(--text-primary)" }}>
|
||||
<Bot className="w-4 h-4" style={{ color: "var(--accent)" }} />
|
||||
Connecter une IA externe (ChatGPT)
|
||||
</p>
|
||||
<ol className="text-[11px] space-y-1.5 list-decimal pl-4" style={{ color: "var(--text-secondary)" }}>
|
||||
<li>Créez une clé d'API dans la section ci-dessous et copiez-la.</li>
|
||||
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
|
||||
<li>
|
||||
Cliquez sur <strong>Importer depuis une URL</strong> et collez l'adresse du schéma :
|
||||
<div className="mt-1"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
|
||||
<span style={{ color: "var(--text-muted)" }}>
|
||||
Ce schéma est public (il ne contient aucune donnée) pour que ChatGPT puisse l'importer.
|
||||
Votre application doit être joignable depuis Internet.
|
||||
{/* Branchement d'une IA externe (ChatGPT) */}
|
||||
<Bloc
|
||||
titre={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-[var(--accent)]" aria-hidden />
|
||||
Connecter une IA externe (ChatGPT)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Dans <strong>Authentification</strong>, choisissez <strong>Clé d'API</strong>, type{" "}
|
||||
<strong>Personnalisé</strong>, nom d'en-tête <code className="font-mono">X-API-Key</code>,
|
||||
et collez votre clé.
|
||||
</li>
|
||||
<li>Testez avec une question du type « cherche les produits tapis » — le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
|
||||
</ol>
|
||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
Si votre domaine public diffère de celui affiché ici, renseignez la variable
|
||||
d'environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l'URL
|
||||
déclarée dans le schéma.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ol className="list-decimal space-y-2 pl-5 text-[13px] text-[var(--text-secondary)]">
|
||||
<li>Créez une clé dans le cadre « Clés d'accès à l'API » ci-dessous et copiez-la.</li>
|
||||
<li>Dans ChatGPT : <strong>Créer un GPT</strong> → onglet <strong>Configurer</strong> → <strong>Créer une action</strong>.</li>
|
||||
<li>
|
||||
Cliquez sur <strong>Importer depuis une URL</strong> et collez l'adresse de la description de l'API :
|
||||
<div className="mt-1.5"><CodeLine>{`${base}/openapi.json`}</CodeLine></div>
|
||||
<span className="mt-1 block text-[var(--text-muted)]">
|
||||
Cette description est publique (elle ne contient aucune donnée) pour que ChatGPT puisse la lire.
|
||||
L'application doit être joignable depuis Internet.
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
Dans <strong>Authentification</strong>, choisissez <strong>Clé d'API</strong>, type{" "}
|
||||
<strong>Personnalisé</strong>, nom d'en-tête <code className="font-mono">X-API-Key</code>,
|
||||
et collez votre clé.
|
||||
</li>
|
||||
<li>Testez avec une question du type « cherche les produits tapis » : le GPT appellera <code className="font-mono">rechercherProduits</code>.</li>
|
||||
</ol>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
Si l'adresse publique de l'application diffère de celle affichée ici, renseignez la variable
|
||||
d'environnement <code className="font-mono">COLLECTFLOW_PUBLIC_URL</code> : elle fixe l'adresse
|
||||
annoncée dans la description.
|
||||
</p>
|
||||
</Bloc>
|
||||
|
||||
{/* Comportement à connaître */}
|
||||
<div className="rounded-xl p-3 text-[11px] space-y-1.5"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}>
|
||||
<p><strong>Aucun recalcul.</strong> L'API lit l'instantané de la grille, écrit quand la Grille
|
||||
est ouverte dans l'application. Un fournisseur jamais consulté renvoie <code className="font-mono">202</code>{" "}
|
||||
<code className="font-mono">not_ready</code> plutôt que de faire attendre.</p>
|
||||
<p><strong>Métriques Qlik</strong> (<code className="font-mono">network</code>) et{" "}
|
||||
<strong>gamme serveur non modifiée</strong> (<code className="font-mono">codeGammeServeur</code>) sont
|
||||
relues à chaque appel. <code className="font-mono">network</code> vaut{" "}
|
||||
<code className="font-mono">null</code> quand le produit n'a pas de données réseau.</p>
|
||||
<p>Erreurs : <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec{" "}
|
||||
<code className="font-mono">401</code> (clé absente/invalide/révoquée),{" "}
|
||||
<code className="font-mono">400</code>, <code className="font-mono">404</code>.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Repli titre="Adresses disponibles (endpoints)">
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left">Méthode</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Adresse</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Contenu</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ENDPOINTS.map((e) => (
|
||||
<tr key={e.path} className="border-t border-[var(--border)] align-top">
|
||||
<td className="px-3 py-2 font-mono text-sm font-semibold text-[var(--accent)]">{e.method}</td>
|
||||
<td className="px-3 py-2 font-mono text-sm text-[var(--text-primary)]">{e.path}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{e.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Repli>
|
||||
|
||||
<a
|
||||
href="/api/v1/openapi.json"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] font-medium"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Ouvrir la spécification OpenAPI <ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<Repli titre="Paramètres communs">
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left">Paramètre</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Effet</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PARAMS.map((p) => (
|
||||
<tr key={p.name} className="border-t border-[var(--border)] align-top">
|
||||
<td className="whitespace-nowrap px-3 py-2 font-mono text-sm text-[var(--text-primary)]">{p.name}</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{p.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Repli>
|
||||
|
||||
<Repli titre="Exemples d'appels">
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/products/search?q=tapis&limit=20"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001&fields=codein,libelle1,totalCa,codeGammeServeur"`}</CodeLine>
|
||||
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
|
||||
Tout un fournisseur en un seul appel : il suffit d'omettre <code className="font-mono">limit</code>,
|
||||
et <code className="font-mono">meta.complet</code> confirme qu'il ne reste rien à lire.
|
||||
</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=FOU001"`}</CodeLine>
|
||||
<p className="pt-1 text-[13px] text-[var(--text-secondary)]">
|
||||
Gros fournisseur (plusieurs dizaines de milliers d'articles) : lister d'abord les
|
||||
postes de nomenclature, puis les traiter un par un.
|
||||
</p>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/nomenclatures?fournisseur=D005&niveau=1"`}</CodeLine>
|
||||
<CodeLine>{`curl -H "X-API-Key: VOTRE_CLE" \\\n "${base}/grid?fournisseur=D005&nomenclature=32"`}</CodeLine>
|
||||
</Repli>
|
||||
</div>
|
||||
|
||||
{/* Comportement à connaître */}
|
||||
<div className="space-y-2 rounded-lg border border-[var(--border)] bg-[var(--bg-elevated)] p-4 text-[13px] text-[var(--text-secondary)]">
|
||||
<p className="font-semibold text-[var(--text-primary)]">Bon à savoir</p>
|
||||
<p>
|
||||
<strong>L'API lit des grilles déjà calculées</strong>, enregistrées quand la Grille est ouverte dans
|
||||
l'application ou par le préchauffage ci-dessous. Un fournisseur jamais calculé l'est au premier
|
||||
appel, qui est alors plus lent ; avec <code className="font-mono">compute=0</code>, l'API répond
|
||||
aussitôt <code className="font-mono">202 not_ready</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Les ventes du réseau</strong> (<code className="font-mono">network</code>) et <strong>la gamme
|
||||
enregistrée</strong> (<code className="font-mono">codeGammeServeur</code>) sont relues à chaque appel.{" "}
|
||||
<code className="font-mono">network</code> vaut <code className="font-mono">null</code> quand le produit
|
||||
n'a pas de données réseau.
|
||||
</p>
|
||||
<p>
|
||||
En cas d'erreur, la réponse a la forme <code className="font-mono">{`{ "error": { "code", "message" } }`}</code> avec
|
||||
le code <code className="font-mono">401</code> (clé absente, invalide ou révoquée), <code className="font-mono">400</code>{" "}
|
||||
(paramètre incorrect) ou <code className="font-mono">404</code> (introuvable).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<a href="/api/v1/openapi.json" target="_blank" rel="noreferrer">
|
||||
Ouvrir la description complète de l'API (OpenAPI) <ExternalLink />
|
||||
</a>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,33 +7,55 @@
|
||||
* création, puis irrécupérable (seul son hachage est stocké côté base).
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, type FormEvent } from "react";
|
||||
import { KeyRound, Plus, Loader2, Copy, Check, Ban, Trash2, AlertTriangle } from "lucide-react";
|
||||
import { getApiKeys, createApiKey, revokeApiKey, deleteApiKey, type ApiKeyRow } from "../api/api-key-actions";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input, Label, Select } from "@/components/ui/form-controls";
|
||||
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
|
||||
import { confirmer, toast } from "@/components/ui/feedback";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
|
||||
type Role = "admin" | "user";
|
||||
|
||||
const ROLES = [
|
||||
{ value: "user", label: "Utilisateur" },
|
||||
{ value: "admin", label: "Administrateur" },
|
||||
] as const;
|
||||
|
||||
const LIBELLE_ROLE: Record<string, string> = { admin: "Administrateur", user: "Utilisateur" };
|
||||
|
||||
function fmtDate(iso: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" });
|
||||
}
|
||||
|
||||
function messageErreur(e: unknown): string {
|
||||
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
export function ApiKeyManagement() {
|
||||
const [keys, setKeys] = useState<ApiKeyRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [erreurChargement, setErreurChargement] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState<"admin" | "user">("user");
|
||||
const [role, setRole] = useState<Role>("user");
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [enCours, setEnCours] = useState<number | null>(null);
|
||||
|
||||
// Pas de setState synchrone ici : `loading` démarre déjà à true, et le premier
|
||||
// statement est un await. Cela évite une cascade de rendus au montage.
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setKeys(await getApiKeys());
|
||||
setError(null);
|
||||
setErreurChargement(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Erreur de chargement");
|
||||
setErreurChargement(messageErreur(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -41,177 +63,233 @@ export function ApiKeyManagement() {
|
||||
|
||||
// Chargement initial depuis la base — la donnée vient d'un système externe, pas
|
||||
// d'un état dérivé. `refresh` n'appelle setState qu'après un await.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
const reessayer = () => {
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
};
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || creating) return;
|
||||
const nom = name.trim();
|
||||
if (!nom || creating) return;
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
const res = await createApiKey(name.trim(), role);
|
||||
if (res.success) {
|
||||
setFreshKey(res.key);
|
||||
setName("");
|
||||
setCopied(false);
|
||||
await refresh();
|
||||
} else {
|
||||
setError(res.error);
|
||||
try {
|
||||
const res = await createApiKey(nom, role);
|
||||
if (res.success) {
|
||||
setFreshKey(res.key);
|
||||
setName("");
|
||||
setCopied(false);
|
||||
toast.succes(`Clé « ${nom} » créée. Copiez-la maintenant : elle ne sera plus affichée.`);
|
||||
await refresh();
|
||||
} else {
|
||||
toast.erreur(res.error);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.erreur(`La clé n'a pas pu être créée : ${messageErreur(err)}`);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
await revokeApiKey(id);
|
||||
await refresh();
|
||||
const copier = async () => {
|
||||
if (!freshKey) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(freshKey);
|
||||
setCopied(true);
|
||||
toast.succes("Clé copiée dans le presse-papiers.");
|
||||
} catch {
|
||||
toast.erreur("La copie automatique a échoué : sélectionnez la clé et copiez-la à la main.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
await deleteApiKey(id);
|
||||
await refresh();
|
||||
const handleRevoke = async (k: ApiKeyRow) => {
|
||||
const ok = await confirmer({
|
||||
titre: `Révoquer la clé « ${k.name} » ?`,
|
||||
message: "Les scripts et outils qui l'utilisent seront refusés dès leur prochain appel. La clé reste dans la liste pour garder la trace de son utilisation.",
|
||||
libelleConfirmer: "Révoquer",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setEnCours(k.id);
|
||||
try {
|
||||
const res = await revokeApiKey(k.id);
|
||||
if (res.success) toast.succes(`La clé « ${k.name} » est révoquée.`);
|
||||
else toast.erreur(res.error ?? "La clé n'a pas pu être révoquée.");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.erreur(`La clé n'a pas pu être révoquée : ${messageErreur(err)}`);
|
||||
} finally {
|
||||
setEnCours(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (k: ApiKeyRow) => {
|
||||
const ok = await confirmer({
|
||||
titre: `Supprimer définitivement la clé « ${k.name} » ?`,
|
||||
message: "Elle disparaîtra de la liste, avec la trace de sa dernière utilisation.",
|
||||
libelleConfirmer: "Supprimer",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setEnCours(k.id);
|
||||
try {
|
||||
const res = await deleteApiKey(k.id);
|
||||
if (res.success) toast.succes(`La clé « ${k.name} » a été supprimée.`);
|
||||
else toast.erreur(res.error ?? "La clé n'a pas pu être supprimée.");
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
toast.erreur(`La clé n'a pas pu être supprimée : ${messageErreur(err)}`);
|
||||
} finally {
|
||||
setEnCours(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[12px] text-[var(--text-secondary)]">
|
||||
Les clés donnent accès à l'API de lecture <code className="font-mono">/api/v1</code> (grille,
|
||||
recherche, métriques réseau) depuis un script ou un outil externe, via l'en-tête{" "}
|
||||
<code className="font-mono">X-API-Key</code>. Depuis un navigateur connecté, la session suffit.
|
||||
</p>
|
||||
|
||||
{/* Clé fraîchement créée — visible une seule fois */}
|
||||
{freshKey && (
|
||||
<div className="rounded-xl p-3.5 space-y-2"
|
||||
style={{ background: "var(--accent-bg)", border: "1px solid var(--accent-border)" }}>
|
||||
<div className="flex items-start gap-2 text-[12px] font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" style={{ color: "var(--accent)" }} />
|
||||
Copiez cette clé maintenant — elle ne sera plus jamais affichée.
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Clés d'accès à l'API"
|
||||
description={
|
||||
<>
|
||||
Une clé permet à un script ou à un outil externe (ChatGPT, tableur…) de lire les données de
|
||||
CollectFlow sans se connecter. Depuis un navigateur déjà connecté, aucune clé n'est nécessaire.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<CardContent className="space-y-5">
|
||||
{/* Clé fraîchement créée — visible une seule fois */}
|
||||
{freshKey && (
|
||||
<div className="space-y-3 rounded-xl border border-[var(--accent-border)] bg-[var(--accent-bg)] p-4">
|
||||
<p className="flex items-start gap-2 text-sm font-semibold text-[var(--text-primary)]">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent)]" aria-hidden />
|
||||
Copiez cette clé maintenant : elle ne sera plus jamais affichée.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 break-all rounded-lg border border-[var(--border)] bg-[var(--bg-surface)] px-3 py-2 font-mono text-[13px] text-[var(--text-primary)]">
|
||||
{freshKey}
|
||||
</code>
|
||||
<Button variant="outline" onClick={copier}>
|
||||
{copied ? <Check className="text-[var(--accent-success)]" /> : <Copy />}
|
||||
{copied ? "Copiée" : "Copier"}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setFreshKey(null)}>
|
||||
J'ai copié la clé, la masquer
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 rounded-lg px-2.5 py-2 text-[12px] font-mono break-all"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}>
|
||||
{freshKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { navigator.clipboard.writeText(freshKey); setCopied(true); }}
|
||||
className="rounded-lg px-2.5 py-2 shrink-0"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-secondary)" }}
|
||||
title="Copier"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Création */}
|
||||
<form onSubmit={handleCreate} className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end">
|
||||
<div>
|
||||
<Label htmlFor="cle-nom">Nom de la clé</Label>
|
||||
<Input
|
||||
id="cle-nom"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="ex. Export comptabilité"
|
||||
className="w-full"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFreshKey(null)}
|
||||
className="text-[11px] underline"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
J'ai copié la clé, masquer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
id="cle-role"
|
||||
label="Droits"
|
||||
value={role}
|
||||
onChange={(v) => setRole(v as Role)}
|
||||
options={ROLES}
|
||||
/>
|
||||
<Button type="submit" disabled={creating || !name.trim()}>
|
||||
{creating ? <Loader2 className="animate-spin" /> : <Plus />}
|
||||
Créer la clé
|
||||
</Button>
|
||||
</form>
|
||||
<p className="-mt-2 text-xs text-[var(--text-muted)]">
|
||||
Donnez un nom qui dit à quoi sert la clé : vous saurez laquelle révoquer le jour où l'outil n'est plus utilisé.
|
||||
La clé s'envoie dans l'en-tête <code className="font-mono">X-API-Key</code> de chaque appel.
|
||||
</p>
|
||||
|
||||
{/* Création */}
|
||||
<form onSubmit={handleCreate} className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Nom de la clé (ex. Export comptabilité)"
|
||||
className="flex-1 rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as "admin" | "user")}
|
||||
className="rounded-lg px-3 py-2 text-[13px] outline-none"
|
||||
style={{ background: "var(--bg-elevated)", border: "1px solid var(--border)", color: "var(--text-primary)" }}
|
||||
>
|
||||
<option value="user">Rôle : utilisateur</option>
|
||||
<option value="admin">Rôle : administrateur</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !name.trim()}
|
||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center justify-center gap-1.5"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||
Créer
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p className="text-[12px]" style={{ color: "var(--accent-error)" }}>{error}</p>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--text-muted)" }}>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Chargement…
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="text-[12px]" style={{ color: "var(--text-muted)" }}>Aucune clé pour le moment.</p>
|
||||
) : (
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--border)" }}>
|
||||
<table className="w-full text-[12px]">
|
||||
<thead>
|
||||
<tr style={{ background: "var(--bg-elevated)" }}>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Nom</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Préfixe</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Rôle</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Créée</th>
|
||||
<th className="text-left px-3 py-2 font-semibold" style={{ color: "var(--text-secondary)" }}>Dernier usage</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} style={{ borderTop: "1px solid var(--border)", opacity: k.revokedAt ? 0.55 : 1 }}>
|
||||
<td className="px-3 py-2 font-medium" style={{ color: "var(--text-primary)" }}>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
|
||||
{k.name}
|
||||
</span>
|
||||
{k.revokedAt && (
|
||||
<span className="ml-2 rounded px-1.5 py-px text-[9px] font-bold uppercase"
|
||||
style={{ background: "var(--accent-error-bg)", color: "var(--accent-error)" }}>
|
||||
Révoquée
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono" style={{ color: "var(--text-secondary)" }}>{k.keyPrefix}…</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-secondary)" }}>{k.role}</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.createdAt)}</td>
|
||||
<td className="px-3 py-2" style={{ color: "var(--text-muted)" }}>{fmtDate(k.lastUsedAt)}</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
{!k.revokedAt ? (
|
||||
<button
|
||||
onClick={() => handleRevoke(k.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
|
||||
style={{ color: "var(--accent-error)" }}
|
||||
title="Révoquer"
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" /> Révoquer
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleDelete(k.id)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title="Supprimer définitivement"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Supprimer
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* Liste */}
|
||||
{loading ? (
|
||||
<div className="space-y-2" aria-busy="true" aria-label="Chargement des clés">
|
||||
{Array.from({ length: 3 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
) : erreurChargement ? (
|
||||
<ErrorState
|
||||
title="La liste des clés n'a pas pu être chargée"
|
||||
detail={erreurChargement}
|
||||
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||
/>
|
||||
) : keys.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={KeyRound}
|
||||
title="Aucune clé pour le moment"
|
||||
description="Créez une clé ci-dessus pour permettre à un outil externe d'interroger l'API."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left">Nom</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Début de la clé</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Droits</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Créée le</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Dernière utilisation</th>
|
||||
<th scope="col" className="px-3 py-2 text-right"><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((k) => (
|
||||
<tr key={k.id} className="border-t border-[var(--border)]">
|
||||
<td className="px-3 py-2 text-sm font-medium text-[var(--text-primary)]">
|
||||
<span className={k.revokedAt ? "inline-flex items-center gap-2 opacity-60" : "inline-flex items-center gap-2"}>
|
||||
<KeyRound className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />
|
||||
{k.name}
|
||||
</span>
|
||||
{k.revokedAt && (
|
||||
<Badge ton="erreur" className="ml-2" title={`Révoquée le ${fmtDate(k.revokedAt)}`}>Révoquée</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-sm text-[var(--text-secondary)]">{k.keyPrefix}…</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{LIBELLE_ROLE[k.role] ?? k.role}</td>
|
||||
<td className="px-3 py-2 text-sm whitespace-nowrap text-[var(--text-secondary)]">{fmtDate(k.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-sm whitespace-nowrap text-[var(--text-secondary)]">
|
||||
{k.lastUsedAt ? fmtDate(k.lastUsedAt) : "Jamais"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right whitespace-nowrap">
|
||||
{!k.revokedAt ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRevoke(k)}
|
||||
disabled={enCours === k.id}
|
||||
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
|
||||
>
|
||||
{enCours === k.id ? <Loader2 className="animate-spin" /> : <Ban />}
|
||||
Révoquer
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(k)}
|
||||
disabled={enCours === k.id}
|
||||
className="text-[var(--text-secondary)]"
|
||||
>
|
||||
{enCours === k.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,11 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Loader2, Play, CheckCircle, AlertTriangle, Database } from "lucide-react";
|
||||
import { Loader2, Play, CheckCircle2, AlertTriangle, Database } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "@/components/ui/feedback";
|
||||
import { fmtEntier } from "@/lib/format";
|
||||
|
||||
interface WarmupState {
|
||||
status: "idle" | "running" | "success" | "error";
|
||||
@@ -26,7 +30,7 @@ interface WarmupState {
|
||||
|
||||
export function GridWarmup() {
|
||||
const [state, setState] = useState<WarmupState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [demarrage, setDemarrage] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const running = state?.status === "running";
|
||||
@@ -57,92 +61,102 @@ export function GridWarmup() {
|
||||
}, [running, fetchState]);
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
setDemarrage(true);
|
||||
let echec: string | null = null;
|
||||
try {
|
||||
const res = await fetch("/api/admin/grid-warmup?staleHours=24", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error ?? `Erreur ${res.status}`); return; }
|
||||
setState(data);
|
||||
if (res.ok) setState(data);
|
||||
else echec = data.error ?? `erreur ${res.status}`;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
echec = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
setDemarrage(false);
|
||||
if (echec) toast.erreur(`Le préchauffage n'a pas pu démarrer : ${echec}`);
|
||||
};
|
||||
|
||||
const traites = (state?.done ?? 0) + (state?.skipped ?? 0) + (state?.failed ?? 0);
|
||||
const pct = state && state.total > 0 ? Math.round((traites / state.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
L'API ne recalcule jamais : elle lit un instantané écrit quand la Grille est ouverte dans
|
||||
l'application. Sans préchauffage, une IA externe ne verrait que les fournisseurs déjà consultés.
|
||||
Ce bouton calcule <strong>tous les fournisseurs</strong> ; ceux déjà à jour depuis moins de 24 h sont
|
||||
sautés, il est donc sans risque de le relancer.
|
||||
</p>
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Préchauffage des données de l'API"
|
||||
description="Préchauffage : calcule à l'avance les grilles de tous les fournisseurs pour que l'API réponde immédiatement."
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
L'API lit les grilles déjà calculées, enregistrées quand un fournisseur est ouvert dans la Grille.
|
||||
Sans préchauffage, un outil externe ne trouverait tout de suite que les fournisseurs déjà consultés.
|
||||
Les fournisseurs calculés il y a moins de 24 h sont sautés : vous pouvez relancer sans risque.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<button
|
||||
onClick={start}
|
||||
disabled={running}
|
||||
className="rounded-lg px-4 py-2 text-[13px] font-semibold text-white disabled:opacity-50 flex items-center gap-1.5"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
{running ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
|
||||
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
|
||||
</button>
|
||||
{state && state.total > 0 && (
|
||||
<span className="text-[12px] tabular-nums" style={{ color: "var(--text-secondary)" }}>
|
||||
{traites} / {state.total} ({pct} %)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{running && state && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: "var(--bg-elevated)" }}>
|
||||
<div className="h-full transition-all" style={{ width: `${pct}%`, background: "var(--accent)" }} />
|
||||
</div>
|
||||
{state.currentFournisseur && (
|
||||
<p className="text-[11px] truncate" style={{ color: "var(--text-muted)" }}>
|
||||
En cours : {state.currentFournisseur}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button onClick={start} disabled={running || demarrage}>
|
||||
{running || demarrage ? <Loader2 className="animate-spin" /> : <Play />}
|
||||
{running ? "Préparation en cours…" : "Préparer les données pour l'API"}
|
||||
</Button>
|
||||
{state && state.total > 0 && (
|
||||
<span className="text-[13px] tabular-nums text-[var(--text-secondary)]">
|
||||
{fmtEntier(traites)} fournisseurs sur {fmtEntier(state.total)} ({pct} %)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state?.status === "success" && (
|
||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--text-secondary)" }}>
|
||||
<CheckCircle className="w-4 h-4 shrink-0 mt-px text-emerald-500" />
|
||||
<span>
|
||||
Terminé : <strong>{state.done}</strong> fournisseur(s) calculé(s),{" "}
|
||||
<strong>{state.skipped}</strong> déjà à jour
|
||||
{state.failed > 0 && <>, <strong style={{ color: "var(--accent-error)" }}>{state.failed} en échec</strong></>}.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{running && state && (
|
||||
<div className="space-y-1.5">
|
||||
<div
|
||||
className="h-2 overflow-hidden rounded-full bg-[var(--bg-elevated)]"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct}
|
||||
aria-label="Avancement du préchauffage"
|
||||
>
|
||||
<div className="h-full bg-[var(--accent)] transition-all" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{state.currentFournisseur && (
|
||||
<p className="truncate text-[13px] text-[var(--text-muted)]">
|
||||
En cours : {state.currentFournisseur}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state && state.failed > 0 && state.lastErrors.length > 0 && (
|
||||
<details className="text-[11px]" style={{ color: "var(--text-muted)" }}>
|
||||
<summary className="cursor-pointer">Voir les échecs ({state.lastErrors.length} premiers)</summary>
|
||||
<ul className="mt-1 pl-4 list-disc space-y-0.5">
|
||||
{state.lastErrors.map((e, i) => <li key={i} className="break-all">{e}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
{state?.status === "success" && (
|
||||
<p className="flex items-start gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-[var(--accent-success)]" aria-hidden />
|
||||
<span>
|
||||
Terminé : <strong className="text-[var(--text-primary)]">{fmtEntier(state.done)}</strong> fournisseur(s) calculé(s),{" "}
|
||||
<strong className="text-[var(--text-primary)]">{fmtEntier(state.skipped)}</strong> déjà à jour
|
||||
{state.failed > 0 && <>, <strong className="text-[var(--accent-error)]">{fmtEntier(state.failed)} en échec</strong></>}.
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(error || state?.error) && (
|
||||
<div className="flex items-start gap-2 text-[12px]" style={{ color: "var(--accent-error)" }}>
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-px" />
|
||||
<span>{error ?? state?.error}</span>
|
||||
</div>
|
||||
)}
|
||||
{state && state.failed > 0 && state.lastErrors.length > 0 && (
|
||||
<details className="text-[13px] text-[var(--text-secondary)]">
|
||||
<summary className="cursor-pointer font-medium">Voir les échecs ({state.lastErrors.length} premiers)</summary>
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-5 text-xs">
|
||||
{state.lastErrors.map((e, i) => <li key={i} className="break-all font-mono">{e}</li>)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{state?.status === "idle" && (
|
||||
<p className="text-[11px] flex items-center gap-1.5" style={{ color: "var(--text-muted)" }}>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
Aucun préchauffage lancé depuis le démarrage de l'application.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{state?.error && (
|
||||
<p role="alert" className="flex items-start gap-2 text-sm text-[var(--accent-error)]">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||
<span>{state.error}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{state?.status === "idle" && (
|
||||
<p className="flex items-center gap-2 text-[13px] text-[var(--text-muted)]">
|
||||
<Database className="h-4 w-4" aria-hidden />
|
||||
Aucun préchauffage lancé depuis le démarrage de l'application.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,209 +1,334 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { KeyRound, Loader2, Shield, Trash2, User, UserPlus } from "lucide-react";
|
||||
import { getUsers, createUser, deleteUser, updatePassword } from "../api/user-actions";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input, Label, Select } from "@/components/ui/form-controls";
|
||||
import { EmptyState, ErrorState, Skeleton } from "@/components/ui/states";
|
||||
import { confirmer, toast } from "@/components/ui/feedback";
|
||||
import {
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Shield,
|
||||
User,
|
||||
Loader2,
|
||||
Check,
|
||||
Key,
|
||||
} from "lucide-react";
|
||||
import { SuccessModal } from "@/components/shared/success-modal";
|
||||
import { ChangePasswordModal } from "@/components/shared/change-password-modal";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
||||
|
||||
export function UserManagement() {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
||||
const [modal, setModal] = useState({ isOpen: false, title: "", message: "" });
|
||||
const [pwdModal, setPwdModal] = useState<{ isOpen: boolean; userId: number; username: string }>({
|
||||
isOpen: false, userId: 0, username: ""
|
||||
});
|
||||
type Utilisateur = Awaited<ReturnType<typeof getUsers>>[number];
|
||||
type Role = "admin" | "user";
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getUsers();
|
||||
setUsers(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const ROLES = [
|
||||
{ value: "user", label: "Utilisateur" },
|
||||
{ value: "admin", label: "Administrateur" },
|
||||
] as const;
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
function messageErreur(e: unknown): string {
|
||||
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
function fmtJour(valeur: Date | string | null | undefined): string {
|
||||
if (!valeur) return "—";
|
||||
const d = new Date(valeur);
|
||||
return isNaN(d.getTime()) ? "—" : d.toLocaleDateString("fr-FR");
|
||||
}
|
||||
|
||||
/**
|
||||
* Changement du mot de passe d'un compte. Monté à l'ouverture (clé = compte) :
|
||||
* les champs repartent vides à chaque fois.
|
||||
*/
|
||||
function DialogueMotDePasse({ cible, onFermer }: { cible: Utilisateur; onFermer: () => void }) {
|
||||
const [motDePasse, setMotDePasse] = useState("");
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [erreur, setErreur] = useState<string | null>(null);
|
||||
const [envoi, setEnvoi] = useState(false);
|
||||
|
||||
const valider = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsCreating(true);
|
||||
const res = await createUser(newUsername, newPassword, newRole);
|
||||
if (res.success) {
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Utilisateur Créé",
|
||||
message: `L'utilisateur ${newUsername} a été ajouté avec succès.`
|
||||
});
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
fetchUsers();
|
||||
} else {
|
||||
alert(res.error);
|
||||
setErreur(null);
|
||||
if (motDePasse.length < 4) {
|
||||
setErreur("Le mot de passe doit faire au moins 4 caractères.");
|
||||
return;
|
||||
}
|
||||
setIsCreating(false);
|
||||
};
|
||||
|
||||
const handleUpdatePassword = (id: number, username: string) => {
|
||||
setPwdModal({ isOpen: true, userId: id, username });
|
||||
};
|
||||
|
||||
const handleConfirmPassword = async (newPassword: string) => {
|
||||
const res = await updatePassword(pwdModal.userId, newPassword);
|
||||
if (res.success) {
|
||||
setModal({
|
||||
isOpen: true,
|
||||
title: "Mot de passe mis à jour",
|
||||
message: `Le mot de passe de ${pwdModal.username} a été modifié avec succès.`
|
||||
});
|
||||
} else {
|
||||
throw new Error(res.error ?? "Erreur lors de la mise à jour.");
|
||||
if (motDePasse !== confirmation) {
|
||||
setErreur("Les deux mots de passe ne sont pas identiques.");
|
||||
return;
|
||||
}
|
||||
setEnvoi(true);
|
||||
try {
|
||||
const res = await updatePassword(cible.id, motDePasse);
|
||||
if (!res.success) {
|
||||
setErreur(res.error ?? "Erreur lors de la mise à jour.");
|
||||
return;
|
||||
}
|
||||
toast.succes(`Le mot de passe de « ${cible.username} » a été modifié.`);
|
||||
onFermer();
|
||||
} catch (err) {
|
||||
setErreur(messageErreur(err));
|
||||
} finally {
|
||||
setEnvoi(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, username: string) => {
|
||||
if (!window.confirm(`Supprimer l'utilisateur ${username} ?`)) return;
|
||||
const res = await deleteUser(id);
|
||||
if (res.success) {
|
||||
setUsers(users.filter(u => u.id !== id));
|
||||
} else {
|
||||
alert(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-12 space-y-4">
|
||||
<Loader2 className="w-8 h-8 text-[var(--accent)] animate-spin" />
|
||||
<p className="text-[var(--text-muted)] text-sm">Chargement des utilisateurs...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Formulaire de création */}
|
||||
<div className="bg-[var(--bg-elevated)] border border-[var(--border)] rounded-2xl p-6">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-[var(--accent-bg)] border border-[var(--accent-border)] flex items-center justify-center">
|
||||
<UserPlus className="w-6 h-6 text-[var(--accent)]" />
|
||||
<Dialog open onOpenChange={(open) => { if (!open && !envoi) onFermer(); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={valider} className="grid gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Changer le mot de passe</DialogTitle>
|
||||
<DialogDescription className="text-[var(--text-secondary)]">
|
||||
Compte « {cible.username} ». Le nouveau mot de passe sera demandé dès la prochaine connexion.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div>
|
||||
<Label htmlFor="mdp-nouveau">Nouveau mot de passe</Label>
|
||||
<Input id="mdp-nouveau" type="password" className="w-full" autoFocus autoComplete="new-password"
|
||||
value={motDePasse} onChange={(e) => setMotDePasse(e.target.value)} />
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">Au moins 4 caractères.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-[var(--text-primary)]">Nouvel Utilisateur</h2>
|
||||
<p className="text-[var(--text-secondary)] text-xs">Ajoutez un collaborateur à la plateforme.</p>
|
||||
<Label htmlFor="mdp-confirmation">Confirmer le mot de passe</Label>
|
||||
<Input id="mdp-confirmation" type="password" className="w-full" autoComplete="new-password"
|
||||
value={confirmation} onChange={(e) => setConfirmation(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Utilisateur</label>
|
||||
<input
|
||||
required
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="apple-input"
|
||||
placeholder="ex: jean.dupont"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Mot de passe</label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="apple-input"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1">Rôle</label>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value as any)}
|
||||
className="apple-input"
|
||||
>
|
||||
<option value="user">Utilisateur</option>
|
||||
<option value="admin">Administrateur</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className="apple-btn-primary"
|
||||
>
|
||||
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||
Créer le compte
|
||||
</button>
|
||||
{erreur && (
|
||||
<p role="alert" className="text-[13px] text-[var(--accent-error)]">{erreur}</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onFermer} disabled={envoi}>Annuler</Button>
|
||||
<Button type="submit" disabled={envoi}>
|
||||
{envoi && <Loader2 className="animate-spin" />}
|
||||
Enregistrer le mot de passe
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Liste des utilisateurs */}
|
||||
<div className="grid gap-3">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-[var(--text-muted)] ml-1 mb-1">Liste des comptes</h3>
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-5 p-4 rounded-xl bg-[var(--bg-surface)] border border-[var(--border)] hover:border-[var(--text-muted)] transition-all group">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--bg-elevated)] flex items-center justify-center shrink-0 border border-[var(--border)]">
|
||||
{u.role === "admin" ? <Shield className="w-5 h-5 text-amber-500" /> : <User className="w-5 h-5 text-[var(--text-muted)]" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserManagement() {
|
||||
const [users, setUsers] = useState<Utilisateur[]>([]);
|
||||
const [chargement, setChargement] = useState(true);
|
||||
const [erreurChargement, setErreurChargement] = useState<string | null>(null);
|
||||
const [creation, setCreation] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState<Role>("user");
|
||||
const [suppression, setSuppression] = useState<number | null>(null);
|
||||
const [cibleMotDePasse, setCibleMotDePasse] = useState<Utilisateur | null>(null);
|
||||
|
||||
// Aucun setState avant le premier await : appelable depuis l'effet de montage.
|
||||
const charger = useCallback(async () => {
|
||||
try {
|
||||
setUsers(await getUsers());
|
||||
setErreurChargement(null);
|
||||
} catch (e) {
|
||||
setErreurChargement(messageErreur(e));
|
||||
} finally {
|
||||
setChargement(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void charger(); }, [charger]);
|
||||
|
||||
const reessayer = () => {
|
||||
setChargement(true);
|
||||
void charger();
|
||||
};
|
||||
|
||||
const handleCreate = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCreation(true);
|
||||
try {
|
||||
const res = await createUser(newUsername, newPassword, newRole);
|
||||
if (res.success) {
|
||||
toast.succes(`Le compte « ${newUsername} » a été créé : il peut se connecter dès maintenant.`);
|
||||
setNewUsername("");
|
||||
setNewPassword("");
|
||||
void charger();
|
||||
} else {
|
||||
toast.erreur(res.error ?? "Le compte n'a pas pu être créé.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.erreur(`Le compte n'a pas pu être créé : ${messageErreur(err)}`);
|
||||
} finally {
|
||||
setCreation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (u: Utilisateur) => {
|
||||
const ok = await confirmer({
|
||||
titre: `Supprimer le compte « ${u.username} » ?`,
|
||||
message: "Cette personne ne pourra plus se connecter à CollectFlow. La suppression est définitive.",
|
||||
libelleConfirmer: "Supprimer",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setSuppression(u.id);
|
||||
try {
|
||||
const res = await deleteUser(u.id);
|
||||
if (res.success) {
|
||||
setUsers((prev) => prev.filter((x) => x.id !== u.id));
|
||||
toast.succes(`Le compte « ${u.username} » a été supprimé.`);
|
||||
} else {
|
||||
toast.erreur(res.error ?? "Le compte n'a pas pu être supprimé.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.erreur(`Le compte n'a pas pu être supprimé : ${messageErreur(err)}`);
|
||||
} finally {
|
||||
setSuppression(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Création */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Ajouter un utilisateur"
|
||||
description="Le compte peut se connecter dès sa création, avec cet identifiant et ce mot de passe."
|
||||
/>
|
||||
<CardContent>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<Label htmlFor="nouvel-identifiant">Identifiant</Label>
|
||||
<Input
|
||||
id="nouvel-identifiant"
|
||||
required
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder="ex. jean.dupont"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="nouveau-mdp">Mot de passe</Label>
|
||||
<Input
|
||||
id="nouveau-mdp"
|
||||
required
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full"
|
||||
placeholder="••••••••"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
id="nouveau-role"
|
||||
label="Rôle"
|
||||
value={newRole}
|
||||
onChange={(v) => setNewRole(v as Role)}
|
||||
options={ROLES}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
||||
Un administrateur accède en plus à la synchronisation et aux paramètres.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-bold text-[var(--text-primary)]">{u.username}</h4>
|
||||
<span className="text-[10px] uppercase font-black text-[var(--text-muted)] tracking-wider">
|
||||
{u.role === "admin" ? "Administrateur" : "Utilisateur Standard"}
|
||||
</span>
|
||||
<Button type="submit" disabled={creation}>
|
||||
{creation ? <Loader2 className="animate-spin" /> : <UserPlus />}
|
||||
Créer le compte
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Liste */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Comptes"
|
||||
description="Les personnes autorisées à se connecter à CollectFlow."
|
||||
actions={!chargement && !erreurChargement ? <Badge>{users.length} compte{users.length > 1 ? "s" : ""}</Badge> : undefined}
|
||||
/>
|
||||
<CardContent>
|
||||
{chargement ? (
|
||||
<div className="space-y-2" aria-busy="true" aria-label="Chargement des comptes">
|
||||
{Array.from({ length: 4 }, (_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleUpdatePassword(u.id, u.username)}
|
||||
className="p-2.5 rounded-lg hover:bg-[var(--accent-bg)] text-[var(--text-muted)] hover:text-[var(--accent)] transition-colors"
|
||||
title="Changer le mot de passe"
|
||||
>
|
||||
<Key className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.username)}
|
||||
className="p-2.5 rounded-lg hover:bg-[var(--accent-error-bg)] text-[var(--text-muted)] hover:text-[var(--accent-error)] transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
) : erreurChargement ? (
|
||||
<ErrorState
|
||||
title="La liste des comptes n'a pas pu être chargée"
|
||||
detail={erreurChargement}
|
||||
action={<Button variant="outline" onClick={reessayer}>Réessayer</Button>}
|
||||
/>
|
||||
) : users.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
title="Aucun compte"
|
||||
description="Créez le premier compte avec le formulaire ci-dessus."
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[var(--bg-elevated)] text-[13px] font-semibold text-[var(--text-secondary)]">
|
||||
<tr>
|
||||
<th scope="col" className="px-3 py-2 text-left">Identifiant</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Rôle</th>
|
||||
<th scope="col" className="px-3 py-2 text-left">Créé le</th>
|
||||
<th scope="col" className="px-3 py-2 text-right"><span className="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-t border-[var(--border)] hover:bg-[var(--bg-elevated)]">
|
||||
<td className="px-3 py-2 text-sm font-medium text-[var(--text-primary)]">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{u.role === "admin"
|
||||
? <Shield className="h-4 w-4 text-[var(--accent)]" aria-hidden />
|
||||
: <User className="h-4 w-4 text-[var(--text-muted)]" aria-hidden />}
|
||||
{u.username}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm">
|
||||
{u.role === "admin"
|
||||
? <Badge ton="accent">Administrateur</Badge>
|
||||
: <Badge>Utilisateur</Badge>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-[var(--text-secondary)]">{fmtJour(u.createdAt)}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setCibleMotDePasse(u)}>
|
||||
<KeyRound /> Mot de passe
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(u)}
|
||||
disabled={suppression === u.id}
|
||||
className="text-[var(--accent-error)] hover:bg-[var(--accent-error-bg)] hover:text-[var(--accent-error)]"
|
||||
>
|
||||
{suppression === u.id ? <Loader2 className="animate-spin" /> : <Trash2 />}
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<SuccessModal
|
||||
isOpen={modal.isOpen}
|
||||
onClose={() => setModal({ ...modal, isOpen: false })}
|
||||
title={modal.title}
|
||||
message={modal.message}
|
||||
/>
|
||||
|
||||
<ChangePasswordModal
|
||||
isOpen={pwdModal.isOpen}
|
||||
onClose={() => setPwdModal({ ...pwdModal, isOpen: false })}
|
||||
onConfirm={handleConfirmPassword}
|
||||
username={pwdModal.username}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{cibleMotDePasse && (
|
||||
<DialogueMotDePasse
|
||||
key={cibleMotDePasse.id}
|
||||
cible={cibleMotDePasse}
|
||||
onFermer={() => setCibleMotDePasse(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user