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 { 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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
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
@@ -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(),
|
||||
|
||||
@@ -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 { 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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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$).*)"],
|
||||
};
|
||||
Reference in new issue
Block a user