feat: implement user authentication and authorization with NextAuth, including user schema, password hashing, and role-based access control.

This commit is contained in:
Michael committed 2026-02-22 14:11:36 +01:00
1 parent c842c2447a
commit 359d196c4d
18 files changed
+652 -32

No files matched your search

+17
View File
@@ -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>
);
}
+11
View File
@@ -8,6 +8,9 @@ import { useScoreSettingsStore } from "@/features/score/store/use-score-settings
import { useDbSettingsStore } from "@/features/settings/store/use-db-settings-store";
import { testDatabaseConnection, saveDatabaseSettings, getSavedDatabaseConfig } from "@/features/settings/actions";
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; }
@@ -402,6 +405,14 @@ export default function SettingsPage() {
</div>
</Section>
{/* Gestion des Utilisateurs */}
<Section
title="Gestion des Utilisateurs"
subtitle="Administration des accès et des rôles"
>
<UserManagement />
</Section>
{/* Save */}
<div className="flex items-center gap-3">
<button
+2
View File
@@ -0,0 +1,2 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+4 -2
View File
@@ -14,6 +14,8 @@ export const metadata: Metadata = {
description: "Révision d'Assortiment Haute Performance",
};
import { Providers } from "./providers";
export default function RootLayout({
children,
}: Readonly<{
@@ -22,9 +24,9 @@ export default function RootLayout({
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider>
<Providers>
{children}
</ThemeProvider>
</Providers>
</body>
</html>
);
+14
View File
@@ -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>
);
}
+33 -12
View File
@@ -1,22 +1,24 @@
"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 { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { useGridStore } from "@/features/grid/store/use-grid-store";
import { useEffect, useState } from "react";
import { useSession, signOut } from "next-auth/react";
const NAV_ITEMS = [
{ icon: LayoutGrid, label: "Grille", href: "/grid" },
{ icon: Camera, label: "Snapshots", href: "/snapshots" },
{ icon: FileDown, label: "Exports", href: "/exports" },
{ 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() {
const pathname = usePathname();
const { data: session } = useSession();
const activeGridQuery = useGridStore((s) => s.activeGridQuery);
const [isMounted, setIsMounted] = useState(false);
@@ -24,6 +26,9 @@ export function Sidebar() {
setIsMounted(true);
}, []);
const userRole = (session?.user as any)?.role;
const filteredItems = NAV_ITEMS.filter(item => !item.adminOnly || userRole === "admin");
return (
<aside
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>
{/* Navigation */}
<nav className="px-2 pt-2.5 space-y-0.5">
{NAV_ITEMS.map(({ icon: Icon, label, href }) => {
<nav className="px-2 pt-2.5 space-y-0.5 flex-1">
{filteredItems.map(({ icon: Icon, label, href }) => {
const isActive = pathname.startsWith(href);
// Restore grid context if navigating back to the grid
let resolvedHref = href;
if (href === "/grid" && isMounted && 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",
!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} />
{label}
@@ -78,8 +76,31 @@ export function Sidebar() {
})}
</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 */}
<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>
</div>
</aside>
+14 -1
View File
@@ -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", {
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) */
export const sessionSnapshots = pgTable("session_snapshots", {
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(),
nomFournisseur: varchar("nom_fournisseur", { length: 255 }),
magasin: varchar("magasin", { length: 20 }).notNull(),
+86
View File
@@ -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>
);
}
+28
View File
@@ -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&apos;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&apos;Arbitrage Magasin<br />
Propulsée par l&apos;Intelligence Artificielle
</p>
</div>
</div>
);
}
+28
View File
@@ -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;
}
}
+29
View File
@@ -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;
}
+11 -2
View File
@@ -2,11 +2,20 @@
import { db } from "@/db";
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) {
const session = await auth();
const userId = session?.user ? Number((session.user as any).id) : null;
if (!userId) {
return { success: false, error: "Non autorisé" };
}
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 };
} catch (err) {
console.error(err);
+11 -5
View File
@@ -2,14 +2,20 @@
import { db } from "@/db";
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") {
let query = db.select().from(sessionSnapshots);
const session = await auth();
const userId = session?.user ? Number((session.user as any).id) : null;
if (type) {
// @ts-ignore - type column added dynamically
return query.where(eq(sessionSnapshots.type, type)).orderBy(desc(sessionSnapshots.createdAt));
let query = db.select().from(sessionSnapshots);
const conditions = [];
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));
+10 -3
View File
@@ -3,7 +3,8 @@
import { db } from "@/db";
import { sessionSnapshots } from "@/db/schema";
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({
codeFournisseur: z.string(),
@@ -31,11 +32,14 @@ export async function saveSnapshot(raw: unknown) {
}
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 {
const [created] = await db
.insert(sessionSnapshots)
.values({
userId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
@@ -55,6 +59,7 @@ export async function saveSnapshot(raw: unknown) {
await db.execute(sql`
CREATE TABLE IF NOT EXISTS session_snapshots (
id SERIAL PRIMARY KEY,
user_id INTEGER,
code_fournisseur VARCHAR(20) NOT NULL,
nom_fournisseur VARCHAR(255),
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 {
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) {
// Ignore if column already exists or other alter errors
// Ignore
}
// Deuxième tentative d'insertion
const [retryCreated] = await db
.insert(sessionSnapshots)
.values({
userId,
codeFournisseur,
nomFournisseur: nomFournisseur ?? null,
magasin,
+60 -2
View File
@@ -1,6 +1,64 @@
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({
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
View File
@@ -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 = {
// Next.js 16+: use 'matcher' with App Router routes only
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$).*)",
],
// Protège toutes les routes sauf fichiers statiques et assets
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$).*)"],
};