mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-23 15:08:12 +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 ───────────────────────────────────────────────────────────────
|
// ── 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 {
|
const {
|
||||||
host, setHost,
|
host, setHost,
|
||||||
port, setPort,
|
port, setPort,
|
||||||
@@ -173,8 +177,12 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
|||||||
setEnregistrement(true);
|
setEnregistrement(true);
|
||||||
try {
|
try {
|
||||||
const res = await saveDatabaseSettings(getDatabaseUrl());
|
const res = await saveDatabaseSettings(getDatabaseUrl());
|
||||||
if (res.success) toast.succes("Connexion à la base PostgreSQL enregistrée.");
|
if (res.success) {
|
||||||
else toast.erreur(`Enregistrement impossible : ${res.error ?? "erreur inconnue"}`);
|
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) {
|
} catch (e) {
|
||||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -223,8 +231,13 @@ function SectionPostgres({ recharger }: { recharger: () => Promise<void> }) {
|
|||||||
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
|
<Input id="pg-utilisateur" className="w-full font-mono" placeholder="postgres" value={user}
|
||||||
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
|
onChange={(e) => setUser(e.target.value)} autoComplete="off" />
|
||||||
</Champ>
|
</Champ>
|
||||||
<Champ id="pg-mdp" label="Mot de passe">
|
<Champ
|
||||||
<Input id="pg-mdp" type="password" className="w-full font-mono" placeholder="••••••••"
|
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" />
|
value={password || ""} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||||
</Champ>
|
</Champ>
|
||||||
</div>
|
</div>
|
||||||
@@ -419,6 +432,8 @@ function SectionQlik() {
|
|||||||
const [host, setHost] = useState("");
|
const [host, setHost] = useState("");
|
||||||
const [user, setUser] = useState("");
|
const [user, setUser] = useState("");
|
||||||
const [password, setPassword] = 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 [showPwd, setShowPwd] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
const [test, setTest] = useState<EtatTest>({ etat: "idle" });
|
||||||
@@ -429,7 +444,7 @@ function SectionQlik() {
|
|||||||
if (!c) return;
|
if (!c) return;
|
||||||
setHost(c.qlikHost ?? "");
|
setHost(c.qlikHost ?? "");
|
||||||
setUser(c.qlikUser ?? "");
|
setUser(c.qlikUser ?? "");
|
||||||
setPassword(c.qlikPassword ?? "");
|
setMotDePasseEnregistre(c.hasQlikPassword);
|
||||||
})
|
})
|
||||||
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
.catch(() => { /* l'échec de lecture est signalé par la page */ });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -438,8 +453,13 @@ function SectionQlik() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
const res = await saveQlikSettings(host.trim(), user.trim(), password);
|
||||||
if (res.success) toast.succes("Identifiants Qlik enregistrés.");
|
if (res.success) {
|
||||||
else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
toast.succes("Identifiants Qlik enregistrés.");
|
||||||
|
if (password) {
|
||||||
|
setMotDePasseEnregistre(true);
|
||||||
|
setPassword("");
|
||||||
|
}
|
||||||
|
} else toast.erreur(`Enregistrement impossible : ${res.error || "erreur inconnue"}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
toast.erreur(`Enregistrement impossible : ${messageErreur(e)}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -457,7 +477,7 @@ function SectionQlik() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const testImpossible = !user || !password;
|
const testImpossible = !user || (!password && !motDePasseEnregistre);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -488,9 +508,14 @@ function SectionQlik() {
|
|||||||
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
|
<Input id="qlik-utilisateur" type="text" placeholder="FFSCH" value={user}
|
||||||
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
|
onChange={(e) => setUser(e.target.value)} className="w-full font-mono" autoComplete="off" />
|
||||||
</Champ>
|
</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">
|
<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" />
|
onChange={(e) => setPassword(e.target.value)} className="w-full pr-10 font-mono" autoComplete="new-password" />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -535,6 +560,7 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
|||||||
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
const [onglet, setOnglet] = useUrlTab<Onglet>("onglet", initial);
|
||||||
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => new Set([initial]));
|
const [visites, setVisites] = useState<ReadonlySet<Onglet>>(() => new Set([initial]));
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
const [pgMotDePasseEnregistre, setPgMotDePasseEnregistre] = useState(false);
|
||||||
|
|
||||||
const setHost = useDbSettingsStore((s) => s.setHost);
|
const setHost = useDbSettingsStore((s) => s.setHost);
|
||||||
const setPort = useDbSettingsStore((s) => s.setPort);
|
const setPort = useDbSettingsStore((s) => s.setPort);
|
||||||
@@ -553,7 +579,11 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
|||||||
setPort(url.port || "5432");
|
setPort(url.port || "5432");
|
||||||
setDatabase(url.pathname.slice(1).split("?")[0]);
|
setDatabase(url.pathname.slice(1).split("?")[0]);
|
||||||
setUser(url.username);
|
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"));
|
setSsl(config.url.includes("sslmode=require"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to parse saved URL", e);
|
console.error("Failed to parse saved URL", e);
|
||||||
@@ -594,7 +624,7 @@ export function ParametresClient({ onglet: ongletDemande }: { onglet?: string })
|
|||||||
{isMounted && (
|
{isMounted && (
|
||||||
<>
|
<>
|
||||||
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
|
<Panneau actif={onglet === "connexions"} visite={visites.has("connexions")}>
|
||||||
<SectionPostgres recharger={recharger} />
|
<SectionPostgres recharger={recharger} motDePasseEnregistre={pgMotDePasseEnregistre} />
|
||||||
<SectionFfApi />
|
<SectionFfApi />
|
||||||
<SectionQlik />
|
<SectionQlik />
|
||||||
</Panneau>
|
</Panneau>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { testFfApiConnection } from "@/features/settings/actions";
|
import { diagnoseFfApi } from "@/lib/api-ff-client";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -13,7 +13,7 @@ export const dynamic = "force-dynamic";
|
|||||||
* détail, impossible à distinguer d'une simple erreur d'URL.
|
* détail, impossible à distinguer d'une simple erreur d'URL.
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const res = await testFfApiConnection();
|
const res = await diagnoseFfApi();
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
{ error: "API FF Nancy non disponible", url: res.url, detail: res.error },
|
||||||
|
|||||||
@@ -11,18 +11,10 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { apiKeys } from "@/db/schema";
|
import { apiKeys } from "@/db/schema";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/lib/auth";
|
import { requireAdmin } from "@/lib/auth";
|
||||||
import { generateApiKey } from "@/lib/api-auth";
|
import { generateApiKey } from "@/lib/api-auth";
|
||||||
import { revalidatePath } from "next/cache";
|
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 {
|
export interface ApiKeyRow {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -36,7 +28,7 @@ export interface ApiKeyRow {
|
|||||||
|
|
||||||
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
/** Liste les clés. Ne renvoie jamais de secret — seulement le préfixe lisible. */
|
||||||
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
export async function getApiKeys(): Promise<ApiKeyRow[]> {
|
||||||
await ensureAdmin();
|
await requireAdmin();
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
id: apiKeys.id,
|
id: apiKeys.id,
|
||||||
@@ -67,7 +59,7 @@ export async function createApiKey(
|
|||||||
name: string,
|
name: string,
|
||||||
role: "admin" | "user" = "user",
|
role: "admin" | "user" = "user",
|
||||||
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
): Promise<{ success: true; key: string; keyPrefix: string } | { success: false; error: string }> {
|
||||||
const session = await ensureAdmin();
|
const session = await requireAdmin();
|
||||||
|
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return { success: false, error: "Le nom de la clé est obligatoire." };
|
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. */
|
/** 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 }> {
|
export async function revokeApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||||
await ensureAdmin();
|
await requireAdmin();
|
||||||
try {
|
try {
|
||||||
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
await db.update(apiKeys).set({ revokedAt: new Date() }).where(eq(apiKeys.id, id));
|
||||||
revalidatePath("/settings");
|
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). */
|
/** Supprime définitivement une clé révoquée (nettoyage de la liste). */
|
||||||
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
export async function deleteApiKey(id: number): Promise<{ success: boolean; error?: string }> {
|
||||||
await ensureAdmin();
|
await requireAdmin();
|
||||||
try {
|
try {
|
||||||
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
await db.delete(apiKeys).where(eq(apiKeys.id, id));
|
||||||
revalidatePath("/settings");
|
revalidatePath("/settings");
|
||||||
|
|||||||
@@ -4,24 +4,14 @@ import { db } from "@/db";
|
|||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { hashPassword } from "@/features/auth/logic/auth-logic";
|
import { hashPassword } from "@/features/auth/logic/auth-logic";
|
||||||
import { auth } from "@/lib/auth";
|
import { requireAdmin } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
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).
|
* Récupère tous les utilisateurs (Admin seulement).
|
||||||
*/
|
*/
|
||||||
export async function getUsers() {
|
export async function getUsers() {
|
||||||
await ensureAdmin();
|
await requireAdmin();
|
||||||
return db.select({
|
return db.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
username: users.username,
|
username: users.username,
|
||||||
@@ -34,7 +24,7 @@ export async function getUsers() {
|
|||||||
* Crée un nouvel utilisateur.
|
* Crée un nouvel utilisateur.
|
||||||
*/
|
*/
|
||||||
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
||||||
await ensureAdmin();
|
await requireAdmin();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.insert(users).values({
|
await db.insert(users).values({
|
||||||
@@ -54,10 +44,8 @@ export async function createUser(username: string, password: string, role: "admi
|
|||||||
* Supprime un utilisateur.
|
* Supprime un utilisateur.
|
||||||
*/
|
*/
|
||||||
export async function deleteUser(id: number) {
|
export async function deleteUser(id: number) {
|
||||||
await ensureAdmin();
|
const session = await requireAdmin();
|
||||||
|
if (Number((session?.user as { id?: string } | undefined)?.id) === id) {
|
||||||
const session = await auth();
|
|
||||||
if (Number((session?.user as any)?.id) === id) {
|
|
||||||
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
|
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.
|
* Met à jour le mot de passe d'un utilisateur.
|
||||||
*/
|
*/
|
||||||
export async function updatePassword(id: number, newPassword: string) {
|
export async function updatePassword(id: number, newPassword: string) {
|
||||||
await ensureAdmin();
|
await requireAdmin();
|
||||||
try {
|
try {
|
||||||
await db.update(users)
|
await db.update(users)
|
||||||
.set({ passwordHash: hashPassword(newPassword) })
|
.set({ passwordHash: hashPassword(newPassword) })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
import { requireAdmin } from "@/lib/auth";
|
||||||
|
|
||||||
const DATA_DIR = path.join(process.cwd(), "data");
|
const DATA_DIR = path.join(process.cwd(), "data");
|
||||||
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
|
||||||
@@ -27,6 +28,17 @@ export interface DbConfig {
|
|||||||
ffApiBaseUrl?: string;
|
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). */
|
/** Lit la config existante (ou {} si absente). */
|
||||||
async function readConfig(): Promise<Partial<DbConfig>> {
|
async function readConfig(): Promise<Partial<DbConfig>> {
|
||||||
try {
|
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(/:([^@]+)@/, ":****@"));
|
console.log("Testing connection to:", url.replace(/:([^@]+)@/, ":****@"));
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
connectionString: url,
|
connectionString: url,
|
||||||
@@ -66,10 +118,18 @@ export async function saveDatabaseSettings(
|
|||||||
googleAiKey?: string,
|
googleAiKey?: string,
|
||||||
googleAiModel?: string,
|
googleAiModel?: string,
|
||||||
) {
|
) {
|
||||||
|
await requireAdmin();
|
||||||
try {
|
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 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
|
// S'assurer que le dossier data existe
|
||||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
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) {
|
export async function saveQlikSettings(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||||
|
await requireAdmin();
|
||||||
try {
|
try {
|
||||||
const existing = await readConfig();
|
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.mkdir(DATA_DIR, { recursive: true });
|
||||||
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||||
console.log("[Settings] Qlik configuration saved.");
|
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) {
|
export async function testQlikConnection(qlikHost: string, qlikUser: string, qlikPassword: string) {
|
||||||
|
await requireAdmin();
|
||||||
try {
|
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 { 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;
|
if (qlikHost) cfg.host = qlikHost;
|
||||||
await qlikNtlmSession(cfg);
|
await qlikNtlmSession(cfg);
|
||||||
return { success: true };
|
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. */
|
/** Enregistre l'URL de l'API FF Nancy. Chaîne vide = revenir au défaut. */
|
||||||
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
export async function saveFfApiSettings(ffApiBaseUrl: string) {
|
||||||
|
await requireAdmin();
|
||||||
try {
|
try {
|
||||||
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
const cleaned = ffApiBaseUrl.trim().replace(/\/+$/, "");
|
||||||
if (cleaned && !/^https?:\/\//i.test(cleaned)) {
|
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**.
|
* 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
|
||||||
* L'ancien panneau se contentait d'un « HTTP 503 » opaque : impossible de savoir
|
* client connecté pourrait faire appeler une URL arbitraire par le serveur.
|
||||||
* 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) {
|
export async function testFfApiConnection(ffApiBaseUrl?: string) {
|
||||||
const base = (ffApiBaseUrl?.trim() || (await readConfig()).ffApiBaseUrl || process.env.FF_API_BASE_URL || "https://api.ffnancy.fr").replace(/\/+$/, "");
|
await requireAdmin();
|
||||||
const url = `${base}/api/sync/status`;
|
const { diagnoseFfApi } = await import("@/lib/api-ff-client");
|
||||||
const started = Date.now();
|
return diagnoseFfApi(ffApiBaseUrl);
|
||||||
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<DbConfig | null> {
|
/** Config enregistrée, secrets masqués (voir `PublicDbConfig`). */
|
||||||
|
export async function getSavedDatabaseConfig(): Promise<PublicDbConfig | null> {
|
||||||
|
await requireAdmin();
|
||||||
try {
|
try {
|
||||||
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
if (!(await fs.stat(CONFIG_FILE).catch(() => null))) {
|
||||||
console.log(`[Settings] Config file not found at ${CONFIG_FILE}`);
|
console.log(`[Settings] Config file not found at ${CONFIG_FILE}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = await fs.readFile(CONFIG_FILE, "utf-8");
|
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"}`);
|
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) {
|
} catch (error) {
|
||||||
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
|
console.error(`[Settings] Error reading config from ${CONFIG_FILE}:`, error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const useDbSettingsStore = create<DatabaseSettingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "collectflow-db-settings",
|
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)
|
// 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