mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-18 20:56:43 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73c74bf579 | ||
|
|
af12527731 |
No files matched your search
@@ -144,7 +144,11 @@ function ResultatTest({ test }: { test: EtatTest }) {
|
||||
|
||||
// ── PostgreSQL ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
/**
|
||||
* `motDePasseEnregistre` : le serveur ne renvoie jamais le mot de passe, seulement
|
||||
* qu'il en existe un. Le champ reste alors vide et « vide » veut dire « inchangé ».
|
||||
*/
|
||||
function SectionPostgres({ recharger, motDePasseEnregistre }: { recharger: () => Promise<void>; motDePasseEnregistre: boolean }) {
|
||||
const {
|
||||
host, setHost,
|
||||
port, setPort,
|
||||
@@ -173,8 +177,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
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"}`);
|
||||
if (res.success) {
|
||||
toast.succes("Connexion à la base PostgreSQL enregistrée.");
|
||||
// Un mot de passe vient d'être saisi : on relit la config pour vider
|
||||
// le champ et afficher qu'il est désormais enregistré.
|
||||
if (password) await recharger().catch(() => { /* simple rafraîchissement */ });
|
||||
} else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
|
||||
} catch (e) {
|
||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||
} finally {
|
||||
@@ -223,8 +231,13 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
||||
<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">
|
||||
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
|
||||
<Champ
|
||||
id="pg-mdp"
|
||||
label="Mot de passe"
|
||||
aide={motDePasseEnregistre ? "Laissez vide pour garder le mot de passe enregistré (même serveur et même utilisateur)." : undefined}
|
||||
>
|
||||
<Input id="pg-mdp" type="password" className="w-full font-mono"
|
||||
placeholder={motDePasseEnregistre ? "Enregistré — inchangé si vide" : "••••••••"}
|
||||
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||
</Champ>
|
||||
</div>
|
||||
@@ -419,6 +432,8 @@ function SectionQlik() {
|
||||
const [host, setHost] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
// Le mot de passe enregistré n'est jamais renvoyé : on sait seulement qu'il existe.
|
||||
const [motDePasseEnregistre, setMotDePasseEnregistre] = useState(false);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||
@@ -429,7 +444,7 @@ function SectionQlik() {
|
||||
if (!c) return;
|
||||
setHost(c.qlikHost ?? "");
|
||||
setUser(c.qlikUser ?? "");
|
||||
setPassword(c.qlikPassword ?? "");
|
||||
setMotDePasseEnregistre(c.hasQlikPassword);
|
||||
})
|
||||
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
||||
}, []);
|
||||
@@ -438,8 +453,13 @@ function SectionQlik() {
|
||||
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"}`);
|
||||
if (res.success) {
|
||||
toast.succes("Identifiants Qlik enregistrés.");
|
||||
if (password) {
|
||||
setMotDePasseEnregistre(true);
|
||||
setPassword("");
|
||||
}
|
||||
} else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
||||
} catch (e) {
|
||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||
} finally {
|
||||
@@ -457,7 +477,7 @@ function SectionQlik() {
|
||||
}
|
||||
};
|
||||
|
||||
const testImpossible = !user || !password;
|
||||
const testImpossible = !user || (!password && !motDePasseEnregistre);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -488,9 +508,14 @@ function SectionQlik() {
|
||||
<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">
|
||||
<Champ
|
||||
id="qlik-mdp"
|
||||
label="Mot de passe"
|
||||
aide={motDePasseEnregistre ? "Laissez vide pour garder le mot de passe enregistré (même serveur et même identifiant)." : undefined}
|
||||
>
|
||||
<div className="relative">
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
|
||||
<Input id="qlik-mdp" type={showPwd ? "text" : "password"}
|
||||
placeholder={motDePasseEnregistre ? "Enregistré — inchangé si vide" : "••••••••"} value={password}
|
||||
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -535,6 +560,7 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
||||
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
||||
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => new Set([initial]));
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [pgMotDePasseEnregistre, setPgMotDePasseEnregistre] = useState(false);
|
||||
|
||||
const setHost = useDbSettingsStore((s) => s.setHost);
|
||||
const setPort = useDbSettingsStore((s) => s.setPort);
|
||||
@@ -553,7 +579,11 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
||||
setPort(url.port || "5432");
|
||||
setDatabase(url.pathname.slice(1).split("?")[0]);
|
||||
setUser(url.username);
|
||||
setPassword(decodeURIComponent(url.password));
|
||||
// Le serveur ne renvoie plus le mot de passe : champ vide = garder
|
||||
// l'enregistré. On efface aussi celui qu'une ancienne version a pu
|
||||
// laisser dans le localStorage.
|
||||
setPassword("");
|
||||
setPgMotDePasseEnregistre(config.hasDbPassword);
|
||||
setSsl(config.url.includes("sslmode=require"));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse saved URL", e);
|
||||
@@ -594,7 +624,7 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
||||
{isMounted && (
|
||||
<>
|
||||
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
|
||||
<SectionPostgres recharger={recharger} />
|
||||
<SectionPostgres recharger={recharger} motDePasseEnregistre={pgMotDePasseEnregistre} />
|
||||
<SectionFfApi />
|
||||
<SectionQlik />
|
||||
</Panneau>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { testFfApiConnection } from "@/features/settings/actions";
|
||||
import { diagnoseFfApi } from "@/lib/api-ff-client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
|
||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||
*/
|
||||
export async function GET() {
|
||||
const res = await testFfApiConnection();
|
||||
const res = await diagnoseFfApi();
|
||||
if (!res.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||
|
||||
@@ -11,18 +11,10 @@
|
||||
import { db } from "@/db";
|
||||
import { apiKeys } from "@/db/schema";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { generateApiKey } from "@/lib/api-auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export interface ApiKeyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -36,7 +28,7 @@ export interface ApiKeyRow {
|
||||
|
||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
const rows = await db
|
||||
.select({
|
||||
id: apiKeys.id,
|
||||
@@ -67,7 +59,7 @@ export async function createApiKey(
|
||||
name: string,
|
||||
role: "admin" | "user" = "user",
|
||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||
const session = await ensureAdmin();
|
||||
const session = await requireAdmin();
|
||||
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
||||
@@ -92,7 +84,7 @@ export async function createApiKey(
|
||||
|
||||
/** Révoque une clé : elle est refusée dès l'appel suivant, mais reste listée. */
|
||||
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
@@ -105,7 +97,7 @@ export async function revokeApiKey(id: number): Promise<{ success: boolean; erro
|
||||
|
||||
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||
revalidatePath("/settings");
|
||||
|
||||
@@ -4,24 +4,14 @@ import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { hashPassword } from "@/features/auth/logic/auth-logic";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
/**
|
||||
* Vérifie si l'utilisateur actuel est un administrateur.
|
||||
*/
|
||||
async function ensureAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as any)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère tous les utilisateurs (Admin seulement).
|
||||
*/
|
||||
export async function getUsers() {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
return db.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
@@ -34,7 +24,7 @@ export async function getUsers() {
|
||||
* Crée un nouvel utilisateur.
|
||||
*/
|
||||
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
|
||||
try {
|
||||
await db.insert(users).values({
|
||||
@@ -54,10 +44,8 @@ export async function createUser(username: string, password: string, role: "admi
|
||||
* Supprime un utilisateur.
|
||||
*/
|
||||
export async function deleteUser(id: number) {
|
||||
await ensureAdmin();
|
||||
|
||||
const session = await auth();
|
||||
if (Number((session?.user as any)?.id) === id) {
|
||||
const session = await requireAdmin();
|
||||
if (Number((session?.user as { id?: string } | undefined)?.id) === id) {
|
||||
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
|
||||
}
|
||||
|
||||
@@ -74,7 +62,7 @@ export async function deleteUser(id: number) {
|
||||
* Met à jour le mot de passe d'un utilisateur.
|
||||
*/
|
||||
export async function updatePassword(id: number, newPassword: string) {
|
||||
await ensureAdmin();
|
||||
await requireAdmin();
|
||||
try {
|
||||
await db.update(users)
|
||||
.set({ passwordHash: hashPassword(newPassword) })
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Pool } from "pg";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), "data");
|
||||
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||
@@ -27,6 +28,17 @@ export interface DbConfig {
|
||||
ffApiBaseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce que la page Paramètres reçoit : jamais de secret, seulement leur présence.
|
||||
* Le mot de passe est retiré de `url` ; le mot de passe Qlik et les clés IA sont omis.
|
||||
*/
|
||||
export type PublicDbConfig = Omit<DbConfig, "qlikPassword" | "openRouterKey" | "googleAiKey"> & {
|
||||
hasDbPassword: boolean;
|
||||
hasQlikPassword: boolean;
|
||||
hasOpenRouterKey: boolean;
|
||||
hasGoogleAiKey: boolean;
|
||||
};
|
||||
|
||||
/** Lit la config existante (ou {} si absente). */
|
||||
async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
try {
|
||||
@@ -37,7 +49,47 @@ async function readConfig(): Promise<Partial<DbConfig>> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(url: string) {
|
||||
function parseDbUrl(url: string): URL | null {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complète une URL saisie sans mot de passe avec celui déjà enregistré : le champ
|
||||
* est vide dans le navigateur, puisque le secret n'y est plus envoyé.
|
||||
*
|
||||
* Seulement pour le même hôte, port et utilisateur — sinon le mot de passe masqué
|
||||
* pourrait être envoyé à un serveur arbitraire par simple test de connexion.
|
||||
*/
|
||||
async function withSavedDbPassword(url: string): Promise<string> {
|
||||
const next = parseDbUrl(url);
|
||||
if (!next || next.password) return url;
|
||||
const savedUrl = (await readConfig()).url;
|
||||
const saved = savedUrl ? parseDbUrl(savedUrl) : null;
|
||||
if (!saved?.password) return url;
|
||||
const sameTarget =
|
||||
saved.hostname.toLowerCase() === next.hostname.toLowerCase() &&
|
||||
(saved.port || "5432") === (next.port || "5432") &&
|
||||
saved.username === next.username;
|
||||
if (!sameTarget) return url;
|
||||
next.password = saved.password;
|
||||
return next.toString();
|
||||
}
|
||||
|
||||
/** Même règle pour Qlik : un mot de passe vide ne réutilise l'enregistré que pour la même cible. */
|
||||
function isSameQlikTarget(cfg: Partial<DbConfig>, qlikHost: string, qlikUser: string): boolean {
|
||||
return (
|
||||
(cfg.qlikHost ?? "").trim().toLowerCase() === qlikHost.trim().toLowerCase() &&
|
||||
(cfg.qlikUser ?? "").trim().toLowerCase() === qlikUser.trim().toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
export async function testDatabaseConnection(rawUrl: string) {
|
||||
await requireAdmin();
|
||||
const url = await withSavedDbPassword(rawUrl);
|
||||
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
|
||||
const pool = new Pool({
|
||||
connectionString: url,
|
||||
@@ -66,10 +118,18 @@ export async function saveDatabaseSettings(
|
||||
googleAiKey?: string,
|
||||
googleAiModel?: string,
|
||||
) {
|
||||
await requireAdmin();
|
||||
try {
|
||||
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik)
|
||||
// Merge avec l'existant pour préserver les autres réglages (ex: Qlik, IA).
|
||||
// Un champ absent ou un secret vide laisse la valeur enregistrée intacte :
|
||||
// la page n'envoie que l'URL, et jamais les clés qu'elle ne reçoit plus.
|
||||
const existing = await readConfig();
|
||||
const config: DbConfig = { ...existing, url, openRouterKey, openRouterModel, aiProvider, googleAiKey, googleAiModel };
|
||||
const config: DbConfig = { ...existing, url: await withSavedDbPassword(url) };
|
||||
if (openRouterKey) config.openRouterKey = openRouterKey;
|
||||
if (openRouterModel !== undefined) config.openRouterModel = openRouterModel;
|
||||
if (aiProvider !== undefined) config.aiProvider = aiProvider;
|
||||
if (googleAiKey) config.googleAiKey = googleAiKey;
|
||||
if (googleAiModel !== undefined) config.googleAiModel = googleAiModel;
|
||||
|
||||
// S'assurer que le dossier data existe
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
@@ -91,9 +151,12 @@ export async function saveDatabaseSettings(
|
||||
}
|
||||
|
||||
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
await requireAdmin();
|
||||
try {
|
||||
const existing = await readConfig();
|
||||
const config = { ...existing, qlikHost, qlikUser, qlikPassword } as DbConfig;
|
||||
// Mot de passe vide = conserver l'enregistré (le champ n'est plus prérempli).
|
||||
const password = qlikPassword || (isSameQlikTarget(existing, qlikHost, qlikUser) ? existing.qlikPassword : undefined);
|
||||
const config = { ...existing, qlikHost, qlikUser, qlikPassword: password } as DbConfig;
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
console.log("[Settings] Qlik configuration saved.");
|
||||
@@ -106,9 +169,15 @@ export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikP
|
||||
}
|
||||
|
||||
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||
await requireAdmin();
|
||||
try {
|
||||
let password = qlikPassword;
|
||||
if (!password) {
|
||||
const existing = await readConfig();
|
||||
if (isSameQlikTarget(existing, qlikHost, qlikUser)) password = existing.qlikPassword ?? "";
|
||||
}
|
||||
const { getQlikConfig, qlikNtlmSession } = await import("@/lib/qlik-client");
|
||||
const cfg = { ...getQlikConfig(), user: qlikUser, password: qlikPassword };
|
||||
const cfg = { ...getQlikConfig(), user: qlikUser, password };
|
||||
if (qlikHost) cfg.host = qlikHost;
|
||||
await qlikNtlmSession(cfg);
|
||||
return { success: true };
|
||||
@@ -120,6 +189,7 @@ 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) {
|
||||
await requireAdmin();
|
||||
try {
|
||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
||||
@@ -142,68 +212,40 @@ 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.
|
||||
* Teste l'API FF Nancy (URL saisie, ou à défaut celle enregistrée) — voir
|
||||
* `diagnoseFfApi`. Réservé aux administrateurs : sans cette garde, n'importe quel
|
||||
* client connecté pourrait faire appeler une URL arbitraire par le serveur.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
await requireAdmin();
|
||||
const { diagnoseFfApi } = await import("@/lib/api-ff-client");
|
||||
return diagnoseFfApi(ffApiBaseUrl);
|
||||
}
|
||||
|
||||
export async function getSavedDatabaseConfig(): Promise<DbConfig | null> {
|
||||
/** Config enregistrée, secrets masqués (voir `PublicDbConfig`). */
|
||||
export async function getSavedDatabaseConfig(): Promise<PublicDbConfig | null> {
|
||||
await requireAdmin();
|
||||
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);
|
||||
const config = JSON.parse(data) as DbConfig;
|
||||
console.log(`[Settings] Config read from ${CONFIG_FILE}. Key present: ${!!config.openRouterKey}, Model: ${config.openRouterModel || "default"}`);
|
||||
return config;
|
||||
const { qlikPassword, openRouterKey, googleAiKey, ...rest } = config;
|
||||
const parsedUrl = config.url ? parseDbUrl(config.url) : null;
|
||||
const hasDbPassword = !!parsedUrl?.password;
|
||||
if (parsedUrl) parsedUrl.password = "";
|
||||
return {
|
||||
...rest,
|
||||
// URL illisible : on n'en renvoie rien plutôt que de risquer d'exposer le mot de passe.
|
||||
url: parsedUrl ? parsedUrl.toString() : "",
|
||||
hasDbPassword,
|
||||
hasQlikPassword: !!qlikPassword,
|
||||
hasOpenRouterKey: !!openRouterKey,
|
||||
hasGoogleAiKey: !!googleAiKey,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
|
||||
return null;
|
||||
|
||||
@@ -48,6 +48,8 @@ export const useDbSettingsStore = create<DatabaseSettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: "collectflow-db-settings",
|
||||
// Le mot de passe ne doit pas rester en clair dans le localStorage.
|
||||
partialize: ({ host, port, database, user, ssl }) => ({ host, port, database, user, ssl }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -620,6 +620,61 @@ export async function getSyncStatus(): Promise<FfSyncStatus | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `baseOverride` permet de tester une URL avant de l'enregistrer : seule l'action
|
||||
* des Paramètres (réservée aux administrateurs) doit le transmettre.
|
||||
*/
|
||||
export async function diagnoseFfApi(baseOverride?: string) {
|
||||
const base = (baseOverride?.trim() || (await getFfApiBase())).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 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 };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sites (magasins)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -73,3 +73,18 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
})
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Garde des actions serveur réservées aux administrateurs.
|
||||
*
|
||||
* Une action serveur est appelable directement par n'importe quel client connecté :
|
||||
* la restriction de `/settings` dans le middleware ne la protège pas. Lève si la
|
||||
* session n'est pas admin et renvoie la session sinon.
|
||||
*/
|
||||
export async function requireAdmin() {
|
||||
const session = await auth();
|
||||
if ((session?.user as { role?: string } | undefined)?.role !== "admin") {
|
||||
throw new Error("Accès refusé : Droits administrateur requis.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
Reference in new issue
Block a user