// middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs"; import { detectDemoMode, logDemoMode } from "@/lib/demo-detector"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || process.env.SUPABASE_URL || ""; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY || ""; // Pages de l'Espace Paie nécessitant une session authentifiée const PROTECTED = [ "/contrats", "/contrats-multi", "/contrats-rg", "/salaries", "/cotisations", "/facturation", "/virements-salaires", "/vos-documents", "/vos-acces", "/informations", "/compte", "/support", "/signatures-electroniques", "/staff", "/debug", ]; export async function middleware(req: NextRequest) { // Toujours créer une réponse et rafraîchir la session en amont const res = NextResponse.next(); if (!supabaseUrl || !supabaseAnonKey) { console.error("[Middleware] Supabase env manquantes", { hasUrl: Boolean(supabaseUrl), hasAnonKey: Boolean(supabaseAnonKey), note: "Vérifie .env.local (à la racine du projet) et redémarre le serveur" }); // On retourne la réponse sans tenter de lire la session (évite le crash en dev) return res; } // 1) Rafraîchit les cookies de session si nécessaire (very important) const supabase = createMiddlewareClient({ req, res }, { supabaseUrl, supabaseKey: supabaseAnonKey }); const { data: { session } } = await supabase.auth.getSession(); // 1.5) Maintenir les cookies persistants si rememberMe est activé const rememberMeCookie = req.cookies.get("remember_me"); if (session && rememberMeCookie?.value === "true") { // Prolonger la durée des cookies Supabase à chaque requête const cookieOptions = { maxAge: 60 * 60 * 24 * 30, // 30 jours path: "/", sameSite: "lax" as const, httpOnly: true, secure: process.env.NODE_ENV === "production", }; // Récupérer tous les cookies Supabase et les renouveler req.cookies.getAll().forEach((cookie) => { if (cookie.name.startsWith("sb-")) { res.cookies.set(cookie.name, cookie.value, cookieOptions); } }); // Renouveler aussi le cookie remember_me res.cookies.set("remember_me", "true", cookieOptions); } const path = req.nextUrl.pathname; const isStaticFile = /\.[^/]+$/.test(path); // ex: /odentas-logo.png, /robots.txt, etc. // 2) Vérification du mode maintenance // Éviter de vérifier sur les API calls, assets, la page maintenance elle-même, la page de connexion, // et les pages publiques (auto-declaration, dl-contrat-signe, signature-salarie, politique-confidentialite, mentions-legales) const isApiOrAssets = path.startsWith('/api') || path.startsWith('/_next') || path.startsWith('/favicon') || path.startsWith('/public') || isStaticFile; const isMaintenancePage = path === '/maintenance'; const isSigninPage = path === '/signin'; const isPublicPage = path.startsWith('/auto-declaration') || path.startsWith('/dl-contrat-signe') || path.startsWith('/signature-salarie') || path === '/politique-confidentialite' || path === '/mentions-legales' || path === '/activate'; // Ne pas impacter l'environnement local/dev par le mode maintenance const hostname = req.nextUrl.hostname || ''; const isDevEnv = process.env.NODE_ENV === 'development'; const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname.endsWith('.local'); // Optionnel: IP privée (LAN) → considérer comme non-prod const isPrivateIp = /^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname); const skipMaintenance = isDevEnv || isLocalHost || isPrivateIp; if (!skipMaintenance && !isApiOrAssets && !isMaintenancePage && !isSigninPage && !isPublicPage) { try { // Vérifier le statut de maintenance const { data: maintenanceStatus } = await supabase .from("maintenance_status") .select("is_maintenance_mode") .single(); if (maintenanceStatus?.is_maintenance_mode) { // Vérifier si l'utilisateur est staff let isStaff = false; if (session?.user) { const { data: staffData } = await supabase .from("staff_users") .select("is_staff") .eq("user_id", session.user.id) .maybeSingle(); isStaff = !!staffData?.is_staff; } // Si pas staff, rediriger vers la page maintenance if (!isStaff) { const url = req.nextUrl.clone(); url.pathname = "/maintenance"; return NextResponse.redirect(url); } } } catch (error) { console.error("Erreur lors de la vérification de maintenance:", error); // En cas d'erreur, on continue sans bloquer } } // 3) Si on essaie d'aller sur /maintenance mais qu'on n'est pas en maintenance if (isMaintenancePage && !skipMaintenance) { try { const { data: maintenanceStatus } = await supabase .from("maintenance_status") .select("is_maintenance_mode") .single(); // Si pas en maintenance, rediriger vers l'accueil if (!maintenanceStatus?.is_maintenance_mode) { const url = req.nextUrl.clone(); url.pathname = "/"; return NextResponse.redirect(url); } } catch (error) { console.error("Erreur lors de la vérification de maintenance:", error); } } // 4) Staff status verification (DB-first, then fallback to metadata like /api/me) let isStaff = false; if (session?.user?.id) { try { // Try DB first (same logic as /api/me) const { data } = await supabase .from("staff_users") .select("is_staff") .eq("user_id", session.user.id) .maybeSingle(); isStaff = !!data?.is_staff; } catch (e) { // fallback to token metadata isStaff = Boolean( (session.user.user_metadata && (session.user.user_metadata.is_staff === true || session.user.user_metadata.role === 'staff')) || (session.user.app_metadata && Array.isArray((session.user.app_metadata as any).roles) && (session.user.app_metadata as any).roles.includes('staff')) ); } } // 4.1) Guard: restrict access to table entry route to staff only (temporary feature flag) if (path.startsWith('/contrats/nouveau/saisie-tableau') && !isStaff) { const url = req.nextUrl.clone(); url.pathname = '/contrats'; return NextResponse.redirect(url); } if (!isStaff) { // Si un cookie staff "active_org_id" existe, purge-le pour éviter les fuites d'org côté client const hasLegacy = req.cookies.get('active_org_id'); if (hasLegacy) { res.cookies.set('active_org_id', '', { path: '/', maxAge: 0 }); res.cookies.set('active_org_id', '', { path: '/staff', maxAge: 0 }); } } // 5) Vérification du mode démo avant l'authentification const isDemoMode = detectDemoMode(req); // Si en mode démo, ajouter un header pour indiquer au layout de contourner l'auth if (isDemoMode) { res.headers.set('x-demo-mode', 'true'); logDemoMode(true, 'MIDDLEWARE', req); } // 6) Gate des pages protégées (laisse passer API & assets) const needsAuth = PROTECTED.some((p) => path.startsWith(p)); // Contourner l'authentification si en mode démo if (needsAuth && !session && !isDemoMode) { const url = req.nextUrl.clone(); url.pathname = "/signin"; url.searchParams.set("next", path); return NextResponse.redirect(url); } // Important : retourner *res* (celui modifié par auth-helpers) return res; } // Évite d'exécuter le middleware sur les assets Next export const config = { matcher: [ // Exclure: assets Next, favicon et tout fichier avec extension (images, css, js, etc.) "/((?!_next/static|_next/image|favicon.ico|public|.*\\..*).*)", ], };