From 26b93642325db3dc43ebece997537760313910cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 06:39:49 +0000 Subject: [PATCH] =?UTF-8?q?Lot=205=20:=20changement=20de=20magasin=20insta?= =?UTF-8?q?ntan=C3=A9=20et=20contr=C3=B4le=20des=20droits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grille - Corrige le lot 4 : au changement de magasin, les chiffres et le tri affichés suivent tout de suite (nouvelle liste), la sélection est gardée. - Lignes toujours chargées « Nos 2 magasins » ; changement de magasin sans aller-retour serveur (URL mise à jour par l'historique du navigateur). - Complément de l'API FF par magasin servi à part (/api/grid/rows/store-patch), appliqué à son arrivée, calcul identique à l'ancien (lib/store-patch.ts). - La recherche ne reconstruit plus toutes les lignes à chaque frappe ; la sélection n'est remise à zéro qu'au changement de fournisseur. Sécurité - lib/authz.ts : session relue en base (cache 1 min), rôle de la base prioritaire ; variantes pour actions, routes et pages. - Paramètres réservés aux administrateurs ; session vérifiée dans les actions Grille, Commandes, Historique et les routes ; diagnostics réservés admin. - Mots de passe et clés d'IA plus envoyés au navigateur ; champ vide = mot de passe conservé ; plus de mot de passe dans le localStorage ; l'enregistrement de la base n'efface plus les clés d'IA. - admin/admin créé seulement si aucun utilisateur ; fichier de secours utilisé uniquement si la base est injoignable ; data/ ignoré par git. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AcA129WzGTH6zxPHFBGWmn --- .gitignore | 3 + docs/plan-optimisation-webui.md | 33 +++ .../admin/synchronisation/page.tsx | 9 +- src/app/(dashboard)/settings/client.tsx | 100 ++++--- src/app/(dashboard)/settings/page.tsx | 5 + src/app/api/admin/grid-warmup/route.ts | 19 +- src/app/api/admin/sync/fournisseurs/route.ts | 19 +- src/app/api/admin/sync/route.ts | 23 +- src/app/api/commandes-auto/route.ts | 4 + src/app/api/diag/route.ts | 14 +- src/app/api/diag/stock-fournisseur/route.ts | 4 + src/app/api/export/excel/route.ts | 4 + src/app/api/export/modified-gammes/route.ts | 4 + src/app/api/ff-status/route.ts | 8 +- src/app/api/grid/rows/route.ts | 4 + src/app/api/grid/rows/store-patch/route.ts | 34 +++ src/app/api/logs/route.ts | 15 +- src/app/api/produits/opportunites/route.ts | 10 +- src/app/api/produits/search/route.ts | 19 +- src/app/api/qlik/sync/route.ts | 46 +--- src/app/api/test-db/route.ts | 10 +- src/features/admin/api/api-key-actions.ts | 20 +- src/features/admin/api/user-actions.ts | 28 +- src/features/auth/logic/seed-admin.ts | 104 +++++--- src/features/commandes-auto/actions.ts | 16 +- src/features/grid/actions.ts | 5 + src/features/grid/api/get-product-rows.ts | 177 ++++++------ src/features/grid/api/save-draft-changes.ts | 10 +- src/features/grid/components/grid-client.tsx | 36 ++- .../grid/components/grid-filter-bar.tsx | 6 +- src/features/grid/components/heatmap-grid.tsx | 30 ++- src/features/grid/hooks/use-store-patch.ts | 35 +++ src/features/grid/lib/store-patch.ts | 155 +++++++++++ src/features/grid/store/use-grid-store.ts | 56 +++- src/features/settings/actions.ts | 252 +++++++++--------- src/features/settings/config-file.ts | 55 ++++ src/features/settings/ff-api-diagnostic.ts | 64 +++++ .../settings/store/use-db-settings-store.ts | 25 +- src/features/snapshots/api/delete-snapshot.ts | 15 +- src/features/snapshots/api/get-snapshots.ts | 12 +- src/features/snapshots/api/save-snapshot.ts | 22 +- .../snapshots/components/snapshot-list.tsx | 2 +- src/lib/auth.ts | 10 +- src/lib/authz.ts | 164 ++++++++++++ 44 files changed, 1155 insertions(+), 531 deletions(-) create mode 100644 src/app/api/grid/rows/store-patch/route.ts create mode 100644 src/features/grid/hooks/use-store-patch.ts create mode 100644 src/features/grid/lib/store-patch.ts create mode 100644 src/features/settings/config-file.ts create mode 100644 src/features/settings/ff-api-diagnostic.ts create mode 100644 src/lib/authz.ts diff --git a/.gitignore b/.gitignore index 287b20d..7512808 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ yarn-error.log* next-env.d.ts .playwright-mcp/ + +# données locales (configuration avec mots de passe, comptes de secours) +/data/ diff --git a/docs/plan-optimisation-webui.md b/docs/plan-optimisation-webui.md index 51300d7..9d61e88 100644 --- a/docs/plan-optimisation-webui.md +++ b/docs/plan-optimisation-webui.md @@ -376,3 +376,36 @@ Autres changements : - Première ouverture d'un gros fournisseur après la synchro de la nuit : elle doit être quasi immédiate. - Changement de magasin : la Grille reste affichée, puis les chiffres se mettent à jour. - Enregistrer, quitter la Grille, puis y revenir : les gammes enregistrées sont bien là. + +## Suivi — Lot 5 réalisé (correctif Grille et sécurité) + +**Grille : changement de magasin** +- **Corrige une régression du lot 4** : les lignes déjà affichées gardaient les chiffres de l'ancien magasin, et un tri actif n'était pas réappliqué, jusqu'à la fin du rechargement. Désormais, la liste est reconstruite à chaque changement de magasin : les chiffres et le tri sont justes tout de suite, et la sélection est conservée. +- **Plus d'aller-retour serveur** : la Grille charge toujours « Nos 2 magasins », dont les lignes portent déjà le détail de chaque magasin. Changer de magasin met seulement à jour l'URL, qui reste partageable. +- **Complément de l'API FF demandé à part** (`/api/grid/rows/store-patch`) et appliqué à son arrivée. Il couvre les articles sans vente SQL dans le magasin, comme avant. + - Le calcul est identique à l'ancien : comparaison automatique sur un jeu d'essai. + - Il ne modifie que les chiffres du magasin, pas les totaux « Nos 2 magasins ». + - Il n'est demandé qu'une fois par magasin et par chargement. +- **Sélection** : remise à zéro seulement au changement de fournisseur. +- **La recherche ne reconstruit plus toutes les lignes** à chaque frappe. + +**Sécurité** +- **Contrôle commun `lib/authz.ts`** : + - le compte est relu en base, avec un cache d'une minute ; un compte supprimé ou rétrogradé perd ses droits sans attendre l'expiration de sa session ; + - si la base est injoignable, le jeton de session fait foi. +- **Paramètres** : actions réservées aux administrateurs, avec la même vérification sur les pages Paramètres et Synchronisation. +- **Toutes les autres actions et routes vérifient la session** : Grille, Commandes, Historique, exports, statut FF. +- **Routes de diagnostic** (`/api/diag*`, `/api/test-db`) : réservées aux administrateurs, sans trace d'erreur technique. +- **Mots de passe et clés d'IA** : + - ils ne sont plus envoyés au navigateur ; un champ laissé vide garde le mot de passe enregistré ; + - le mot de passe PostgreSQL n'est plus conservé dans le `localStorage`, et l'ancien est effacé ; + - enregistrer la connexion PostgreSQL n'efface plus les clés d'IA. +- **Compte admin/admin** : + - il n'est créé qu'au premier démarrage (aucun utilisateur en base) ; + - le fichier de secours `data/users.json` ne sert que si la base est injoignable ; + - `data/` est ignoré par git. + +**À savoir après la mise en ligne** +- Un utilisateur promu administrateur doit se reconnecter pour accéder à Paramètres. +- Après avoir configuré la base depuis le compte de secours, il faut se reconnecter. +- Un mot de passe enregistré ne peut plus être effacé depuis l'écran ; seul son remplacement est possible. diff --git a/src/app/(dashboard)/admin/synchronisation/page.tsx b/src/app/(dashboard)/admin/synchronisation/page.tsx index ce8c239..86b6644 100644 --- a/src/app/(dashboard)/admin/synchronisation/page.tsx +++ b/src/app/(dashboard)/admin/synchronisation/page.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { verifierAdmin } from "@/lib/authz"; import { SynchronisationClient } from "./client"; export const metadata: Metadata = { title: "Synchronisation" }; @@ -6,9 +8,10 @@ export const metadata: Metadata = { title: "Synchronisation" }; /** * Paramétrage de la synchronisation nocturne des fournisseurs. * - * L'accès admin est déjà imposé par le middleware (préfixe /admin) ; les routes - * /api/admin/sync revérifient le rôle côté serveur. + * L'accès admin est déjà imposé par le middleware (préfixe /admin), sur le rôle + * du jeton ; la page le revérifie en base, comme les routes /api/admin/sync. */ -export default function SynchronisationPage() { +export default async function SynchronisationPage() { + if (!(await verifierAdmin()).ok) redirect("/dashboard"); return ; } diff --git a/src/app/(dashboard)/settings/client.tsx b/src/app/(dashboard)/settings/client.tsx index dca2313..6a15b0c 100644 --- a/src/app/(dashboard)/settings/client.tsx +++ b/src/app/(dashboard)/settings/client.tsx @@ -30,7 +30,7 @@ import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-st import { testDatabaseConnection, saveDatabaseSettings, - getSavedDatabaseConfig, + getParametresAffichables, saveQlikSettings, testQlikConnection, saveFfApiSettings, @@ -45,12 +45,13 @@ import { ServerLogs } from "@/features/settings/components/server-logs"; /** * Configuration enregistrée, lue UNE fois pour toute la page : trois sections * (connexion, API FF, Qlik) la demandaient chacune au montage, et les actions - * serveur s'exécutent les unes après les autres. + * serveur s'exécutent les unes après les autres. Elle ne contient aucun mot de + * passe : seulement l'indication qu'il y en a un d'enregistré. */ -let savedConfigPromise: ReturnType | null = null; -function loadSavedConfig(force = false): ReturnType { +let savedConfigPromise: ReturnType | null = null; +function loadSavedConfig(force = false): ReturnType { if (force || !savedConfigPromise) { - savedConfigPromise = getSavedDatabaseConfig().catch((e) => { + savedConfigPromise = getParametresAffichables().catch((e) => { savedConfigPromise = null; throw e; }); @@ -142,6 +143,13 @@ function ResultatTest({ test }: { test: EtatTest }) { return null; } +/** Texte indicatif d'un champ mot de passe, selon qu'un mot de passe est déjà enregistré. */ +function placeholderMotDePasse(enregistre: boolean): string { + return enregistre ? "•••••• (inchangé)" : "••••••••"; +} + +const AIDE_MOT_DE_PASSE_ENREGISTRE = "Laissez vide pour conserver le mot de passe enregistré."; + // ── PostgreSQL ─────────────────────────────────────────────────────────────── function SectionPostgres({ recharger }: { recharger: () => Promise }) { @@ -152,9 +160,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise }) { user, setUser, password, setPassword, ssl, setSsl, - getDatabaseUrl, + motDePasseEnregistre, setMotDePasseEnregistre, } = useDbSettingsStore(); + // Mot de passe vide : le serveur reprend celui qui est enregistré. + const champs = () => ({ host, port, database, user, password: password || undefined, ssl }); + const [test, setTest] = useState({ etat: "idle" }); const [enregistrement, setEnregistrement] = useState(false); const [rechargement, setRechargement] = useState(false); @@ -162,7 +173,7 @@ function SectionPostgres({ recharger }: { recharger: () => Promise }) { const tester = async () => { setTest({ etat: "en-cours" }); try { - const res = await testDatabaseConnection(getDatabaseUrl()); + const res = await testDatabaseConnection(champs()); setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Erreur de connexion inconnue." }); } catch (e) { setTest({ etat: "erreur", message: messageErreur(e) }); @@ -172,9 +183,14 @@ function SectionPostgres({ recharger }: { recharger: () => Promise }) { const enregistrer = async () => { setEnregistrement(true); try { - const res = await saveDatabaseSettings(getDatabaseUrl()); - if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée."); - else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`); + const res = await saveDatabaseSettings(champs()); + if (res.success) { + toast.succes("Connexion à la base PostgreSQL enregistrée."); + if (password) { + setPassword(""); + setMotDePasseEnregistre(true); + } + } else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`); } catch (e) { toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); } finally { @@ -223,8 +239,8 @@ function SectionPostgres({ recharger }: { recharger: () => Promise }) { setUser(e.target.value)} autoComplete="off" /> - - + setPassword(e.target.value)} autoComplete="new-password" /> @@ -283,8 +299,8 @@ function SectionFfApi() { useEffect(() => { loadSavedConfig() .then((cfg) => { - setSavedUrl(cfg?.ffApiBaseUrl ?? null); - if (cfg?.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); + setSavedUrl(cfg.ffApiBaseUrl || null); + if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl); }) .catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ }); }, []); @@ -418,7 +434,9 @@ function SectionFfApi() { function SectionQlik() { const [host, setHost] = useState(""); const [user, setUser] = useState(""); + // Toujours vide au chargement : le mot de passe enregistré ne quitte pas le serveur. const [password, setPassword] = useState(""); + const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false); const [showPwd, setShowPwd] = useState(false); const [saving, setSaving] = useState(false); const [test, setTest] = useState({ etat: "idle" }); @@ -426,10 +444,9 @@ function SectionQlik() { useEffect(() => { loadSavedConfig() .then((c) => { - if (!c) return; - setHost(c.qlikHost ?? ""); - setUser(c.qlikUser ?? ""); - setPassword(c.qlikPassword ?? ""); + setHost(c.qlik.host); + setUser(c.qlik.user); + setMotDePasseEnregistre(c.qlik.motDePasseEnregistre); }) .catch(() => { /* l'échec de lecture est signalé par la page */ }); }, []); @@ -437,9 +454,14 @@ function SectionQlik() { const handleSave = async () => { setSaving(true); try { - const res = await saveQlikSettings(host.trim(), user.trim(), password); - if (res.success) toast.succes("Identifiants Qlik enregistrés."); - else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`); + const res = await saveQlikSettings(host.trim(), user.trim(), password || undefined); + if (res.success) { + toast.succes("Identifiants Qlik enregistrés."); + if (password) { + setPassword(""); + setMotDePasseEnregistre(true); + } + } else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`); } catch (e) { toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`); } finally { @@ -450,14 +472,15 @@ function SectionQlik() { const handleTest = async () => { setTest({ etat: "en-cours" }); try { - const res = await testQlikConnection(host.trim(), user.trim(), password); + const res = await testQlikConnection(host.trim(), user.trim(), password || undefined); setTest(res.success ? { etat: "ok" } : { etat: "erreur", message: res.error || "Échec de la connexion." }); } catch (e) { setTest({ etat: "erreur", message: messageErreur(e) }); } }; - const testImpossible = !user || !password; + // Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend. + const testImpossible = !user || (!password && !motDePasseEnregistre); return ( @@ -488,9 +511,9 @@ function SectionQlik() { setUser(e.target.value)} className="w-full font-mono" autoComplete="off" /> - +
- setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
- +
@@ -327,6 +334,7 @@ export function GridClient({ codeFournisseur, nomFournisseur, fournisseurs, maga {/* Main content */}
s.setActiveMagasin); const activeMagasin = useGridStore((s) => s.activeMagasin); + // Changement de magasin sans aller-retour serveur : les lignes portent déjà + // le détail par magasin. L'URL suit (lien partageable, retour par le menu), + // via l'historique du navigateur, que Next répercute sur useSearchParams. const handleStoreSelect = (code: string) => { + if (code === activeMagasin) return; setActiveMagasin(code); const params = new URLSearchParams(searchParams.toString()); params.set("magasin", code); - router.replace(`/grid?${params.toString()}`, { scroll: false }); + window.history.replaceState(null, "", `?${params.toString()}`); }; diff --git a/src/features/grid/components/heatmap-grid.tsx b/src/features/grid/components/heatmap-grid.tsx index a3b9f86..5249ae0 100644 --- a/src/features/grid/components/heatmap-grid.tsx +++ b/src/features/grid/components/heatmap-grid.tsx @@ -52,6 +52,8 @@ import { couleurMarge } from "@/lib/marge"; import { Button } from "@/components/ui/button"; interface HeatmapGridProps { + /** Fournisseur affiché : la sélection est remise à zéro quand il change. */ + codeFournisseur: string; onSelectionChange?: (codeins: string[]) => void; isAdmin?: boolean; /** Sert à nommer et documenter les extractions issues de la Grille. */ @@ -699,10 +701,14 @@ function VentilationParMagasin({ d }: { d: CellDetailData }) { // ========================================================================= -function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) { +function HeatmapGridInner({ codeFournisseur, onSelectionChange, isAdmin, nomFournisseur }: HeatmapGridProps) { // L'abonnement doit être minimal ici ! PAS de draftChanges ni de setDraftGamme. + // Champs de filtre lus un par un : la recherche ne doit pas recalculer + // `filteredData` (donc reconstruire toutes les lignes) à chaque frappe. const rows = useGridStore((s) => s.rows); - const filters = useGridStore((s) => s.filters); + const code3 = useGridStore((s) => s.filters.code3); + const codeGamme = useGridStore((s) => s.filters.codeGamme); + const search = useGridStore((s) => s.filters.search); const displayDensity = useGridStore((s) => s.displayDensity); const draftChanges = useGridStore((s) => s.filters.codeGamme ? s.draftChanges : EMPTY_DRAFT_CHANGES); const activeMagasin = useGridStore((s) => s.activeMagasin); @@ -713,12 +719,16 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma // Filtre client-side par code3 (famille) et codeGamme const filteredData = useMemo(() => { - const { code3, codeGamme } = filters; + // Changement de magasin : les accesseurs de tri (quantité, CA, marge, prix) + // dépendent du magasin, et TanStack garde la valeur de chaque ligne en cache + // sans jamais retrier sur un changement de colonnes. Un nouveau tableau + // donne de nouvelles lignes : tri juste, et cases redessinées aussitôt. + void activeMagasin; // Garde : un état persisté d'une version antérieure peut encore contenir // une chaîne. La migration du store le corrige, mais un `new Set("320211")` // produirait un ensemble de caractères et viderait la Grille sans rien dire. const codes = Array.isArray(code3) ? code3 : typeof code3 === "string" ? [code3] : null; - if (!codes && !codeGamme) return rows; + if (!codes && !codeGamme) return rows.slice(); // Set plutôt que includes() : la liste peut compter des dizaines de // nomenclatures, et le filtre est réévalué pour chaque ligne. const nomenclatures = codes ? new Set(codes) : null; @@ -731,7 +741,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma } return true; }); - }, [rows, filters, draftChanges]); + }, [rows, code3, codeGamme, draftChanges, activeMagasin]); const [sorting, setSorting] = useState([]); const [rowSelection, setRowSelection] = useState({}); @@ -775,10 +785,12 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma [] ); - // Reset selection when rows change (e.g. supplier change) + // Sélection remise à zéro au changement de fournisseur seulement : elle est + // indexée par code article, et survit donc à un rechargement des lignes, à un + // enregistrement ou au complément des chiffres d'un magasin. useEffect(() => { setRowSelection({}); - }, [rows]); + }, [codeFournisseur]); const columns = React.useMemo[]>(() => [ { @@ -1198,7 +1210,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma const table = useReactTable({ data: filteredData, columns, - state: { sorting, globalFilter: filters.search, rowSelection, columnVisibility, columnSizing }, + state: { sorting, globalFilter: search, rowSelection, columnVisibility, columnSizing }, onSortingChange: setSorting, onRowSelectionChange: handleRowSelectionChange, onColumnVisibilityChange: setColumnVisibility, @@ -1249,7 +1261,7 @@ function HeatmapGridInner({ onSelectionChange, isAdmin, nomFournisseur }: Heatma // Delay update to next tick to ensure we're out of any render cycles setTimeout(() => onSelectionChange(selectedCodeins), 0); } - }, [table, rowSelection, onSelectionChange, filteredData, filters.search]); + }, [table, rowSelection, onSelectionChange, filteredData, search]); const rowVirtualizer = useVirtualizer({ diff --git a/src/features/grid/hooks/use-store-patch.ts b/src/features/grid/hooks/use-store-patch.ts new file mode 100644 index 0000000..818c600 --- /dev/null +++ b/src/features/grid/hooks/use-store-patch.ts @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect } from "react"; +import { rowsKeyFor, useGridStore } from "@/features/grid/store/use-grid-store"; +import { CODE_TOUS_MAGASINS } from "@/lib/magasins"; +import type { StorePatch } from "@/features/grid/lib/store-patch"; + +/** + * Demande le complément de l'API FF du magasin affiché, une fois les lignes + * chargées, et l'applique à son arrivée. Une seule demande par magasin et par + * chargement : revenir sur un magasin déjà complété ne coûte rien. + */ +export function useStorePatch(codeFournisseur: string, enabled: boolean) { + const activeMagasin = useGridStore((s) => s.activeMagasin); + const rowsMeta = useGridStore((s) => s.rowsMeta); + const applyStorePatch = useGridStore((s) => s.applyStorePatch); + + useEffect(() => { + if (!enabled || activeMagasin === CODE_TOUS_MAGASINS || !rowsMeta) return; + if (rowsMeta.key !== rowsKeyFor(codeFournisseur) || rowsMeta.patchedStores.includes(activeMagasin)) return; + + const controller = new AbortController(); + const gen = { key: rowsMeta.key, loadedAt: rowsMeta.loadedAt }; + const params = new URLSearchParams({ fournisseur: codeFournisseur, magasin: activeMagasin }); + fetch(`/api/grid/rows/store-patch?${params}`, { signal: controller.signal, cache: "no-store" }) + .then((res) => (res.ok ? (res.json() as Promise) : null)) + .then((patch) => { + if (patch && !controller.signal.aborted) applyStorePatch(gen, patch); + }) + // Échec ou abandon : les chiffres SQL restent affichés, et la demande + // repartira au prochain passage sur ce magasin. + .catch(() => {}); + return () => controller.abort(); + }, [enabled, codeFournisseur, activeMagasin, rowsMeta, applyStorePatch]); +} diff --git a/src/features/grid/lib/store-patch.ts b/src/features/grid/lib/store-patch.ts new file mode 100644 index 0000000..dd66ade --- /dev/null +++ b/src/features/grid/lib/store-patch.ts @@ -0,0 +1,155 @@ +/** + * Complément des chiffres d'un magasin par l'API FF (ventes mensuelles). + * + * Le calcul SQL de la Grille peut manquer les ventes d'un magasin pour certains + * articles (quantité à 0 pour ce magasin). L'API FF les connaît : on en tire, + * pour ces articles seulement, les séries et totaux du magasin. + * + * Module sans dépendance d'exécution : utilisé côté serveur (calcul du + * complément) comme côté navigateur (application aux lignes affichées). + */ + +import type { ProductRow } from "@/types/grid"; +import type { FfMensuelEntry } from "@/lib/api-ff-client"; + +/** Chiffres d'un article pour le magasin complété. */ +export interface StorePatchEntry { + codein: string; + /** Ventes par mois (« YYYYMM »). */ + sales: Record; + /** Stock de fin de mois, reporté sur les mois sans donnée. */ + stock: Record; + receptions: Record; + quantite: number; + ca: number; + marge: number; +} + +export interface StorePatch { + magasin: string; + /** Fenêtre des 12 mois du calcul : un complément d'un autre mois est ignoré. */ + periods: string[]; + entries: StorePatchEntry[]; +} + +/** Articles à compléter : connus de FF (noid) et sans aucune vente SQL dans ce magasin. */ +export function storePatchCandidates(rows: ProductRow[], magasin: string): ProductRow[] { + return rows.filter((row) => !!row.noid && (row.quantiteByStore?.[magasin] ?? 0) === 0); +} + +/** Chiffres du magasin tirés de l'API, pour les articles où elle montre des ventes. */ +export function buildStorePatchEntries( + retenus: ProductRow[], + magasin: string, + periods: string[], + mensuel: Map, +): StorePatchEntry[] { + const resultat: StorePatchEntry[] = []; + for (const row of retenus) { + const entries = (mensuel.get(row.codein) ?? []).filter((entry) => entry.site === magasin); + if (entries.length === 0) continue; + + const byPeriod = new Map(); + for (const entry of entries) { + const period = entry.mois.replace("-", ""); + if (!periods.includes(period)) continue; + // qte_vendue / ca_ht sont NÉGATIFS côté API (ventes nettes) : on + // les nie au lieu de Math.abs pour que les retours restent déduits. + const qty = -(Number(entry.ventes?.qte_vendue ?? 0) || 0); + const ca = -(Number(entry.ventes?.ca_ht ?? 0) || 0); + const marge = Number(entry.ventes?.marge ?? 0) || 0; + const stock = Number(entry.stock_fin_mois ?? 0) || 0; + const receptions = Number(entry.receptions?.qte_recue ?? 0) || 0; + byPeriod.set(period, { qty, ca, marge, stock, receptions }); + } + + const apiQty = [...byPeriod.values()].reduce((sum, value) => sum + value.qty, 0); + if (apiQty === 0) continue; + + const e: StorePatchEntry = { codein: row.codein, sales: {}, stock: {}, receptions: {}, quantite: 0, ca: 0, marge: 0 }; + let lastStock = 0; + for (const period of periods) { + const value = byPeriod.get(period); + e.sales[period] = value?.qty ?? 0; + e.receptions[period] = value?.receptions ?? 0; + if (value) lastStock = value.stock; + e.stock[period] = lastStock; + e.quantite += value?.qty ?? 0; + e.ca += value?.ca ?? 0; + e.marge += value?.marge ?? 0; + } + resultat.push(e); + } + return resultat; +} + +/** + * Application historique, côté serveur (`/api/grid/rows?magasin=…`) : corrige le + * magasin ET reporte les écarts sur les totaux « tous magasins » de la ligne. + * Renvoie le nombre de lignes corrigées. + */ +export function applyStorePatchInPlace( + rows: ProductRow[], + magasin: string, + periods: string[], + entries: StorePatchEntry[], +): number { + const parCode = new Map(entries.map((e) => [e.codein, e])); + let corrigees = 0; + for (const row of rows) { + const e = parCode.get(row.codein); + if (!e) continue; + + row.sales12mByStore ??= {}; + row.stock12mByStore ??= {}; + row.receptions12mByStore ??= {}; + row.caByStore ??= {}; + row.quantiteByStore ??= {}; + row.margeByStore ??= {}; + row.sales12mByStore[magasin] ??= {}; + row.stock12mByStore[magasin] ??= {}; + row.receptions12mByStore[magasin] ??= {}; + + for (const period of periods) { + const currentQty = row.sales12mByStore[magasin][period] ?? 0; + const nextQty = e.sales[period] ?? 0; + row.sales12mByStore[magasin][period] = nextQty; + row.receptions12mByStore[magasin][period] = e.receptions[period] ?? 0; + row.stock12mByStore[magasin][period] = e.stock[period] ?? 0; + row.sales12m[period] = (row.sales12m[period] ?? 0) + (nextQty - currentQty); + } + + const deltaTotalQty = e.quantite - (row.quantiteByStore[magasin] ?? 0); + const deltaTotalCa = e.ca - (row.caByStore[magasin] ?? 0); + const deltaTotalMarge = e.marge - (row.margeByStore[magasin] ?? 0); + + row.quantiteByStore[magasin] = e.quantite; + row.caByStore[magasin] = e.ca; + row.margeByStore[magasin] = e.marge; + row.totalQuantite += deltaTotalQty; + row.totalCa += deltaTotalCa; + row.totalMarge += deltaTotalMarge; + row.tauxMarge = row.totalCa > 0 ? (row.totalMarge / row.totalCa) * 100 : 0; + if (!row.workingStores.includes(magasin)) row.workingStores.push(magasin); + corrigees++; + } + return corrigees; +} + +/** + * Application dans le navigateur : nouvelle ligne, chiffres du magasin seuls. + * Les totaux « tous magasins » ne bougent pas, pour que la vue « Nos 2 + * magasins » reste identique quel que soit le magasin consulté avant. + */ +export function withStorePatch(row: ProductRow, magasin: string, e: StorePatchEntry): ProductRow { + return { + ...row, + sales12mByStore: { ...row.sales12mByStore, [magasin]: e.sales }, + stock12mByStore: { ...row.stock12mByStore, [magasin]: e.stock }, + receptions12mByStore: { ...row.receptions12mByStore, [magasin]: e.receptions }, + quantiteByStore: { ...row.quantiteByStore, [magasin]: e.quantite }, + caByStore: { ...row.caByStore, [magasin]: e.ca }, + margeByStore: { ...row.margeByStore, [magasin]: e.marge }, + workingStores: row.workingStores.includes(magasin) ? row.workingStores : [...row.workingStores, magasin], + }; +} diff --git a/src/features/grid/store/use-grid-store.ts b/src/features/grid/store/use-grid-store.ts index f5e21d9..f0b326c 100644 --- a/src/features/grid/store/use-grid-store.ts +++ b/src/features/grid/store/use-grid-store.ts @@ -3,6 +3,19 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { GammeCode, GridFilters, GridSummary, ProductRow } from "@/types/grid"; +import { withStorePatch, type StorePatch } from "@/features/grid/lib/store-patch"; +import { getMonthsFromRows } from "@/features/grid/lib/months"; + +/** Ce que décrit `rowsMeta` : les lignes d'un fournisseur, tous magasins. */ +export interface RowsMeta { + key: string; + loadedAt: number; + /** Magasins dont le complément de l'API FF est déjà appliqué à ces lignes. */ + patchedStores: string[]; +} + +/** Clé des lignes chargées pour un fournisseur (toujours « tous magasins »). */ +export const rowsKeyFor = (codeFournisseur: string) => `${codeFournisseur}:TOTAL`; interface GridState { /** Source data from server */ @@ -25,12 +38,17 @@ interface GridState { refreshRequest: number; requestRefresh: () => void; /** - * Ce que contiennent `rows` (fournisseur:magasin) et quand elles ont été - * chargées. Non persisté : sert à ne pas retélécharger la Grille quand on y - * revient, et à changer de magasin sans vider l'écran. + * Ce que contiennent `rows` et quand elles ont été chargées. Non persisté : + * sert à ne pas retélécharger la Grille quand on y revient, et à savoir quels + * magasins ont déjà reçu leur complément. */ - rowsMeta: { key: string; loadedAt: number } | null; - setRowsMeta: (meta: { key: string; loadedAt: number } | null) => void; + rowsMeta: RowsMeta | null; + setRowsMeta: (meta: RowsMeta | null) => void; + /** + * Applique le complément d'un magasin, s'il concerne encore les lignes + * affichées (`gen` = clé et date de chargement au moment de la demande). + */ + applyStorePatch: (gen: { key: string; loadedAt: number }, patch: StorePatch) => void; /** Persisted column visibility state */ columnVisibility: Record; /** Persisted column sizing state */ @@ -231,6 +249,34 @@ export const useGridStore = create()( setActiveGridQuery: (query) => set({ activeGridQuery: sansParamRefresh(query) }), requestRefresh: () => set((state) => ({ refreshRequest: state.refreshRequest + 1 })), setRowsMeta: (meta) => set({ rowsMeta: meta }), + applyStorePatch: (gen, patch) => { + const { rows, rowsByCodein, rowsMeta } = get(); + if (!rowsMeta || rowsMeta.key !== gen.key || rowsMeta.loadedAt !== gen.loadedAt) return; + const patchedStores = rowsMeta.patchedStores.includes(patch.magasin) + ? rowsMeta.patchedStores + : [...rowsMeta.patchedStores, patch.magasin]; + // Complément calculé sur une autre fenêtre de 12 mois (changement de + // mois entre les deux requêtes) : on le laisse de côté. + const mois = getMonthsFromRows(rows); + const memeFenetre = !mois || mois.join(",") === patch.periods.join(","); + const parCode = new Map(patch.entries.map((e) => [e.codein, e])); + if (!memeFenetre || !patch.entries.some((e) => rowsByCodein[e.codein])) { + set({ rowsMeta: { ...rowsMeta, patchedStores } }); + return; + } + // Nouvelles lignes pour les seuls articles complétés : les autres + // gardent leur objet, et donc leur rendu mémorisé. + const nouvelles = rows.map((r) => { + const e = parCode.get(r.codein); + return e ? withStorePatch(r, patch.magasin, e) : r; + }); + set({ rows: nouvelles, rowsByCodein: indexRows(nouvelles), rowsMeta: { ...rowsMeta, patchedStores } }); + if (summaryTimer) clearTimeout(summaryTimer); + summaryTimer = setTimeout(() => { + const state = get(); + set({ summary: computeSummary(state.rows, state.draftChanges, state.activeMagasin) }); + }, 80); + }, restoreSnapshot: (changes) => { set({ draftChanges: changes, summary: computeSummary(get().rows, changes, get().activeMagasin) }); }, diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index cb51049..dea37f4 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -1,43 +1,114 @@ "use server"; +/** + * Actions des Paramètres — réservées aux administrateurs. + * + * Aucun secret ne repart vers le navigateur : les mots de passe (base, Qlik) et + * les clés d'IA restent dans `data/.db-config.json`. Un mot de passe laissé vide + * dans le formulaire signifie « conserver celui qui est enregistré ». + */ + import { Pool } from "pg"; -import fs from "fs/promises"; -import path from "path"; +import { requireAdmin, verifierAdmin } from "@/lib/authz"; +import { CONFIG_FILE, readConfig, writeConfig } from "./config-file"; +import { diagnostiquerApiFf } from "./ff-api-diagnostic"; -const DATA_DIR = path.join(process.cwd(), "data"); -const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); - -export interface DbConfig { - url: string; - openRouterKey?: string; - openRouterModel?: string; - aiProvider?: "openrouter" | "google"; - googleAiKey?: string; - googleAiModel?: string; - /** Qlik Sense — données réseau */ - qlikHost?: string; - qlikUser?: string; - qlikPassword?: string; - /** - * API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et - * au rattrapage des ventes par magasin dans la Grille. Configurable ici pour - * ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer - * sans redéployer. - */ - ffApiBaseUrl?: string; +export interface ChampsBaseDeDonnees { + host: string; + port: string; + database: string; + user: string; + /** Vide ou absent : le mot de passe enregistré est conservé. */ + password?: string; + ssl: boolean; } -/** Lit la config existante (ou {} si absente). */ -async function readConfig(): Promise> { +/** Ce que le formulaire peut afficher : jamais de mot de passe ni de clé. */ +export interface ParametresAffichables { + db: { + host: string; + port: string; + database: string; + user: string; + ssl: boolean; + motDePasseEnregistre: boolean; + } | null; + qlik: { host: string; user: string; motDePasseEnregistre: boolean }; + ffApiBaseUrl: string; +} + +/** Décode un composant d'URL, ou le garde tel quel s'il n'est pas encodé proprement. */ +function decoder(valeur: string): string { try { - const data = await fs.readFile(CONFIG_FILE, "utf-8"); - return JSON.parse(data); + return decodeURIComponent(valeur); } catch { - return {}; + return valeur; } } -export async function testDatabaseConnection(url: string) { +/** Découpe l'URL `postgres://` enregistrée en champs du formulaire. */ +function lireUrlPostgres(url: string) { + try { + const u = new URL(url); + return { + host: u.hostname, + port: u.port || "5432", + database: decoder(u.pathname.slice(1)), + user: decoder(u.username), + password: decoder(u.password), + ssl: url.includes("sslmode=require"), + }; + } catch (e) { + console.error("[Settings] URL de base enregistrée illisible :", (e as Error).message); + return null; + } +} + +function construireUrlPostgres(champs: ChampsBaseDeDonnees, motDePasse: string): string { + const user = encodeURIComponent(champs.user.trim()); + const identifiants = motDePasse ? `${user}:${encodeURIComponent(motDePasse)}` : user; + const port = champs.port.trim() || "5432"; + return `postgres://${identifiants}@${champs.host.trim()}:${port}/${champs.database.trim()}${champs.ssl ? "?sslmode=require" : ""}`; +} + +/** Mot de passe saisi, sinon celui de l'URL enregistrée. */ +async function motDePasseBase(champs: ChampsBaseDeDonnees): Promise { + if (champs.password) return champs.password; + const { url } = await readConfig(); + return (url && lireUrlPostgres(url)?.password) || ""; +} + +/** Réglages enregistrés, sans aucun secret. */ +export async function getParametresAffichables(): Promise { + await requireAdmin(); + const config = await readConfig(); + const base = config.url ? lireUrlPostgres(config.url) : null; + return { + db: base + ? { + host: base.host, + port: base.port, + database: base.database, + user: base.user, + ssl: base.ssl, + motDePasseEnregistre: base.password !== "", + } + : null, + qlik: { + host: config.qlikHost ?? "", + user: config.qlikUser ?? "", + // Même ordre de priorité que getQlikConfig() : réglage enregistré, puis variable d'environnement. + motDePasseEnregistre: Boolean(config.qlikPassword || process.env.QLIK_PWD), + }, + ffApiBaseUrl: config.ffApiBaseUrl ?? "", + }; +} + +export async function testDatabaseConnection(champs: ChampsBaseDeDonnees) { + const acces = await verifierAdmin(); + if (!acces.ok) return { success: false, error: acces.message }; + + const url = construireUrlPostgres(champs, await motDePasseBase(champs)); console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@")); const pool = new Pool({ connectionString: url, @@ -58,24 +129,15 @@ export async function testDatabaseConnection(url: string) { } } -export async function saveDatabaseSettings( - url: string, - openRouterKey?: string, - openRouterModel?: string, - aiProvider?: "openrouter" | "google", - googleAiKey?: string, - googleAiModel?: string, -) { +export async function saveDatabaseSettings(champs: ChampsBaseDeDonnees) { + const acces = await verifierAdmin(); + if (!acces.ok) return { success: false, error: acces.message }; + try { - // Merge avec l'existant pour préserver les autres réglages (ex: Qlik) - const existing = await readConfig(); - const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel }; - - // S'assurer que le dossier data existe - await fs.mkdir(DATA_DIR, { recursive: true }); - + const url = construireUrlPostgres(champs, await motDePasseBase(champs)); + // Seule l'URL change : les clés d'IA et les réglages Qlik / API FF sont conservés. console.log(`[Settings] Saving config to ${CONFIG_FILE}`); - await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); + await writeConfig({ url }); console.log("[Settings] Database configuration saved successfully."); // Refresh the shared DB instance immediately @@ -90,12 +152,13 @@ export async function saveDatabaseSettings( } } -export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) { +/** Mot de passe vide ou absent : celui qui est enregistré est conservé. */ +export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword?: string) { + const acces = await verifierAdmin(); + if (!acces.ok) return { success: false, error: acces.message }; + try { - const existing = await readConfig(); - const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig; - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); + await writeConfig({ qlikHost, qlikUser, ...(qlikPassword ? { qlikPassword } : {}) }); console.log("[Settings] Qlik configuration saved."); return { success: true }; } catch (error: unknown) { @@ -105,10 +168,15 @@ export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikP } } -export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) { +/** Mot de passe vide ou absent : on teste avec celui qui est enregistré. */ +export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword?: string) { + const acces = await verifierAdmin(); + if (!acces.ok) return { success: false, error: acces.message }; + try { const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client"); - const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword }; + const enregistre = getQlikConfig(); + const cfg = { ...enregistre, user: qlikUser, password: qlikPassword || enregistre.password }; if (qlikHost) cfg.host = qlikHost; await qlikNtlmSession(cfg); return { success: true }; @@ -120,15 +188,15 @@ export async function testQlikConnection(qlikHost: string, qlikUser: string, qli /** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */ export async function saveFfApiSettings(ffApiBaseUrl: string) { + const acces = await verifierAdmin(); + if (!acces.ok) return { success: false, error: acces.message }; + try { const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, ""); if (cleaned && !/^https?:\/\//i.test(cleaned)) { return { success: false, error: "L'URL doit commencer par http:// ou https://" }; } - const existing = await readConfig(); - const config = { ...existing, ffApiBaseUrl: cleaned || undefined } as DbConfig; - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); + await writeConfig({ ffApiBaseUrl: cleaned || undefined }); // Sans cela, l'ancienne URL resterait servie jusqu'à 30 s après la sauvegarde. const { resetFfApiBaseCache } = await import("@/lib/api-ff-client"); resetFfApiBaseCache(); @@ -141,71 +209,9 @@ export async function saveFfApiSettings(ffApiBaseUrl: string) { } } -/** - * Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. - * - * L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir - * si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue - * donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. - */ -export async function testFfApiConnection(ffApiBaseUrl?: string) { - const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, ""); - const url = `${base}/api/sync/status`; - const started = Date.now(); - try { - const res = await fetch(url, { - cache: "no-store", - signal: AbortSignal.timeout(8000), - }); - const ms = Date.now() - started; - if (!res.ok) { - return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` }; - } - const body = await res.json().catch(() => null); - if (!body) { - return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` }; - } - // La réponse brute de l'API n'a pas la forme attendue par l'interface : - // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. - const { normalizeSyncStatus } = await import("@/lib/api-ff-client"); - const status = normalizeSyncStatus(body); - if (!status) { - return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` }; - } - return { success: true, url, ms, status }; - } catch (error: unknown) { - const ms = Date.now() - started; - const raw = error instanceof Error ? error.message : String(error); - const name = error instanceof Error ? error.name : ""; - // fetch masque la cause réelle derrière « fetch failed » : on la déplie. - const cause = (error as { cause?: { code?: string; message?: string } })?.cause; - let hint = raw; - if (name === "TimeoutError" || name === "AbortError") { - hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`; - } else if (cause?.code === "ENOTFOUND") { - hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`; - } else if (cause?.code === "ECONNREFUSED") { - hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`; - } else if (cause?.code) { - hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`; - } - console.error(`[Settings] FF API test KO (${url}):`, raw); - return { success: false, url, error: hint }; - } -} - -export async function getSavedDatabaseConfig(): Promise { - try { - if (!(await fs.stat(CONFIG_FILE).catch(() => null))) { - console.log(`[Settings] Config file not found at ${CONFIG_FILE}`); - return null; - } - const data = await fs.readFile(CONFIG_FILE, "utf-8"); - const config = JSON.parse(data); - console.log(`[Settings] Config read from ${CONFIG_FILE}. Key present: ${!!config.openRouterKey}, Model: ${config.openRouterModel || "default"}`); - return config; - } catch (error) { - console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error); - return null; - } +/** Teste l'API FF Nancy (voir `diagnostiquerApiFf`). */ +export async function testFfApiConnection(ffApiBaseUrl?: string): Promise>> { + const acces = await verifierAdmin(); + if (!acces.ok) return { success: false, url: "", error: acces.message }; + return diagnostiquerApiFf(ffApiBaseUrl); } diff --git a/src/features/settings/config-file.ts b/src/features/settings/config-file.ts new file mode 100644 index 0000000..b9745bb --- /dev/null +++ b/src/features/settings/config-file.ts @@ -0,0 +1,55 @@ +/** + * CollectFlow — Fichier de configuration `data/.db-config.json`. + * + * Module **serveur**, pas fichier d'actions : ses fonctions ne doivent pas être + * appelables depuis le navigateur (le fichier contient des mots de passe et des + * clés d'API). Les actions de `./actions.ts` les utilisent après contrôle du rôle. + */ + +import "server-only"; + +import fs from "fs/promises"; +import path from "path"; + +export const DATA_DIR = path.join(process.cwd(), "data"); +export const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json"); + +export interface DbConfig { + url: string; + openRouterKey?: string; + openRouterModel?: string; + aiProvider?: "openrouter" | "google"; + googleAiKey?: string; + googleAiModel?: string; + /** Qlik Sense — données réseau */ + qlikHost?: string; + qlikUser?: string; + qlikPassword?: string; + /** + * API REST FF Nancy (ex. https://api.ffnancy.fr). Sert au panneau de statut et + * au rattrapage des ventes par magasin dans la Grille. Configurable ici pour + * ne pas dépendre d'une variable d'environnement qu'on ne peut pas changer + * sans redéployer. + */ + ffApiBaseUrl?: string; +} + +/** Lit la config existante (ou {} si absente). */ +export async function readConfig(): Promise> { + try { + const data = await fs.readFile(CONFIG_FILE, "utf-8"); + return JSON.parse(data); + } catch { + return {}; + } +} + +/** + * Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont + * conservées telles quelles (une clé passée à `undefined` est retirée). + */ +export async function writeConfig(patch: Partial): Promise { + const existing = await readConfig(); + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existing, ...patch }, null, 2)); +} diff --git a/src/features/settings/ff-api-diagnostic.ts b/src/features/settings/ff-api-diagnostic.ts new file mode 100644 index 0000000..50409a9 --- /dev/null +++ b/src/features/settings/ff-api-diagnostic.ts @@ -0,0 +1,64 @@ +/** + * CollectFlow — Diagnostic de l'API FF Nancy. + * + * Partagé par le bouton « Tester » des Paramètres (administrateurs) et par la + * route `/api/ff-status` (tout utilisateur connecté). Module serveur et non + * fichier d'actions : chaque appelant fait son propre contrôle d'accès. + */ + +import "server-only"; + +import { readConfig } from "./config-file"; + +/** + * Teste l'API FF Nancy et renvoie un diagnostic **exploitable**. + * + * L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir + * si l'hôte était injoignable, l'URL erronée ou le service en panne. On distingue + * donc ici l'échec réseau (DNS, refus de connexion, délai dépassé) du code HTTP. + */ +export async function diagnostiquerApiFf(ffApiBaseUrl?: string) { + const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, ""); + const url = `${base}/api/sync/status`; + const started = Date.now(); + try { + const res = await fetch(url, { + cache: "no-store", + signal: AbortSignal.timeout(8000), + }); + const ms = Date.now() - started; + if (!res.ok) { + return { success: false, url, error: `Le serveur a répondu HTTP ${res.status} (${res.statusText || "sans message"}) en ${ms} ms.` }; + } + const body = await res.json().catch(() => null); + if (!body) { + return { success: false, url, error: `Réponse HTTP 200 mais corps illisible (JSON attendu).` }; + } + // La réponse brute de l'API n'a pas la forme attendue par l'interface : + // sans cette normalisation, le panneau s'affiche vide malgré un HTTP 200. + const { normalizeSyncStatus } = await import("@/lib/api-ff-client"); + const status = normalizeSyncStatus(body); + if (!status) { + return { success: false, url, error: `Réponse HTTP 200 mais format inattendu (ni « sync » ni « tables »).` }; + } + return { success: true, url, ms, status }; + } catch (error: unknown) { + const ms = Date.now() - started; + const raw = error instanceof Error ? error.message : String(error); + const name = error instanceof Error ? error.name : ""; + // fetch masque la cause réelle derrière « fetch failed » : on la déplie. + const cause = (error as { cause?: { code?: string; message?: string } })?.cause; + let hint = raw; + if (name === "TimeoutError" || name === "AbortError") { + hint = `Aucune réponse en ${ms} ms — serveur injoignable ou trop lent.`; + } else if (cause?.code === "ENOTFOUND") { + hint = `Nom d'hôte introuvable (DNS) — vérifiez l'URL.`; + } else if (cause?.code === "ECONNREFUSED") { + hint = `Connexion refusée — le service n'écoute pas sur cette adresse.`; + } else if (cause?.code) { + hint = `${cause.code}${cause.message ? ` — ${cause.message}` : ""}`; + } + console.error(`[Settings] FF API test KO (${url}):`, raw); + return { success: false, url, error: hint }; + } +} diff --git a/src/features/settings/store/use-db-settings-store.ts b/src/features/settings/store/use-db-settings-store.ts index 2c0d55a..3afdba7 100644 --- a/src/features/settings/store/use-db-settings-store.ts +++ b/src/features/settings/store/use-db-settings-store.ts @@ -8,29 +8,33 @@ export interface DatabaseSettings { port: string; database: string; user: string; + /** Saisie en cours uniquement : jamais enregistré dans le navigateur. */ password?: string; ssl: boolean; } interface DatabaseSettingsState extends DatabaseSettings { + /** Un mot de passe est enregistré sur le serveur (lu au chargement de la page, jamais mémorisé). */ + motDePasseEnregistre: boolean; setHost: (host: string) => void; setPort: (port: string) => void; setDatabase: (database: string) => void; setUser: (user: string) => void; setPassword: (password: string) => void; setSsl: (ssl: boolean) => void; - getDatabaseUrl: () => string; + setMotDePasseEnregistre: (motDePasseEnregistre: boolean) => void; } export const useDbSettingsStore = create()( persist( - (set, get) => ({ + (set) => ({ host: "localhost", port: "5432", database: "collectflow", user: "postgres", password: "", ssl: false, + motDePasseEnregistre: false, setHost: (host) => set({ host }), setPort: (port) => set({ port }), @@ -38,16 +42,19 @@ export const useDbSettingsStore = create()( setUser: (user) => set({ user }), setPassword: (password) => set({ password }), setSsl: (ssl) => set({ ssl }), - - getDatabaseUrl: () => { - const { user, password, host, port, database, ssl } = get(); - const auth = password ? `${user}:${password}` : user; - const url = `postgres://${auth}@${host}:${port}/${database}${ssl ? "?sslmode=require" : ""}`; - return url; - }, + setMotDePasseEnregistre: (motDePasseEnregistre) => set({ motDePasseEnregistre }), }), { name: "collectflow-db-settings", + // Le mot de passe reste sur le serveur : seuls les autres champs sont mémorisés. + partialize: ({ host, port, database, user, ssl }) => ({ host, port, database, user, ssl }), + // Version 1 : les versions précédentes enregistraient le mot de passe en clair. + version: 1, + migrate: (persisted) => { + const reste = { ...(persisted as Partial | undefined) }; + delete reste.password; + return reste as DatabaseSettingsState; + }, } ) ); diff --git a/src/features/snapshots/api/delete-snapshot.ts b/src/features/snapshots/api/delete-snapshot.ts index 0cbb69c..09ce9de 100644 --- a/src/features/snapshots/api/delete-snapshot.ts +++ b/src/features/snapshots/api/delete-snapshot.ts @@ -3,12 +3,14 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { and, eq, isNull } from "drizzle-orm"; -import { auth } from "@/lib/auth"; +import { verifierSession } from "@/lib/authz"; export async function deleteSnapshot(id: number) { - const session = await auth(); - const rawUserId = (session?.user as any)?.id; - const userId = rawUserId ? parseInt(String(rawUserId), 10) : null; + const acces = await verifierSession(); + if (!acces.ok) return { success: false, error: acces.message }; + + const rawUserId = acces.utilisateur.id; + const userId = rawUserId ? parseInt(rawUserId, 10) : null; console.log(`[deleteSnapshot] Request for ID: ${id}, Session UserId: ${userId}`); @@ -29,8 +31,9 @@ export async function deleteSnapshot(id: number) { console.log(`[deleteSnapshot] Successfully deleted ID: ${id}`); return { success: true }; - } catch (err: any) { + } catch (err) { + // Le détail technique reste dans les journaux du serveur. console.error("[deleteSnapshot] DB Error:", err); - return { success: false, error: err.message || "Delete failed" }; + return { success: false, error: "erreur technique sur le serveur" }; } } diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts index bed4ca5..e636a2b 100644 --- a/src/features/snapshots/api/get-snapshots.ts +++ b/src/features/snapshots/api/get-snapshots.ts @@ -2,14 +2,14 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; -import { auth } from "@/lib/auth"; +import { requireSession } from "@/lib/authz"; import { and, desc, eq, isNull } from "drizzle-orm"; export async function getSnapshots(type?: "snapshot" | "export") { + const utilisateur = await requireSession(); try { - const session = await auth(); - const rawUserId = (session?.user as any)?.id; - const userId = rawUserId ? parseInt(String(rawUserId), 10) : null; + const rawUserId = utilisateur.id; + const userId = rawUserId ? parseInt(rawUserId, 10) : null; // Si on a un userId, on filtre par celui-ci. // Sinon, on filtre par userId IS NULL (mode anonyme/local) @@ -24,8 +24,8 @@ export async function getSnapshots(type?: "snapshot" | "export") { .from(sessionSnapshots) .where(and(...conditions)) .orderBy(desc(sessionSnapshots.createdAt)); - } catch (err: any) { - const msg = (err?.message || String(err)).split("\n")[0]; + } catch (err) { + const msg = ((err as Error)?.message || String(err)).split("\n")[0]; console.error(`[getSnapshots] ERROR (type=${type}):`, msg, err); // Remonter l'erreur : une liste vide faisait afficher « Aucun snapshot » // au lieu de signaler que le chargement a échoué. diff --git a/src/features/snapshots/api/save-snapshot.ts b/src/features/snapshots/api/save-snapshot.ts index e539762..81445fc 100644 --- a/src/features/snapshots/api/save-snapshot.ts +++ b/src/features/snapshots/api/save-snapshot.ts @@ -4,7 +4,7 @@ import { db } from "@/db"; import { sessionSnapshots } from "@/db/schema"; import { z } from "zod"; import { sql, and, eq } from "drizzle-orm"; -import { auth } from "@/lib/auth"; +import { verifierSession } from "@/lib/authz"; import { revalidatePath } from "next/cache"; const SaveSnapshotSchema = z.object({ @@ -26,6 +26,9 @@ const SaveSnapshotSchema = z.object({ }); export async function saveSnapshot(raw: unknown) { + const acces = await verifierSession(); + if (!acces.ok) return { success: false, error: acces.message }; + const parsed = SaveSnapshotSchema.safeParse(raw); if (!parsed.success) { console.error("Snapshot validation failed:", parsed.error.format()); @@ -33,9 +36,8 @@ export async function saveSnapshot(raw: unknown) { } const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data; - const session = await auth(); - const rawUserId = (session?.user as any)?.id; - const userId = rawUserId ? parseInt(String(rawUserId), 10) : null; + const rawUserId = acces.utilisateur.id; + const userId = rawUserId ? parseInt(rawUserId, 10) : null; console.log(`[saveSnapshot] User: ${userId} (raw: ${rawUserId}), Supplier: ${codeFournisseur}, Type: ${type}`); @@ -59,7 +61,7 @@ export async function saveSnapshot(raw: unknown) { revalidatePath("/snapshots"); revalidatePath("/exports"); return { success: true, snapshotId: created?.id }; - } catch (err: any) { + } catch (err) { console.error("Initial snapshot save failed, attempting auto-repair...", err); try { @@ -103,14 +105,10 @@ export async function saveSnapshot(raw: unknown) { .returning({ id: sessionSnapshots.id }); return { success: true, snapshotId: retryCreated?.id }; - } catch (repairErr: any) { + } catch (repairErr) { + // Le détail technique (les deux erreurs) reste dans les journaux du serveur. console.error("Snapshot auto-repair/retry failed:", repairErr); - const origMsg = (err.message || String(err)).split('\n')[0]; - const retryMsg = (repairErr.message || String(repairErr)).split('\n')[0]; - return { - success: false, - error: `Erreur initiale: ${origMsg} | Erreur après réparation: ${retryMsg}` - }; + return { success: false, error: "erreur technique sur le serveur" }; } } } diff --git a/src/features/snapshots/components/snapshot-list.tsx b/src/features/snapshots/components/snapshot-list.tsx index a9c1aea..63328bb 100644 --- a/src/features/snapshots/components/snapshot-list.tsx +++ b/src/features/snapshots/components/snapshot-list.tsx @@ -117,7 +117,7 @@ export function SnapshotList({ type }: SnapshotListProps) { setSnapshots((prev) => prev.filter((x) => x.id !== s.id)); toast.succes(estExport ? "L'export a été retiré de votre historique." : "La session a été supprimée de votre historique."); } else { - toast.erreur(`La suppression a échoué : ${res.error ?? "erreur inconnue"}.`); + toast.erreur(`La suppression a échoué : ${(res.error ?? "erreur inconnue").replace(/\.$/, "")}.`); } } catch (err) { if (isStaleServerActionError(err)) void proposerRechargement(); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 8825d6a..9f0927f 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -24,7 +24,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ try { console.log(`[AUTH] Checking user "${credentials.username}"...`); - let user: any = null; + let user: { id: number | string; username: string; passwordHash: string; role: string } | undefined; // Try DB first try { @@ -33,12 +33,10 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ .where(eq(users.username, credentials.username as string)); user = dbUser; console.log(`[AUTH] DB check: User found: ${!!user}`); - } catch (dbErr) { + } catch { + // Base injoignable uniquement : un compte absent de la base ne doit + // pas pouvoir se connecter avec le fichier de secours (admin/admin). console.warn("[AUTH] DB unreachable, falling back to JSON."); - } - - // Fallback to JSON if DB failed or user not found - if (!user) { const fallbackUsers = getFallbackUsers(); user = fallbackUsers.find(u => u.username === credentials.username); console.log(`[AUTH] JSON check: User found: ${!!user}`); diff --git a/src/lib/authz.ts b/src/lib/authz.ts new file mode 100644 index 0000000..fc90420 --- /dev/null +++ b/src/lib/authz.ts @@ -0,0 +1,164 @@ +/** + * CollectFlow — Contrôle d'accès commun aux actions serveur, routes et pages. + * + * Le jeton de session (JWT) seul ne suffit pas : il reste valide jusqu'à son + * expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On + * relit donc l'utilisateur dans la table `users` (avec un cache court), et le + * rôle enregistré en base l'emporte sur celui du jeton. + * + * Base injoignable : on se contente du jeton (mode dégradé), sans quoi le compte + * de secours `data/users.json` ne pourrait plus rien faire — y compris configurer + * la base. + */ + +import "server-only"; + +import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/db"; +import { users } from "@/db/schema"; + +export interface UtilisateurCourant { + id: string; + username: string; + role: string; +} + +const MESSAGE_SESSION = "Session expirée : reconnectez-vous."; +const MESSAGE_ADMIN = "Action réservée aux administrateurs."; + +/** Refus d'accès. `statut` sert aux routes : 401 sans session valide, 403 sans le rôle requis. */ +export class AccesRefuse extends Error { + constructor(message: string, readonly statut: 401 | 403 = 403) { + super(message); + this.name = "AccesRefuse"; + } +} + +// ── Relecture du compte en base ────────────────────────────────────────────── + +/** Durée pendant laquelle une relecture de la table `users` est réutilisée. */ +const CACHE_TTL_MS = 60_000; +/** Après un échec de connexion, on ne réessaie pas avant ce délai (chaque essai peut durer 10 s). */ +const PAUSE_BASE_INJOIGNABLE_MS = 15_000; + +interface FicheUtilisateur { + exists: boolean; + role: string; + username: string; + at: number; +} + +const cache = new Map(); +let baseInjoignableDepuis = 0; + +/** Plus grand entier accepté par la colonne `users.id` (serial). */ +const ID_MAX = 2_147_483_647; + +/** + * Fiche de l'utilisateur en base, ou `null` si la base n'a pas pu répondre. + * Un identifiant qui ne peut pas exister (compte de secours « 0 », valeur non + * numérique) est cherché tel quel : introuvable si la base répond. + */ +async function lireFiche(id: string): Promise { + const maintenant = Date.now(); + const enCache = cache.get(id); + if (enCache && maintenant - enCache.at < CACHE_TTL_MS) return enCache; + if (maintenant - baseInjoignableDepuis < PAUSE_BASE_INJOIGNABLE_MS) return null; + + const n = /^\d+$/.test(id) ? Number(id) : -1; + const idNumerique = Number.isSafeInteger(n) && n <= ID_MAX ? n : -1; + + try { + const [ligne] = await db + .select({ username: users.username, role: users.role }) + .from(users) + .where(eq(users.id, idNumerique)) + .limit(1); + const fiche: FicheUtilisateur = ligne + ? { exists: true, role: ligne.role, username: ligne.username, at: maintenant } + : { exists: false, role: "", username: "", at: maintenant }; + cache.set(id, fiche); + return fiche; + } catch (e) { + baseInjoignableDepuis = maintenant; + console.warn("[authz] Base injoignable, contrôle sur le seul jeton de session :", (e as Error).message?.slice(0, 200)); + return null; + } +} + +// ── Contrôles ──────────────────────────────────────────────────────────────── + +/** Utilisateur connecté, relu en base. Lève `AccesRefuse` (401) sinon. */ +export async function requireSession(): Promise { + const session = await auth(); + const jeton = session?.user as { id?: string | number; name?: string | null; role?: string } | undefined; + if (!jeton) throw new AccesRefuse(MESSAGE_SESSION, 401); + + const id = jeton.id != null ? String(jeton.id) : ""; + const fiche = await lireFiche(id); + + if (!fiche) { + // Mode dégradé : le jeton fait foi. + return { id, username: jeton.name ?? "", role: jeton.role ?? "user" }; + } + if (!fiche.exists) throw new AccesRefuse(MESSAGE_SESSION, 401); + return { id, username: fiche.username, role: fiche.role }; +} + +/** Administrateur connecté. Lève `AccesRefuse` (401 ou 403) sinon. */ +export async function requireAdmin(): Promise { + const utilisateur = await requireSession(); + if (utilisateur.role !== "admin") throw new AccesRefuse(MESSAGE_ADMIN, 403); + return utilisateur; +} + +// ── Variantes sans exception ───────────────────────────────────────────────── + +export type Autorisation = + | { ok: true; utilisateur: UtilisateurCourant } + | { ok: false; message: string; statut: 401 | 403 }; + +async function verifier(controle: () => Promise): Promise { + try { + return { ok: true, utilisateur: await controle() }; + } catch (e) { + if (e instanceof AccesRefuse) return { ok: false, message: e.message, statut: e.statut }; + throw e; + } +} + +/** + * Pour les actions qui renvoient un résultat `{ success: false, error }` plutôt + * que de lever une exception, et pour les pages (le `redirect()` doit être appelé + * hors d'un try/catch). + */ +export function verifierSession(): Promise { + return verifier(requireSession); +} + +export function verifierAdmin(): Promise { + return verifier(requireAdmin); +} + +// ── Variantes pour les routes ──────────────────────────────────────────────── + +function reponse(a: Autorisation): UtilisateurCourant | Response { + return a.ok ? a.utilisateur : NextResponse.json({ error: a.message }, { status: a.statut }); +} + +/** + * Utilisateur connecté, ou réponse JSON 401 à renvoyer telle quelle : + * + * const acces = await sessionOuReponse(); + * if (acces instanceof Response) return acces; + */ +export async function sessionOuReponse(): Promise { + return reponse(await verifierSession()); +} + +/** Administrateur connecté, ou réponse JSON 401 / 403 à renvoyer telle quelle. */ +export async function adminOuReponse(): Promise { + return reponse(await verifierAdmin()); +}