diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 2e3cd79..73051d3 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -171,6 +171,8 @@ function QlikSettingsSection() { 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 [hasSavedPassword, setHasSavedPassword] = useState(false); const [showPwd, setShowPwd] = useState(false); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -181,7 +183,7 @@ function QlikSettingsSection() { if (!c) return; setHost(c.qlikHost ?? ""); setUser(c.qlikUser ?? ""); - setPassword(c.qlikPassword ?? ""); + setHasSavedPassword(c.hasQlikPassword); }); }, []); @@ -190,6 +192,10 @@ function QlikSettingsSection() { setStatus(null); const res = await saveQlikSettings(host.trim(), user.trim(), password); setSaving(false); + if (res.success && password) { + setHasSavedPassword(true); + setPassword(""); + } setStatus(res.success ? { type: "ok", msg: "Réglages Qlik enregistrés" } : { type: "err", msg: res.error || "Erreur" }); }; @@ -211,9 +217,9 @@ function QlikSettingsSection() { setUser(e.target.value)} className="apple-input font-mono" /> - + - setPassword(e.target.value)} className="apple-input font-mono pr-10" /> setShowPwd((v) => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--text-muted)]"> @@ -233,7 +239,7 @@ function QlikSettingsSection() { {saving ? : } Enregistrer - {testing ? : } Tester @@ -254,6 +260,7 @@ export default function SettingsPage() { const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved">("idle"); const [isMounted, setIsMounted] = useState(false); + const [hasSavedDbPassword, setHasSavedDbPassword] = useState(false); const { host, setHost, @@ -276,7 +283,11 @@ export default function SettingsPage() { 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 = conserver + // l'enregistré. On efface aussi celui qu'une ancienne version a pu + // laisser dans le localStorage. + setPassword(""); + setHasSavedDbPassword(config.hasDbPassword); setSsl(config.url.includes("sslmode=require")); } catch (e) { console.error("Failed to parse saved URL", e); @@ -311,6 +322,10 @@ export default function SettingsPage() { const url = getDatabaseUrl(); const res = await saveDatabaseSettings(url); if (res.success) { + if (password) { + setHasSavedDbPassword(true); + setPassword(""); + } setSaveStatus("saved"); setTimeout(() => setSaveStatus("idle"), 2500); } else { @@ -371,10 +386,10 @@ export default function SettingsPage() { className="apple-input font-mono" /> - + setPassword(e.target.value)} className="apple-input font-mono" diff --git a/src/app/api/ff-status/route.ts b/src/app/api/ff-status/route.ts index 333627a..48926d5 100644 --- a/src/app/api/ff-status/route.ts +++ b/src/app/api/ff-status/route.ts @@ -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 }, diff --git a/src/features/admin/api/api-key-actions.ts b/src/features/admin/api/api-key-actions.ts index a27a7b3..624da08 100644 --- a/src/features/admin/api/api-key-actions.ts +++ b/src/features/admin/api/api-key-actions.ts @@ -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 { - 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"); diff --git a/src/features/admin/api/user-actions.ts b/src/features/admin/api/user-actions.ts index 2d1044b..36a0a84 100644 --- a/src/features/admin/api/user-actions.ts +++ b/src/features/admin/api/user-actions.ts @@ -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) }) diff --git a/src/features/settings/actions.ts b/src/features/settings/actions.ts index cb51049..1b89f41 100644 --- a/src/features/settings/actions.ts +++ b/src/features/settings/actions.ts @@ -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 & { + hasDbPassword: boolean; + hasQlikPassword: boolean; + hasOpenRouterKey: boolean; + hasGoogleAiKey: boolean; +}; + /** Lit la config existante (ou {} si absente). */ async function readConfig(): Promise> { try { @@ -37,7 +49,47 @@ async function readConfig(): Promise> { } } -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 { + 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, 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 { +/** Config enregistrée, secrets masqués (voir `PublicDbConfig`). */ +export async function getSavedDatabaseConfig(): Promise { + 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; diff --git a/src/features/settings/store/use-db-settings-store.ts b/src/features/settings/store/use-db-settings-store.ts index 2c0d55a..c15c570 100644 --- a/src/features/settings/store/use-db-settings-store.ts +++ b/src/features/settings/store/use-db-settings-store.ts @@ -48,6 +48,8 @@ export const useDbSettingsStore = create()( }), { 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 }), } ) ); diff --git a/src/lib/api-ff-client.ts b/src/lib/api-ff-client.ts index e4eb42e..16a47e6 100644 --- a/src/lib/api-ff-client.ts +++ b/src/lib/api-ff-client.ts @@ -611,6 +611,61 @@ export async function getSyncStatus(): Promise { } } +/** + * 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) // --------------------------------------------------------------------------- diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f82464b..a99684a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -70,3 +70,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; +}