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:
6
bun.lock
6
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
prisma/dev.db
BIN
prisma/dev.db
Binary file not shown.
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal 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 }
|
||||
36
src/app/api/auth/seed/route.ts
Normal file
36
src/app/api/auth/seed/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<SessionProvider>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
88
src/app/login/page.tsx
Normal file
88
src/app/login/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
7
src/components/auth/SessionProvider.tsx
Normal file
7
src/components/auth/SessionProvider.tsx
Normal 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
20
src/lib/auth-utils.ts
Normal 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
58
src/lib/auth.ts
Normal 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
30
src/proxy.ts
Normal 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).*)'],
|
||||
}
|
||||
Reference in New Issue
Block a user