diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..5eb7011 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -0,0 +1,17 @@ +import { LoginForm } from "@/features/auth/components/login-form"; + +export default function LoginPage() { + return ( +
+ {/* Éléments de design en arrière-plan pour l'effet "Apple Premium" */} +
+
+
+
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 39cf81b..01029f3 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -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() { + {/* Gestion des Utilisateurs */} +
+ +
+ {/* Save */}
+
+ {/* Footer */} -
+

v1.0.0-beta

diff --git a/src/db/schema.ts b/src/db/schema.ts index 30e4f63..35ccf2e 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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(), diff --git a/src/features/admin/api/user-actions.ts b/src/features/admin/api/user-actions.ts new file mode 100644 index 0000000..2d1044b --- /dev/null +++ b/src/features/admin/api/user-actions.ts @@ -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." }; + } +} diff --git a/src/features/admin/components/user-management.tsx b/src/features/admin/components/user-management.tsx new file mode 100644 index 0000000..db9581c --- /dev/null +++ b/src/features/admin/components/user-management.tsx @@ -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([]); + 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 ( +
+ +

Chargement des utilisateurs...

+
+ ); + } + + return ( +
+ {/* Formulaire de création */} +
+
+
+ +
+
+

Nouvel Utilisateur

+

Ajoutez un collaborateur à la plateforme.

+
+
+ +
+
+ + 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" + /> +
+
+ + 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="••••••••" + /> +
+
+ + +
+ +
+
+ + {/* Liste des utilisateurs */} +
+

Liste des comptes

+ {users.map((u) => ( +
+
+ {u.role === "admin" ? : } +
+
+

{u.username}

+ + {u.role === "admin" ? "Administrateur" : "Utilisateur Standard"} + +
+
+ +
+
+ ))} +
+ + setModal({ ...modal, isOpen: false })} + title={modal.title} + message={modal.message} + /> +
+ ); +} diff --git a/src/features/auth/api/login-action.ts b/src/features/auth/api/login-action.ts new file mode 100644 index 0000000..9b514f9 --- /dev/null +++ b/src/features/auth/api/login-action.ts @@ -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; + } +} diff --git a/src/features/auth/components/login-form.tsx b/src/features/auth/components/login-form.tsx new file mode 100644 index 0000000..55d3aa8 --- /dev/null +++ b/src/features/auth/components/login-form.tsx @@ -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 ( +
+
+
+ +
+

+ CollectFlow +

+

+ Connectez-vous pour accéder à votre espace d'arbitrage. +

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + {error && ( +
+
+ {error} +
+ )} + + + + +
+

+ Plateforme d'Arbitrage Magasin
+ Propulsée par l'Intelligence Artificielle +

+
+
+ ); +} diff --git a/src/features/auth/logic/auth-logic.ts b/src/features/auth/logic/auth-logic.ts new file mode 100644 index 0000000..2cfff19 --- /dev/null +++ b/src/features/auth/logic/auth-logic.ts @@ -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; + } +} diff --git a/src/features/auth/logic/seed-admin.ts b/src/features/auth/logic/seed-admin.ts new file mode 100644 index 0000000..d53a20d --- /dev/null +++ b/src/features/auth/logic/seed-admin.ts @@ -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; +} diff --git a/src/features/snapshots/api/delete-snapshot.ts b/src/features/snapshots/api/delete-snapshot.ts index de84817..a142777 100644 --- a/src/features/snapshots/api/delete-snapshot.ts +++ b/src/features/snapshots/api/delete-snapshot.ts @@ -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); diff --git a/src/features/snapshots/api/get-snapshots.ts b/src/features/snapshots/api/get-snapshots.ts index 456fcd4..288a34e 100644 --- a/src/features/snapshots/api/get-snapshots.ts +++ b/src/features/snapshots/api/get-snapshots.ts @@ -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)); diff --git a/src/features/snapshots/api/save-snapshot.ts b/src/features/snapshots/api/save-snapshot.ts index e94892b..4184b26 100644 --- a/src/features/snapshots/api/save-snapshot.ts +++ b/src/features/snapshots/api/save-snapshot.ts @@ -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, diff --git a/src/lib/auth.ts b/src/lib/auth.ts index ea97aab..52345c7 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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", + } }); diff --git a/src/middleware.ts b/src/middleware.ts index a93c535..8ba6e04 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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$).*)"], };