mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-11 17:26:32 +02:00
feat: implement user authentication and authorization with NextAuth, including user schema, password hashing, and role-based access control.
This commit is contained in:
1 parent
c842c2447a
commit
359d196c4d
18 files changed
+652
-32
No files matched your search
@@ -0,0 +1,17 @@
|
|||||||
|
import { LoginForm } from "@/features/auth/components/login-form";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-6 relative overflow-hidden">
|
||||||
|
{/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */}
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-indigo-500/10 dark:bg-indigo-500/5 blur-[120px] rounded-full" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[40%] h-[40%] bg-emerald-500/10 dark:bg-emerald-500/5 blur-[120px] rounded-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 w-full flex justify-center">
|
||||||
|
<LoginForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ import { useScoreSettingsStore } from "@/features/score/store/use-score-settings
|
|||||||
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
|
||||||
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
|
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { UserManagement } from "@/features/admin/components/user-management";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
interface OpenRouterModel { id: string; name: string; free: boolean; }
|
||||||
|
|
||||||
@@ -402,6 +405,14 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Gestion des Utilisateurs */}
|
||||||
|
<Section
|
||||||
|
title="Gestion des Utilisateurs"
|
||||||
|
subtitle="Administration des accès et des rôles"
|
||||||
|
>
|
||||||
|
<UserManagement />
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* Save */}
|
{/* Save */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
+4
-2
@@ -14,6 +14,8 @@ export const metadata: Metadata = {
|
|||||||
description: "Révision d'Assortiment Haute Performance",
|
description: "Révision d'Assortiment Haute Performance",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
@@ -22,9 +24,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="fr" suppressHydrationWarning>
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} font-sans antialiased`}>
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
<ThemeProvider>
|
<Providers>
|
||||||
{children}
|
{children}
|
||||||
</ThemeProvider>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
import { ThemeProvider } from "@/components/shared/theme-provider";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SessionProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</SessionProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3 } from "lucide-react";
|
import { LayoutGrid, Camera, FileDown, Settings, Package, BarChart3, LogOut, User as UserIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
import { useGridStore } from "@/features/grid/store/use-grid-store";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession, signOut } from "next-auth/react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
|
||||||
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
|
||||||
{ icon: FileDown, label: "Exports", href: "/exports" },
|
{ icon: FileDown, label: "Exports", href: "/exports" },
|
||||||
{ icon: BarChart3, label: "Score", href: "/score" },
|
{ icon: BarChart3, label: "Score", href: "/score" },
|
||||||
{ icon: Settings, label: "Paramètres", href: "/settings" },
|
{ icon: Settings, label: "Paramètres", href: "/settings", adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const { data: session } = useSession();
|
||||||
const activeGridQuery = useGridStore((s) => s.activeGridQuery);
|
const activeGridQuery = useGridStore((s) => s.activeGridQuery);
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
@@ -24,6 +26,9 @@ export function Sidebar() {
|
|||||||
setIsMounted(true);
|
setIsMounted(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const userRole = (session?.user as any)?.role;
|
||||||
|
const filteredItems = NAV_ITEMS.filter(item => !item.adminOnly || userRole === "admin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="w-60 flex-shrink-0 flex flex-col h-screen fixed left-0 top-0 z-30 glass"
|
className="w-60 flex-shrink-0 flex flex-col h-screen fixed left-0 top-0 z-30 glass"
|
||||||
@@ -42,11 +47,10 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="px-2 pt-2.5 space-y-0.5">
|
<nav className="px-2 pt-2.5 space-y-0.5 flex-1">
|
||||||
{NAV_ITEMS.map(({ icon: Icon, label, href }) => {
|
{filteredItems.map(({ icon: Icon, label, href }) => {
|
||||||
const isActive = pathname.startsWith(href);
|
const isActive = pathname.startsWith(href);
|
||||||
|
|
||||||
// Restore grid context if navigating back to the grid
|
|
||||||
let resolvedHref = href;
|
let resolvedHref = href;
|
||||||
if (href === "/grid" && isMounted && activeGridQuery) {
|
if (href === "/grid" && isMounted && activeGridQuery) {
|
||||||
resolvedHref = `/grid${activeGridQuery}`;
|
resolvedHref = `/grid${activeGridQuery}`;
|
||||||
@@ -64,12 +68,6 @@ export function Sidebar() {
|
|||||||
"flex items-center gap-2.5 px-3 py-[7px] rounded-[8px] text-[13px] font-medium transition-all duration-150",
|
"flex items-center gap-2.5 px-3 py-[7px] rounded-[8px] text-[13px] font-medium transition-all duration-150",
|
||||||
!isActive && "hover:bg-[var(--bg-elevated)]"
|
!isActive && "hover:bg-[var(--bg-elevated)]"
|
||||||
)}
|
)}
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "var(--bg-elevated)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isActive) (e.currentTarget as HTMLElement).style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Icon className="w-[15px] h-[15px] shrink-0" strokeWidth={1.8} />
|
<Icon className="w-[15px] h-[15px] shrink-0" strokeWidth={1.8} />
|
||||||
{label}
|
{label}
|
||||||
@@ -78,8 +76,31 @@ export function Sidebar() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* User Profile & Logout */}
|
||||||
|
<div className="px-3 pb-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3 px-3 py-3 rounded-xl bg-slate-800/20 border border-slate-700/30">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center border border-indigo-500/20">
|
||||||
|
<UserIcon className="w-4 h-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[12px] font-bold text-slate-100 truncate">{session?.user?.name || "Invité"}</p>
|
||||||
|
<p className="text-[10px] uppercase font-black text-slate-500 tracking-wider">
|
||||||
|
{userRole === "admin" ? "Administrateur" : "Utilisateur"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => signOut({ callbackUrl: "/login" })}
|
||||||
|
className="w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-[13px] font-medium text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-[15px] h-[15px]" strokeWidth={1.8} />
|
||||||
|
Déconnexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-auto px-4 py-3" style={{ borderTop: "1px solid var(--border)" }}>
|
<div className="px-4 py-3" style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>v1.0.0-beta</p>
|
<p className="text-[11px]" style={{ color: "var(--text-muted)" }}>v1.0.0-beta</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
+14
-1
@@ -1,4 +1,4 @@
|
|||||||
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb } from "drizzle-orm/pg-core";
|
import { pgTable, serial, varchar, numeric, smallint, timestamp, uniqueIndex, index, text, jsonb, integer } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const ventesProduits = pgTable("ventes_produits", {
|
export const ventesProduits = pgTable("ventes_produits", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
@@ -47,9 +47,22 @@ export const ventesProduits = pgTable("ventes_produits", {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** User management (Epic 6) */
|
||||||
|
export const users = pgTable("users", {
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
username: varchar("username", { length: 50 }).notNull().unique(),
|
||||||
|
/** Hashed password */
|
||||||
|
passwordHash: text("password_hash").notNull(),
|
||||||
|
/** 'admin' or 'user' */
|
||||||
|
role: varchar("role", { length: 20 }).default("user").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
/** Snapshot of a complete arbitrage session (Epic 5) */
|
/** Snapshot of a complete arbitrage session (Epic 5) */
|
||||||
export const sessionSnapshots = pgTable("session_snapshots", {
|
export const sessionSnapshots = pgTable("session_snapshots", {
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
|
/** Link to user who created the snapshot */
|
||||||
|
userId: integer("user_id").references(() => users.id),
|
||||||
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
|
codeFournisseur: varchar("code_fournisseur", { length: 20 }).notNull(),
|
||||||
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
|
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
|
||||||
magasin: varchar("magasin", { length: 20 }).notNull(),
|
magasin: varchar("magasin", { length: 20 }).notNull(),
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
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 { 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();
|
||||||
|
return db.select({
|
||||||
|
id: users.id,
|
||||||
|
username: users.username,
|
||||||
|
role: users.role,
|
||||||
|
createdAt: users.createdAt
|
||||||
|
}).from(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crée un nouvel utilisateur.
|
||||||
|
*/
|
||||||
|
export async function createUser(username: string, password: string, role: "admin" | "user" = "user") {
|
||||||
|
await ensureAdmin();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(users).values({
|
||||||
|
username,
|
||||||
|
passwordHash: hashPassword(password),
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("CreateUser Error:", err);
|
||||||
|
return { success: false, error: "L'utilisateur existe déjà ou une erreur technique est survenue." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un utilisateur.
|
||||||
|
*/
|
||||||
|
export async function deleteUser(id: number) {
|
||||||
|
await ensureAdmin();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
if (Number((session?.user as any)?.id) === id) {
|
||||||
|
return { success: false, error: "Vous ne pouvez pas supprimer votre propre compte." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.delete(users).where(eq(users.id, id));
|
||||||
|
revalidatePath("/settings");
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: "Erreur lors de la suppression." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met à jour le mot de passe d'un utilisateur.
|
||||||
|
*/
|
||||||
|
export async function updatePassword(id: number, newPassword: string) {
|
||||||
|
await ensureAdmin();
|
||||||
|
try {
|
||||||
|
await db.update(users)
|
||||||
|
.set({ passwordHash: hashPassword(newPassword) })
|
||||||
|
.where(eq(users.id, id));
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour du mot de passe." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getUsers, createUser, deleteUser } from "../api/user-actions";
|
||||||
|
import {
|
||||||
|
UserPlus,
|
||||||
|
Trash2,
|
||||||
|
Shield,
|
||||||
|
User,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SuccessModal } from "@/components/shared/success-modal";
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [users, setUsers] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [newUsername, setNewUsername] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [newRole, setNewRole] = useState<"admin" | "user">("user");
|
||||||
|
const [modal, setModal] = useState({ isOpen: false, title: "", message: "" });
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getUsers();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsCreating(true);
|
||||||
|
const res = await createUser(newUsername, newPassword, newRole);
|
||||||
|
if (res.success) {
|
||||||
|
setModal({
|
||||||
|
isOpen: true,
|
||||||
|
title: "Utilisateur Créé",
|
||||||
|
message: `L'utilisateur ${newUsername} a été ajouté avec succès.`
|
||||||
|
});
|
||||||
|
setNewUsername("");
|
||||||
|
setNewPassword("");
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
alert(res.error);
|
||||||
|
}
|
||||||
|
setIsCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number, username: string) => {
|
||||||
|
if (!window.confirm(`Supprimer l'utilisateur ${username} ?`)) return;
|
||||||
|
const res = await deleteUser(id);
|
||||||
|
if (res.success) {
|
||||||
|
setUsers(users.filter(u => u.id !== id));
|
||||||
|
} else {
|
||||||
|
alert(res.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-12 space-y-4">
|
||||||
|
<Loader2 className="w-8 h-8 text-indigo-500 animate-spin" />
|
||||||
|
<p className="text-slate-400 text-sm">Chargement des utilisateurs...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Formulaire de création */}
|
||||||
|
<div className="bg-slate-800/30 border border-slate-700/50 rounded-3xl p-8">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center">
|
||||||
|
<UserPlus className="w-6 h-6 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-black text-slate-100 italic">Nouvel Utilisateur</h2>
|
||||||
|
<p className="text-slate-500 text-sm">Ajoutez un collaborateur à la plateforme.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="grid grid-cols-1 md:grid-cols-4 gap-6 items-end">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Utilisateur</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={newUsername}
|
||||||
|
onChange={(e) => setNewUsername(e.target.value)}
|
||||||
|
className="w-full bg-slate-900/50 border border-slate-700/50 rounded-xl px-4 py-3 text-sm text-slate-100 outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
placeholder="ex: jean.dupont"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="w-full bg-slate-900/50 border border-slate-700/50 rounded-xl px-4 py-3 text-sm text-slate-100 outline-none focus:border-indigo-500 transition-colors"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500 ml-1">Rôle</label>
|
||||||
|
<select
|
||||||
|
value={newRole}
|
||||||
|
onChange={(e) => setNewRole(e.target.value as any)}
|
||||||
|
className="w-full bg-slate-900/50 border border-slate-700/50 rounded-xl px-4 py-3 text-sm text-slate-100 outline-none focus:border-indigo-500 transition-colors appearance-none"
|
||||||
|
>
|
||||||
|
<option value="user">Utilisateur</option>
|
||||||
|
<option value="admin">Administrateur</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isCreating}
|
||||||
|
className="h-12 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm font-black rounded-xl transition-all active:scale-95 flex items-center justify-center gap-2 shadow-lg shadow-indigo-900/20"
|
||||||
|
>
|
||||||
|
{isCreating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
|
||||||
|
Créer le compte
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Liste des utilisateurs */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<h3 className="text-xs font-black uppercase tracking-widest text-slate-500 ml-1 mb-2 italic">Liste des comptes</h3>
|
||||||
|
{users.map((u) => (
|
||||||
|
<div key={u.id} className="flex items-center gap-6 p-4 rounded-2xl bg-slate-800/40 border border-slate-700/50 hover:border-slate-600 transition-all group">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-slate-700/50 flex items-center justify-center shrink-0">
|
||||||
|
{u.role === "admin" ? <Shield className="w-5 h-5 text-amber-400" /> : <User className="w-5 h-5 text-slate-400" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">{u.username}</h4>
|
||||||
|
<span className="text-[10px] uppercase font-black text-slate-500 tracking-wider">
|
||||||
|
{u.role === "admin" ? "Administrateur" : "Utilisateur Standard"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id, u.username)}
|
||||||
|
className="p-2.5 rounded-xl hover:bg-rose-500/10 text-slate-500 hover:text-rose-500 transition-colors"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SuccessModal
|
||||||
|
isOpen={modal.isOpen}
|
||||||
|
onClose={() => setModal({ ...modal, isOpen: false })}
|
||||||
|
title={modal.title}
|
||||||
|
message={modal.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { signIn } from "@/lib/auth";
|
||||||
|
import { AuthError } from "next-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server action to handle user login.
|
||||||
|
*/
|
||||||
|
export async function loginAction(prevState: string | undefined, formData: FormData) {
|
||||||
|
try {
|
||||||
|
await signIn("credentials", {
|
||||||
|
username: formData.get("username"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
redirectTo: "/dashboard"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AuthError) {
|
||||||
|
switch (error.type) {
|
||||||
|
case "CredentialsSignin":
|
||||||
|
return "Utilisateur ou mot de passe incorrect.";
|
||||||
|
default:
|
||||||
|
return "Une erreur technique est survenue.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Next.js redirect throws a special error that must be rethrown
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { loginAction } from "../api/login-action";
|
||||||
|
import { Loader2, Lock, User, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [error, action, isPending] = useActionState(loginAction, undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md p-8 rounded-[2.5rem] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-2xl shadow-slate-200/10 dark:shadow-none">
|
||||||
|
<div className="flex flex-col items-center mb-10 text-center">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center mb-6">
|
||||||
|
<ShieldCheck className="w-8 h-8 text-indigo-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-black text-slate-900 dark:text-white tracking-tight mb-2">
|
||||||
|
CollectFlow
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 dark:text-slate-400 text-sm font-medium">
|
||||||
|
Connectez-vous pour accéder à votre espace d'arbitrage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={action} className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-slate-400 ml-1">
|
||||||
|
Utilisateur
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-indigo-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="admin"
|
||||||
|
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-slate-900 dark:text-white font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-black uppercase tracking-widest text-slate-400 ml-1">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 group-focus-within:text-indigo-500 transition-colors" />
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-800 focus:border-indigo-500 focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all text-slate-900 dark:text-white font-medium"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/20 text-rose-500 text-xs font-bold flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-rose-500 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full py-4 rounded-2xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-black transition-all active:scale-[0.98] shadow-lg shadow-indigo-600/20 flex items-center justify-center gap-2 group"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Connexion...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Se connecter"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center border-t border-slate-100 dark:border-slate-800 pt-8">
|
||||||
|
<p className="text-[10px] text-slate-400 uppercase font-black tracking-widest leading-relaxed">
|
||||||
|
Plateforme d'Arbitrage Magasin<br />
|
||||||
|
Propulsée par l'Intelligence Artificielle
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { scryptSync, randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a password using scrypt.
|
||||||
|
* Format: salt:hash
|
||||||
|
*/
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16).toString("hex");
|
||||||
|
const hash = scryptSync(password, salt, 64).toString("hex");
|
||||||
|
return `${salt}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a password against a stored salt:hash string.
|
||||||
|
*/
|
||||||
|
export function verifyPassword(password: string, storedHash: string): boolean {
|
||||||
|
try {
|
||||||
|
const [salt, hash] = storedHash.split(":");
|
||||||
|
if (!salt || !hash) return false;
|
||||||
|
|
||||||
|
const hashBuffer = scryptSync(password, salt, 64);
|
||||||
|
const storedHashBuffer = Buffer.from(hash, "hex");
|
||||||
|
|
||||||
|
return timingSafeEqual(hashBuffer, storedHashBuffer);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { hashPassword } from "./auth-logic";
|
||||||
|
import { count } from "drizzle-orm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the users table is empty.
|
||||||
|
* If so, creates a default admin account (admin/admin).
|
||||||
|
*/
|
||||||
|
export async function ensureAdminExists() {
|
||||||
|
try {
|
||||||
|
const [userCount] = await db.select({ value: count() }).from(users);
|
||||||
|
|
||||||
|
if (userCount.value === 0) {
|
||||||
|
console.log("BMAD: Initializing default admin account (admin/admin)...");
|
||||||
|
await db.insert(users).values({
|
||||||
|
username: "admin",
|
||||||
|
passwordHash: hashPassword("admin"),
|
||||||
|
role: "admin"
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// If table doesn't exist, we might need auto-repair like session_snapshots
|
||||||
|
// But for users, it's better to let drizzle handled or handle it here if needed.
|
||||||
|
console.error("BMAD: Error checking/seeding users table.", err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -2,11 +2,20 @@
|
|||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { sessionSnapshots } from "@/db/schema";
|
import { sessionSnapshots } from "@/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
export async function deleteSnapshot(id: number) {
|
export async function deleteSnapshot(id: number) {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user ? Number((session.user as any).id) : null;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return { success: false, error: "Non autorisé" };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.delete(sessionSnapshots).where(eq(sessionSnapshots.id, id));
|
await db.delete(sessionSnapshots)
|
||||||
|
.where(and(eq(sessionSnapshots.id, id), eq(sessionSnapshots.userId, userId)));
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -2,14 +2,20 @@
|
|||||||
|
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { sessionSnapshots } from "@/db/schema";
|
import { sessionSnapshots } from "@/db/schema";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
export async function getSnapshots(type?: "snapshot" | "export") {
|
export async function getSnapshots(type?: "snapshot" | "export") {
|
||||||
let query = db.select().from(sessionSnapshots);
|
const session = await auth();
|
||||||
|
const userId = session?.user ? Number((session.user as any).id) : null;
|
||||||
|
|
||||||
if (type) {
|
let query = db.select().from(sessionSnapshots);
|
||||||
// @ts-ignore - type column added dynamically
|
const conditions = [];
|
||||||
return query.where(eq(sessionSnapshots.type, type)).orderBy(desc(sessionSnapshots.createdAt));
|
if (userId) conditions.push(eq(sessionSnapshots.userId, userId));
|
||||||
|
if (type) conditions.push(eq(sessionSnapshots.type, type));
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
return query.where(and(...conditions)).orderBy(desc(sessionSnapshots.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
return query.orderBy(desc(sessionSnapshots.createdAt));
|
return query.orderBy(desc(sessionSnapshots.createdAt));
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { sessionSnapshots } from "@/db/schema";
|
import { sessionSnapshots } from "@/db/schema";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql, and, eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
const SaveSnapshotSchema = z.object({
|
const SaveSnapshotSchema = z.object({
|
||||||
codeFournisseur: z.string(),
|
codeFournisseur: z.string(),
|
||||||
@@ -31,11 +32,14 @@ export async function saveSnapshot(raw: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
|
const { codeFournisseur, nomFournisseur, magasin, label, changes, summary, type } = parsed.data;
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user ? Number((session.user as any).id) : null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(sessionSnapshots)
|
.insert(sessionSnapshots)
|
||||||
.values({
|
.values({
|
||||||
|
userId,
|
||||||
codeFournisseur,
|
codeFournisseur,
|
||||||
nomFournisseur: nomFournisseur ?? null,
|
nomFournisseur: nomFournisseur ?? null,
|
||||||
magasin,
|
magasin,
|
||||||
@@ -55,6 +59,7 @@ export async function saveSnapshot(raw: unknown) {
|
|||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
CREATE TABLE IF NOT EXISTS session_snapshots (
|
CREATE TABLE IF NOT EXISTS session_snapshots (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER,
|
||||||
code_fournisseur VARCHAR(20) NOT NULL,
|
code_fournisseur VARCHAR(20) NOT NULL,
|
||||||
nom_fournisseur VARCHAR(255),
|
nom_fournisseur VARCHAR(255),
|
||||||
magasin VARCHAR(20) NOT NULL,
|
magasin VARCHAR(20) NOT NULL,
|
||||||
@@ -66,17 +71,19 @@ export async function saveSnapshot(raw: unknown) {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Si la table existait déjà mais sans la colonne 'type', on l'ajoute
|
// Si la table existait déjà mais sans la colonne 'type' ou 'user_id', on les ajoute
|
||||||
try {
|
try {
|
||||||
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'`);
|
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'snapshot'`);
|
||||||
|
await db.execute(sql`ALTER TABLE session_snapshots ADD COLUMN IF NOT EXISTS user_id INTEGER`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore if column already exists or other alter errors
|
// Ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deuxième tentative d'insertion
|
// Deuxième tentative d'insertion
|
||||||
const [retryCreated] = await db
|
const [retryCreated] = await db
|
||||||
.insert(sessionSnapshots)
|
.insert(sessionSnapshots)
|
||||||
.values({
|
.values({
|
||||||
|
userId,
|
||||||
codeFournisseur,
|
codeFournisseur,
|
||||||
nomFournisseur: nomFournisseur ?? null,
|
nomFournisseur: nomFournisseur ?? null,
|
||||||
magasin,
|
magasin,
|
||||||
|
|||||||
+60
-2
@@ -1,6 +1,64 @@
|
|||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import GitHub from "next-auth/providers/github"; // Example provider
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { verifyPassword } from "@/features/auth/logic/auth-logic";
|
||||||
|
import { ensureAdminExists } from "@/features/auth/logic/seed-admin";
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
providers: [GitHub],
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
username: { label: "Utilisateur", type: "text" },
|
||||||
|
password: { label: "Mot de passe", type: "password" }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
// S'assure que le compte admin par défaut existe
|
||||||
|
await ensureAdminExists();
|
||||||
|
|
||||||
|
if (!credentials?.username || !credentials?.password) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [user] = await db.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, credentials.username as string));
|
||||||
|
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const isValid = verifyPassword(credentials.password as string, user.passwordHash);
|
||||||
|
if (!isValid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id.toString(),
|
||||||
|
name: user.username,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Auth Error:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.role = (user as any).role;
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
(session.user as any).role = token.role as string;
|
||||||
|
(session.user as any).id = token.id as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: "/login",
|
||||||
|
}
|
||||||
});
|
});
|
||||||
+35
-5
@@ -1,8 +1,38 @@
|
|||||||
export { auth as middleware } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const isLoggedIn = !!req.auth;
|
||||||
|
const { nextUrl } = req;
|
||||||
|
|
||||||
|
const isApiAuthRoute = nextUrl.pathname.startsWith("/api/auth");
|
||||||
|
const isPublicRoute = nextUrl.pathname === "/login" || nextUrl.pathname.startsWith("/public");
|
||||||
|
|
||||||
|
// 1. Laisser passer les requêtes d'auth API
|
||||||
|
if (isApiAuthRoute) return NextResponse.next();
|
||||||
|
|
||||||
|
// 2. Rediriger vers /login si non connecté et route non publique
|
||||||
|
if (!isLoggedIn && !isPublicRoute) {
|
||||||
|
return NextResponse.redirect(new URL("/login", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Rediriger vers le dashboard si déjà connecté et sur /login
|
||||||
|
if (isLoggedIn && isPublicRoute) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Protection par rôle (Admin seulement pour les paramètres)
|
||||||
|
const isAdminRoute = nextUrl.pathname.startsWith("/settings") || nextUrl.pathname.startsWith("/admin");
|
||||||
|
const userRole = (req.auth?.user as any)?.role;
|
||||||
|
|
||||||
|
if (isAdminRoute && userRole !== "admin") {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
// Next.js 16+: use 'matcher' with App Router routes only
|
// Protège toutes les routes sauf fichiers statiques et assets
|
||||||
matcher: [
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$).*)"],
|
||||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user