Add authentication system with next-auth (CredentialsProvider + JWT)

- Login page with email/password
- Auth middleware (proxy) protecting all routes
- Seed endpoint for admin user creation (admin@optiquestock.com / admin123)
- Session provider wrapping root layout
- User info + logout button in header
- Updated POS sales route to track authenticated user
This commit is contained in:
2026-05-30 15:35:40 +01:00
parent d23f2ab53e
commit 816c1c40c6
13 changed files with 291 additions and 25 deletions

View File

@@ -42,6 +42,7 @@
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -76,6 +77,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "^1.3.4",
@@ -676,6 +678,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -832,6 +836,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],

View File

@@ -50,6 +50,7 @@
"@tanstack/react-query": "^5.82.0",
"@tanstack/react-table": "^8.21.3",
"@types/qrcode": "^1.5.6",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -84,6 +85,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^3.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"bun-types": "^1.3.4",

Binary file not shown.

View File

@@ -0,0 +1,6 @@
import NextAuth from 'next-auth'
import { authOptions } from '@/lib/auth'
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { db } from '@/lib/db'
export async function POST() {
try {
const existing = await db.employe.findUnique({
where: { email: 'admin@optiquestock.com' },
})
if (existing) {
return NextResponse.json({ message: 'Admin already exists' })
}
const hashedPassword = await bcrypt.hash('admin123', 12)
const employe = await db.employe.create({
data: {
email: 'admin@optiquestock.com',
nom: 'Admin',
prenom: 'Admin',
role: 'ADMIN',
motDePasse: hashedPassword,
},
})
return NextResponse.json({
message: 'Admin user created',
email: employe.email,
password: 'admin123',
})
} catch (error) {
console.error('Seed error:', error)
return NextResponse.json({ error: 'Seed failed' }, { status: 500 })
}
}

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { StatutVente, ModePaiement } from '@prisma/client'
import { getCurrentUserId, requireAuth } from '@/lib/auth-utils'
// Helper function to generate sale number
async function generateSaleNumber(): Promise<string> {
@@ -55,6 +56,9 @@ export async function GET(request: NextRequest) {
// POST /api/pos/sales - Create a new sale
export async function POST(request: NextRequest) {
try {
const authError = await requireAuth()
if (authError) return authError
const body = await request.json()
const {
clientId,
@@ -103,6 +107,9 @@ export async function POST(request: NextRequest) {
}
}
// Get current user
const employeId = await getCurrentUserId()
// Generate sale number
const numero = await generateSaleNumber()
@@ -158,7 +165,7 @@ export async function POST(request: NextRequest) {
montant: paiement.montant,
reference: paiement.reference || null,
notes: paiement.notes || null,
employeId: null // Can be updated with authentication
employeId
}
})
}

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import SessionProvider from "@/components/auth/SessionProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -14,25 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Z.ai Code Scaffold - AI-Powered Development",
description: "Modern Next.js scaffold optimized for AI-powered development with Z.ai. Built with TypeScript, Tailwind CSS, and shadcn/ui.",
keywords: ["Z.ai", "Next.js", "TypeScript", "Tailwind CSS", "shadcn/ui", "AI development", "React"],
authors: [{ name: "Z.ai Team" }],
icons: {
icon: "https://z-cdn.chatglm.cn/z-ai/static/logo.svg",
},
openGraph: {
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
url: "https://chat.z.ai",
siteName: "Z.ai",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Z.ai Code Scaffold",
description: "AI-powered development with modern React stack",
},
title: "OptiqueStock - Gestion de Magasin d'Optique",
description: "Application de gestion de magasin d'optique : clients, produits, ventes, achats, atelier et rapports.",
};
export default function RootLayout({
@@ -41,11 +25,13 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<html lang="fr" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
>
{children}
<SessionProvider>
{children}
</SessionProvider>
<Toaster />
</body>
</html>

88
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,88 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Eye } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
setLoading(false)
if (result?.error) {
setError('Email ou mot de passe incorrect')
return
}
router.push('/')
router.refresh()
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<Card className="w-full max-w-md mx-4">
<CardHeader className="text-center space-y-2">
<div className="flex justify-center mb-2">
<div className="p-3 bg-primary rounded-lg">
<Eye className="h-8 w-8 text-primary-foreground" />
</div>
</div>
<CardTitle className="text-2xl">OptiqueStock</CardTitle>
<CardDescription>Connectez-vous pour accéder à l'application</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@optiquestock.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-red-500 text-center">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Connexion...' : 'Se connecter'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,8 @@
'use client'
import { useState } from 'react'
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -13,7 +15,9 @@ import {
BarChart3,
Eye,
LayoutDashboard,
Wrench
Wrench,
LogOut,
User
} from 'lucide-react'
import POSModule from '@/components/pos/POSModule'
import { ProduitListe } from '@/components/products/ProduitListe'
@@ -90,8 +94,15 @@ const modules: ModuleCard[] = [
]
export default function Home() {
const { data: session } = useSession()
const router = useRouter()
const [currentModule, setCurrentModule] = useState<Module>('HOME')
if (!session) {
router.push('/login')
return null
}
const renderModule = () => {
if (currentModule === 'HOME') {
return (
@@ -347,9 +358,18 @@ export default function Home() {
<Badge variant="outline" className="text-sm">
v1.0.0
</Badge>
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center text-primary-foreground text-sm font-medium">
OP
<div className="flex items-center gap-2 text-sm text-gray-600">
<User className="h-4 w-4" />
<span className="hidden sm:inline">{session?.user?.name}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => signOut({ callbackUrl: '/login' })}
title="Se déconnecter"
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
</div>

View File

@@ -0,0 +1,7 @@
'use client'
import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'
export default function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>
}

20
src/lib/auth-utils.ts Normal file
View File

@@ -0,0 +1,20 @@
import { getServerSession } from 'next-auth'
import { authOptions } from './auth'
import { NextResponse } from 'next/server'
export async function getSession() {
return await getServerSession(authOptions)
}
export async function getCurrentUserId(): Promise<string | null> {
const session = await getSession()
return (session?.user as any)?.id || null
}
export async function requireAuth() {
const session = await getSession()
if (!session?.user) {
return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
}
return null
}

58
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,58 @@
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import bcrypt from 'bcryptjs'
import { db } from './db'
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Mot de passe', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const employe = await db.employe.findUnique({
where: { email: credentials.email },
})
if (!employe || !employe.actif) return null
const isValid = await bcrypt.compare(credentials.password, employe.motDePasse)
if (!isValid) return null
return {
id: employe.id,
email: employe.email,
name: `${employe.prenom} ${employe.nom}`,
role: employe.role,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
if (session.user) {
(session.user as any).id = token.id
(session.user as any).role = token.role
}
return session
},
},
pages: {
signIn: '/login',
},
session: {
strategy: 'jwt',
},
secret: process.env.NEXTAUTH_SECRET,
}

30
src/proxy.ts Normal file
View File

@@ -0,0 +1,30 @@
import { getToken } from 'next-auth/jwt'
import { NextRequest, NextResponse } from 'next/server'
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (
pathname.startsWith('/login') ||
pathname.startsWith('/api/auth') ||
pathname.startsWith('/_next/static') ||
pathname.startsWith('/_next/image') ||
pathname === '/favicon.ico'
) {
return NextResponse.next()
}
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET })
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}