mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-16 19:59:30 +02:00
Le serveur étant réécrit en Rust, l'interface devient une application
React 19 côté navigateur, compilée par Vite dans dist/ et servie par le
serveur Rust (STATIC_DIR). Composants, styles et logique cliente
inchangés : seule la plomberie Next.js est remplacée.
- Outillage : Vite 8, @vitejs/plugin-react, Tailwind 4 via PostCSS,
React Router 7 (mode déclaratif), index.html, src/main.tsx, alias @/,
proxy /api → localhost:5643 en développement, ESLint sans plugin Next.
- Adaptations (src/shims) : next/link, next/navigation (useRouter avec
refresh, usePathname/useSearchParams suivant aussi les replaceState de
l'interface), next-auth/react (session lue sur /api/auth/session,
déconnexion par /api/auth/logout).
- Actions serveur → POST /api/actions/{nom} (src/lib/rpc.ts) : mêmes noms,
mêmes signatures, mêmes types. La connexion passe par /api/auth/login.
- Pages → table des routes (src/router.tsx) : données lues sur
/api/pages/{nom} avec Suspense (loading.tsx au premier chargement,
page conservée pendant une navigation ou un router.refresh()),
error.tsx en frontière d'erreur, not-found.tsx, titres d'onglet,
pages d'administration réservées aux administrateurs.
- Suppression du code serveur Next.js (routes /api, accès base, Qlik,
authentification, middleware…) ; types utiles à l'interface recopiés
dans src/types/ff.ts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG2LLJuswrrTTPKJmunEVf
638 lines
29 KiB
TypeScript
638 lines
29 KiB
TypeScript
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
import {
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Eye,
|
|
EyeOff,
|
|
FileText,
|
|
KeyRound,
|
|
Loader2,
|
|
PlugZap,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Save,
|
|
Settings,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { PageHeader } from "@/components/ui/page-header";
|
|
import { Tabs, useUrlTab, type TabItem } from "@/components/ui/tabs";
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input, Label } from "@/components/ui/form-controls";
|
|
import { Terme } from "@/components/ui/tooltip";
|
|
import { toast } from "@/components/ui/feedback";
|
|
import { fmtEntier } from "@/lib/format";
|
|
import { isStaleServerActionError, STALE_ACTION_MESSAGE } from "@/lib/stale-action";
|
|
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
|
import {
|
|
testDatabaseConnection,
|
|
saveDatabaseSettings,
|
|
getParametresAffichables,
|
|
saveQlikSettings,
|
|
testQlikConnection,
|
|
saveFfApiSettings,
|
|
testFfApiConnection,
|
|
} from "@/features/settings/actions";
|
|
import { UserManagement } from "@/features/admin/components/user-management";
|
|
import { ApiKeyManagement } from "@/features/admin/components/api-key-management";
|
|
import { ApiConnectionInfo } from "@/features/admin/components/api-connection-info";
|
|
import { GridWarmup } from "@/features/admin/components/grid-warmup";
|
|
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. Elle ne contient aucun mot de
|
|
* passe : seulement l'indication qu'il y en a un d'enregistré.
|
|
*/
|
|
let savedConfigPromise: ReturnType<typeof getParametresAffichables> | null = null;
|
|
function loadSavedConfig(force = false): ReturnType<typeof getParametresAffichables> {
|
|
if (force || !savedConfigPromise) {
|
|
savedConfigPromise = getParametresAffichables().catch((e) => {
|
|
savedConfigPromise = null;
|
|
throw e;
|
|
});
|
|
}
|
|
return savedConfigPromise;
|
|
}
|
|
|
|
/** Message lisible d'une erreur de server action (onglet périmé compris). */
|
|
function messageErreur(e: unknown): string {
|
|
if (isStaleServerActionError(e)) return STALE_ACTION_MESSAGE;
|
|
return e instanceof Error ? e.message : String(e);
|
|
}
|
|
|
|
function fmtDateHeure(valeur: string | null | undefined): string {
|
|
if (!valeur) return "—";
|
|
const d = new Date(valeur);
|
|
return isNaN(d.getTime()) ? valeur : d.toLocaleString("fr-FR");
|
|
}
|
|
|
|
// ── Onglets ──────────────────────────────────────────────────────────────────
|
|
|
|
type Onglet = "connexions" | "utilisateurs" | "api" | "journal";
|
|
|
|
const ONGLETS: readonly TabItem<Onglet>[] = [
|
|
{ value: "connexions", label: "Connexions", icon: PlugZap },
|
|
{ value: "utilisateurs", label: "Utilisateurs", icon: Users },
|
|
{ value: "api", label: "Accès API", icon: KeyRound },
|
|
{ value: "journal", label: "Journal serveur", icon: FileText },
|
|
];
|
|
|
|
function estOnglet(v: string | undefined): v is Onglet {
|
|
return ONGLETS.some((o) => o.value === v);
|
|
}
|
|
|
|
/**
|
|
* Contenu d'un onglet : monté à la première visite puis seulement masqué, pour
|
|
* qu'une saisie non enregistrée survive au changement d'onglet et que rien ne
|
|
* soit rechargé en revenant.
|
|
*/
|
|
function Panneau({ actif, visite, children }: { actif: boolean; visite: boolean; children: ReactNode }) {
|
|
if (!visite) return null;
|
|
return (
|
|
<div role="tabpanel" hidden={!actif} className="space-y-6">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Éléments de formulaire ───────────────────────────────────────────────────
|
|
|
|
function Champ({ id, label, aide, children, className }: {
|
|
id: string;
|
|
label: ReactNode;
|
|
aide?: ReactNode;
|
|
children: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
return (
|
|
<div className={className}>
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
{aide && <p className="mt-1 text-xs text-[var(--text-muted)]">{aide}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type EtatTest = { etat: "idle" } | { etat: "en-cours" } | { etat: "ok" } | { etat: "erreur"; message: string };
|
|
|
|
/** Résultat d'un test de connexion, affiché à côté des boutons. */
|
|
function ResultatTest({ test }: { test: EtatTest }) {
|
|
if (test.etat === "ok") {
|
|
return (
|
|
<Badge ton="succes">
|
|
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden /> Connexion réussie
|
|
</Badge>
|
|
);
|
|
}
|
|
if (test.etat === "erreur") {
|
|
return (
|
|
<p role="alert" className="flex items-start gap-1.5 text-[13px] text-[var(--accent-error)]">
|
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
<span>
|
|
<span className="font-semibold">Connexion impossible.</span>{" "}
|
|
<span className="text-[var(--text-secondary)]">{test.message}</span>
|
|
</span>
|
|
</p>
|
|
);
|
|
}
|
|
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<void> }) {
|
|
const {
|
|
host, setHost,
|
|
port, setPort,
|
|
database, setDatabase,
|
|
user, setUser,
|
|
password, setPassword,
|
|
ssl, setSsl,
|
|
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<EtatTest>({ etat: "idle" });
|
|
const [enregistrement, setEnregistrement] = useState(false);
|
|
const [rechargement, setRechargement] = useState(false);
|
|
|
|
const tester = async () => {
|
|
setTest({ etat: "en-cours" });
|
|
try {
|
|
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) });
|
|
}
|
|
};
|
|
|
|
const enregistrer = async () => {
|
|
setEnregistrement(true);
|
|
try {
|
|
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 {
|
|
setEnregistrement(false);
|
|
}
|
|
};
|
|
|
|
const rechargerConfig = async () => {
|
|
setRechargement(true);
|
|
try {
|
|
await recharger();
|
|
toast.info("Les valeurs enregistrées sur le serveur ont été rechargées.");
|
|
} catch (e) {
|
|
toast.erreur(`La configuration n'a pas pu être relue : ${messageErreur(e)}`);
|
|
} finally {
|
|
setRechargement(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title="Base de données de l'application (PostgreSQL)"
|
|
description="Contient les comptes utilisateurs, l'historique des sessions et les réglages. Les produits et les ventes, eux, viennent de l'API FF Nancy."
|
|
/>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Champ
|
|
id="pg-hote"
|
|
label="Serveur"
|
|
aide="Adresse du serveur : localhost, une adresse IP (192.168.1.10) ou le nom du service Docker (postgres, db)."
|
|
className="sm:col-span-2"
|
|
>
|
|
<Input id="pg-hote" className="w-full font-mono" placeholder="localhost" value={host}
|
|
onChange={(e) => setHost(e.target.value)} />
|
|
</Champ>
|
|
<Champ id="pg-port" label="Port" aide="5432 dans la plupart des installations.">
|
|
<Input id="pg-port" className="w-full font-mono" placeholder="5432" value={port}
|
|
onChange={(e) => setPort(e.target.value)} />
|
|
</Champ>
|
|
<Champ id="pg-base" label="Nom de la base">
|
|
<Input id="pg-base" className="w-full font-mono" placeholder="collectflow" value={database}
|
|
onChange={(e) => setDatabase(e.target.value)} />
|
|
</Champ>
|
|
<Champ id="pg-utilisateur" label="Utilisateur">
|
|
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
|
|
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
|
|
</Champ>
|
|
<Champ id="pg-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
|
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder={placeholderMotDePasse(motDePasseEnregistre)}
|
|
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
|
</Champ>
|
|
</div>
|
|
|
|
<div>
|
|
<label htmlFor="pg-ssl" className="inline-flex cursor-pointer items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
id="pg-ssl"
|
|
checked={ssl}
|
|
onChange={(e) => setSsl(e.target.checked)}
|
|
className="h-4 w-4 cursor-pointer accent-[var(--accent)]"
|
|
/>
|
|
Connexion chiffrée (SSL)
|
|
</label>
|
|
<p className="mt-1 text-xs text-[var(--text-muted)]">
|
|
À cocher si le serveur de base de données l'exige, en général quand il est hébergé à distance.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
|
<Button onClick={enregistrer} disabled={enregistrement}>
|
|
{enregistrement ? <Loader2 className="animate-spin" /> : <Save />}
|
|
Enregistrer
|
|
</Button>
|
|
<Button variant="outline" onClick={tester} disabled={test.etat === "en-cours"}>
|
|
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
|
Tester la connexion
|
|
</Button>
|
|
<Button variant="ghost" onClick={rechargerConfig} disabled={rechargement}>
|
|
{rechargement ? <Loader2 className="animate-spin" /> : <RotateCcw />}
|
|
Recharger les valeurs enregistrées
|
|
</Button>
|
|
<ResultatTest test={test} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── API FF Nancy ─────────────────────────────────────────────────────────────
|
|
|
|
interface FfSyncTable { nom: string; derniereSync: string; nbLignes?: number; statut?: string; erreur?: string | null; }
|
|
interface FfSyncStatus { lastSync: string; tables: FfSyncTable[]; }
|
|
|
|
function SectionFfApi() {
|
|
const [status, setStatus] = useState<FfSyncStatus | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [url, setUrl] = useState("");
|
|
const [savedUrl, setSavedUrl] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testedUrl, setTestedUrl] = useState<string | null>(null);
|
|
|
|
// Charge l'URL enregistrée pour la préremplir (vide = valeur par défaut).
|
|
useEffect(() => {
|
|
loadSavedConfig()
|
|
.then((cfg) => {
|
|
setSavedUrl(cfg.ffApiBaseUrl || null);
|
|
if (cfg.ffApiBaseUrl) setUrl(cfg.ffApiBaseUrl);
|
|
})
|
|
.catch(() => { /* réglage optionnel ; l'échec de lecture est signalé par la page */ });
|
|
}, []);
|
|
|
|
const runTest = useCallback((candidate?: string) => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setStatus(null);
|
|
testFfApiConnection(candidate)
|
|
.then((res) => {
|
|
setTestedUrl(res.url);
|
|
if (res.success) setStatus(res.status as FfSyncStatus);
|
|
else setError(res.error ?? "Échec inconnu.");
|
|
})
|
|
.catch((e) => setError(messageErreur(e)))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => { runTest(); }, [runTest]);
|
|
|
|
const save = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const res = await saveFfApiSettings(url);
|
|
if (!res.success) {
|
|
toast.erreur(res.error ?? "Enregistrement impossible.");
|
|
return;
|
|
}
|
|
setSavedUrl(url.trim() || null);
|
|
toast.succes(url.trim() ? "Adresse de l'API FF enregistrée." : "L'adresse par défaut de l'API FF sera utilisée.");
|
|
runTest(url);
|
|
} catch (e) {
|
|
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const test: EtatTest = loading ? { etat: "en-cours" } : status ? { etat: "ok" } : error ? { etat: "erreur", message: error } : { etat: "idle" };
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title="API FF Nancy"
|
|
description="Source des produits et des ventes de nos magasins. Ses données sont mises à jour chaque nuit depuis la base FF : elles s'arrêtent donc à la veille."
|
|
/>
|
|
<CardContent className="space-y-4">
|
|
<Champ
|
|
id="ff-url"
|
|
label="Adresse de l'API"
|
|
aide="Laissez vide pour utiliser l'adresse par défaut (https://api.ffnancy.fr)."
|
|
>
|
|
<Input
|
|
id="ff-url"
|
|
type="text"
|
|
placeholder="https://api.ffnancy.fr"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") void save(); }}
|
|
className="w-full font-mono"
|
|
/>
|
|
</Champ>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
|
<Button onClick={save} disabled={saving}>
|
|
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
|
Enregistrer
|
|
</Button>
|
|
<Button variant="outline" onClick={() => runTest(url)} disabled={loading}>
|
|
{loading ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
|
Tester la connexion
|
|
</Button>
|
|
<ResultatTest test={test} />
|
|
</div>
|
|
|
|
{/* L'URL réellement appelée : évite de croire qu'on teste celle du champ
|
|
alors que le réglage enregistré ou la variable d'environnement prime. */}
|
|
{testedUrl && (
|
|
<p className="text-xs text-[var(--text-muted)]">
|
|
Adresse testée : <span className="font-mono">{testedUrl}</span>
|
|
{!savedUrl && <> (adresse par défaut, aucun réglage enregistré)</>}
|
|
</p>
|
|
)}
|
|
|
|
{status && (
|
|
<div className="space-y-2">
|
|
<p className="text-[13px] text-[var(--text-secondary)]">
|
|
Dernière mise à jour complète des données :{" "}
|
|
<span className="font-medium text-[var(--text-primary)]">{fmtDateHeure(status.lastSync)}</span>
|
|
</p>
|
|
{status.tables?.length > 0 && (
|
|
<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">Table de données</th>
|
|
<th scope="col" className="px-3 py-2 text-right">Mise à jour</th>
|
|
<th scope="col" className="px-3 py-2 text-right">Lignes</th>
|
|
<th scope="col" className="px-3 py-2 text-right">État</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{status.tables.map((t) => (
|
|
<tr key={t.nom} className="border-t border-[var(--border)]">
|
|
<td className="px-3 py-2 text-sm font-mono text-[var(--text-primary)]">{t.nom}</td>
|
|
<td className="px-3 py-2 text-sm text-right text-[var(--text-secondary)]">{fmtDateHeure(t.derniereSync)}</td>
|
|
<td className="px-3 py-2 text-sm text-right tabular-nums text-[var(--text-secondary)]">
|
|
{t.nbLignes != null ? fmtEntier(t.nbLignes) : "—"}
|
|
</td>
|
|
{/* Une table en erreur est précisément ce qu'on vient chercher ici. */}
|
|
<td className="px-3 py-2 text-sm text-right">
|
|
{t.statut === "ok"
|
|
? <Badge ton="succes">OK</Badge>
|
|
: <Badge ton="erreur" title={t.erreur ?? undefined}>{t.statut ?? "—"}</Badge>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Qlik Sense ───────────────────────────────────────────────────────────────
|
|
|
|
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<EtatTest>({ etat: "idle" });
|
|
|
|
useEffect(() => {
|
|
loadSavedConfig()
|
|
.then((c) => {
|
|
setHost(c.qlik.host);
|
|
setUser(c.qlik.user);
|
|
setMotDePasseEnregistre(c.qlik.motDePasseEnregistre);
|
|
})
|
|
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
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 {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
setTest({ etat: "en-cours" });
|
|
try {
|
|
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) });
|
|
}
|
|
};
|
|
|
|
// Mot de passe vide accepté s'il y en a un d'enregistré : le serveur le reprend.
|
|
const testImpossible = !user || (!password && !motDePasseEnregistre);
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title="Qlik Sense (données du réseau)"
|
|
description={
|
|
<>
|
|
Identifiants utilisés pour récupérer les ventes des magasins du{" "}
|
|
<Terme id="reseau">réseau</Terme> dans l'outil de reporting Qlik Sense de La Foir'Fouille.
|
|
</>
|
|
}
|
|
/>
|
|
<CardContent className="space-y-4">
|
|
<Champ
|
|
id="qlik-hote"
|
|
label="Serveur Qlik"
|
|
aide="Laissez vide pour utiliser le serveur habituel : reporting-magasins.lafoirfouille.fr."
|
|
>
|
|
<Input id="qlik-hote" type="text" placeholder="reporting-magasins.lafoirfouille.fr" value={host}
|
|
onChange={(e) => setHost(e.target.value)} className="w-full font-mono" />
|
|
</Champ>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Champ
|
|
id="qlik-utilisateur"
|
|
label="Identifiant"
|
|
aide="Identifiant Windows sans le domaine, par exemple FFSCH (connexion par authentification Windows, dite NTLM)."
|
|
>
|
|
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
|
|
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
|
|
</Champ>
|
|
<Champ id="qlik-mdp" label="Mot de passe" aide={motDePasseEnregistre ? AIDE_MOT_DE_PASSE_ENREGISTRE : undefined}>
|
|
<div className="relative">
|
|
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder={placeholderMotDePasse(motDePasseEnregistre)} value={password}
|
|
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setShowPwd((v) => !v)}
|
|
aria-label={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
|
title={showPwd ? "Masquer le mot de passe" : "Afficher le mot de passe"}
|
|
className="absolute right-0.5 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"
|
|
>
|
|
{showPwd ? <EyeOff /> : <Eye />}
|
|
</Button>
|
|
</div>
|
|
</Champ>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 border-t border-[var(--border)] pt-4">
|
|
<Button onClick={handleSave} disabled={saving}>
|
|
{saving ? <Loader2 className="animate-spin" /> : <Save />}
|
|
Enregistrer
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleTest}
|
|
disabled={test.etat === "en-cours" || testImpossible}
|
|
title={testImpossible ? "Renseignez l'identifiant et le mot de passe pour tester" : undefined}
|
|
>
|
|
{test.etat === "en-cours" ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
|
Tester la connexion
|
|
</Button>
|
|
<ResultatTest test={test} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
|
|
|
export function ParametresClient({ onglet: ongletDemande }: { onglet?: string }) {
|
|
const initial: Onglet = estOnglet(ongletDemande) ? ongletDemande : "connexions";
|
|
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
|
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => new Set([initial]));
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
|
|
const setHost = useDbSettingsStore((s) => s.setHost);
|
|
const setPort = useDbSettingsStore((s) => s.setPort);
|
|
const setDatabase = useDbSettingsStore((s) => s.setDatabase);
|
|
const setUser = useDbSettingsStore((s) => s.setUser);
|
|
const setPassword = useDbSettingsStore((s) => s.setPassword);
|
|
const setSsl = useDbSettingsStore((s) => s.setSsl);
|
|
const setMotDePasseEnregistre = useDbSettingsStore((s) => s.setMotDePasseEnregistre);
|
|
|
|
const reloadFromServer = useCallback(async (force = false) => {
|
|
const { db } = await loadSavedConfig(force);
|
|
// Le mot de passe n'est jamais renvoyé : champ vide = conserver celui enregistré.
|
|
setPassword("");
|
|
setMotDePasseEnregistre(db?.motDePasseEnregistre ?? false);
|
|
if (db) {
|
|
setHost(db.host);
|
|
setPort(db.port);
|
|
setDatabase(db.database);
|
|
setUser(db.user);
|
|
setSsl(db.ssl);
|
|
}
|
|
}, [setDatabase, setHost, setMotDePasseEnregistre, setPassword, setPort, setSsl, setUser]);
|
|
|
|
useEffect(() => {
|
|
// `isMounted` sert à éviter un écart d'hydratation (réglages PostgreSQL
|
|
// lus dans le localStorage) : il doit donc être posé au montage, ce que
|
|
// la règle déconseille en général mais qui est ici le but.
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setIsMounted(true);
|
|
// Lecture fraîche à chaque ouverture de la page ; les sections, montées
|
|
// juste après (cf. `isMounted`), réutilisent cette même requête.
|
|
reloadFromServer(true).catch((e) => {
|
|
toast.erreur(`La configuration enregistrée n'a pas pu être lue : ${messageErreur(e)}`);
|
|
});
|
|
}, [reloadFromServer]);
|
|
|
|
const changerOnglet = (suivant: Onglet) => {
|
|
setOnglet(suivant);
|
|
setVisites((prev) => (prev.has(suivant) ? prev : new Set([...prev, suivant])));
|
|
};
|
|
|
|
const recharger = useCallback(() => reloadFromServer(true), [reloadFromServer]);
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-5xl">
|
|
<PageHeader
|
|
icon={Settings}
|
|
title="Paramètres"
|
|
description="Connexions aux sources de données, comptes des utilisateurs et clés d'accès à l'API (réservé aux administrateurs)."
|
|
/>
|
|
|
|
<Tabs items={ONGLETS} value={onglet} onChange={changerOnglet} className="mb-6" />
|
|
|
|
{isMounted && (
|
|
<>
|
|
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
|
|
<SectionPostgres recharger={recharger} />
|
|
<SectionFfApi />
|
|
<SectionQlik />
|
|
</Panneau>
|
|
|
|
<Panneau actif={onglet === "utilisateurs"} visite={visites.has("utilisateurs")}>
|
|
<UserManagement />
|
|
</Panneau>
|
|
|
|
<Panneau actif={onglet === "api"} visite={visites.has("api")}>
|
|
<ApiConnectionInfo />
|
|
<ApiKeyManagement />
|
|
<GridWarmup />
|
|
</Panneau>
|
|
|
|
<Panneau actif={onglet === "journal"} visite={visites.has("journal")}>
|
|
<ServerLogs />
|
|
</Panneau>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|