mirror of
https://github.com/R0m1k3/CollectFlow.git
synced 2026-10-16 03:39:04 +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,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;
|
||||
}
|
||||
Reference in new issue
Block a user