fix(sécurité): réserver les actions des Paramètres aux administrateurs et masquer les secrets

Les actions serveur de src/features/settings/actions.ts étaient appelables
par n'importe quel utilisateur connecté : la restriction de /settings dans le
middleware ne protège pas une action serveur. Un simple utilisateur pouvait
lire l'URL PostgreSQL, le mot de passe Qlik et les clés IA, réécrire la
configuration (puis refreshDb()), ou faire appeler une URL arbitraire au
serveur via les boutons « Tester ».

- requireAdmin() partagé dans src/lib/auth.ts, appelé en tête de chaque
  action des Paramètres ; il remplace les deux copies locales d'ensureAdmin()
  de user-actions.ts et api-key-actions.ts.
- getSavedDatabaseConfig ne renvoie plus aucun secret : URL sans mot de
  passe, mot de passe Qlik et clés IA remplacés par des booléens has*.
- Un mot de passe laissé vide conserve celui enregistré (enregistrement et
  test), uniquement pour le même hôte/utilisateur afin qu'il ne puisse pas
  être envoyé à un autre serveur.
- saveDatabaseSettings n'écrase plus les clés et modèles IA quand la page
  n'envoie que l'URL (ils étaient effacés à chaque sauvegarde).
- Le diagnostic de l'API FF passe dans lib/api-ff-client (diagnoseFfApi) :
  /api/ff-status reste accessible à tous sans exposer l'action admin.
- Le mot de passe PostgreSQL n'est plus persisté dans le localStorage.

Les routes /api/admin/** et sync-settings.ts (module serveur, pas une
action) étaient déjà correctement protégées : inchangées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkfUui3qGg2zmu3uHWjNXJ
This commit is contained in:
Claude committed 2026-10-04 06:35:35 +00:00
1 parent 2c84cfda53
commit af12527731
8 files changed
+205 -96

No files matched your search

+22 -7
View File
@@ -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() {
<input type="text" placeholder="FFSCH" value={user}
onChange={(e) => setUser(e.target.value)} className="apple-input font-mono" />
</Field>
<Field label="Mot de passe">
<Field label="Mot de passe" hint={hasSavedPassword ? "Vide = conservé, pour le même hôte et utilisateur." : undefined}>
<div className="relative">
<input type={showPwd ? "text" : "password"} placeholder="••••••••" value={password}
<input type={showPwd ? "text" : "password"} placeholder={hasSavedPassword ? "Enregistré — inchangé si vide" : "••••••••"} value={password}
onChange={(e) => setPassword(e.target.value)} className="apple-input font-mono pr-10" />
<button type="button" onClick={() => 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 ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Enregistrer
</button>
<button onClick={handleTest} disabled={testing || !user || !password}
<button onClick={handleTest} disabled={testing || !user || (!password && !hasSavedPassword)}
className="btn-action btn-action-secondary flex items-center gap-1.5 disabled:opacity-60">
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
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"
/>
</Field>
<Field label="Mot de passe">
<Field label="Mot de passe" hint={hasSavedDbPassword ? "Vide = conservé, pour le même serveur et utilisateur." : undefined}>
<input
type="password"
placeholder="••••••••"
placeholder={hasSavedDbPassword ? "Enregistré — inchangé si vide" : "••••••••"}
value={password || ""}
onChange={(e) => setPassword(e.target.value)}
className="apple-input font-mono"
+2 -2
View File
@@ -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 },
+5 -13
View File
@@ -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");
+6 -18
View File
@@ -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) })
+98 -56
View File
@@ -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 }),
}
)
);
+55
View File
@@ -611,6 +611,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)
// ---------------------------------------------------------------------------
+15
View File
@@ -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;
}