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

+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,