feat: add authentication support and user model

- Updated `env.example` to include NextAuth configuration for authentication.
- Added `next-auth` dependency to manage user sessions.
- Introduced `User` model in Prisma schema with fields for user details and password hashing.
- Integrated `AuthProvider` in layout for session management across the app.
- Enhanced `Header` component with `AuthButton` for user authentication controls.
This commit is contained in:
Julien Froidefond
2025-09-30 21:49:52 +02:00
parent 43c141d3cd
commit 17b86b6087
20 changed files with 1418 additions and 13 deletions

103
src/actions/profile.ts Normal file
View File

@@ -0,0 +1,103 @@
'use server'
import { getServerSession } from 'next-auth/next'
import { authOptions } from '@/lib/auth'
import { usersService } from '@/services/users'
import { revalidatePath } from 'next/cache'
export async function updateProfile(formData: {
name?: string
firstName?: string
lastName?: string
avatar?: string
}) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' }
}
// Validation
if (formData.firstName && formData.firstName.length > 50) {
return { success: false, error: 'Le prénom ne peut pas dépasser 50 caractères' }
}
if (formData.lastName && formData.lastName.length > 50) {
return { success: false, error: 'Le nom ne peut pas dépasser 50 caractères' }
}
if (formData.name && formData.name.length > 100) {
return { success: false, error: 'Le nom d\'affichage ne peut pas dépasser 100 caractères' }
}
if (formData.avatar && formData.avatar.length > 500) {
return { success: false, error: 'L\'URL de l\'avatar ne peut pas dépasser 500 caractères' }
}
// Mettre à jour l'utilisateur
const updatedUser = await usersService.updateUser(session.user.id, {
name: formData.name || null,
firstName: formData.firstName || null,
lastName: formData.lastName || null,
avatar: formData.avatar || null,
})
// Revalider la page de profil
revalidatePath('/profile')
return {
success: true,
user: {
id: updatedUser.id,
email: updatedUser.email,
name: updatedUser.name,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
avatar: updatedUser.avatar,
role: updatedUser.role,
createdAt: updatedUser.createdAt.toISOString(),
lastLoginAt: updatedUser.lastLoginAt?.toISOString() || null,
}
}
} catch (error) {
console.error('Profile update error:', error)
return { success: false, error: 'Erreur lors de la mise à jour du profil' }
}
}
export async function getProfile() {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return { success: false, error: 'Non authentifié' }
}
const user = await usersService.getUserById(session.user.id)
if (!user) {
return { success: false, error: 'Utilisateur non trouvé' }
}
return {
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
firstName: user.firstName,
lastName: user.lastName,
avatar: user.avatar,
role: user.role,
createdAt: user.createdAt.toISOString(),
lastLoginAt: user.lastLoginAt?.toISOString() || null,
}
}
} catch (error) {
console.error('Profile get error:', error)
return { success: false, error: 'Erreur lors de la récupération du profil' }
}
}

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,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { usersService } from '@/services/users'
export async function POST(request: NextRequest) {
try {
const { email, name, firstName, lastName, password } = await request.json()
// Validation
if (!email || !password) {
return NextResponse.json(
{ error: 'Email et mot de passe requis' },
{ status: 400 }
)
}
if (password.length < 6) {
return NextResponse.json(
{ error: 'Le mot de passe doit contenir au moins 6 caractères' },
{ status: 400 }
)
}
// Vérifier si l'email existe déjà
const emailExists = await usersService.emailExists(email)
if (emailExists) {
return NextResponse.json(
{ error: 'Un compte avec cet email existe déjà' },
{ status: 400 }
)
}
// Créer l'utilisateur
const user = await usersService.createUser({
email,
name,
firstName,
lastName,
password,
})
return NextResponse.json({
message: 'Compte créé avec succès',
user: {
id: user.id,
email: user.email,
name: user.name,
firstName: user.firstName,
lastName: user.lastName,
}
})
} catch (error) {
console.error('Registration error:', error)
return NextResponse.json(
{ error: 'Erreur lors de la création du compte' },
{ status: 500 }
)
}
}

View File

@@ -7,6 +7,7 @@ import { UserPreferencesProvider } from "@/contexts/UserPreferencesContext";
import { KeyboardShortcutsProvider } from "@/contexts/KeyboardShortcutsContext";
import { userPreferencesService } from "@/services/core/user-preferences";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
import { AuthProvider } from "../components/AuthProvider";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -36,19 +37,21 @@ export default async function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<ThemeProvider
initialTheme={initialPreferences.viewPreferences.theme}
userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme}
>
<KeyboardShortcutsProvider>
<KeyboardShortcuts />
<JiraConfigProvider config={initialPreferences.jiraConfig}>
<UserPreferencesProvider initialPreferences={initialPreferences}>
{children}
</UserPreferencesProvider>
</JiraConfigProvider>
</KeyboardShortcutsProvider>
</ThemeProvider>
<AuthProvider>
<ThemeProvider
initialTheme={initialPreferences.viewPreferences.theme}
userPreferredTheme={initialPreferences.viewPreferences.theme === 'light' ? 'dark' : initialPreferences.viewPreferences.theme}
>
<KeyboardShortcutsProvider>
<KeyboardShortcuts />
<JiraConfigProvider config={initialPreferences.jiraConfig}>
<UserPreferencesProvider initialPreferences={initialPreferences}>
{children}
</UserPreferencesProvider>
</JiraConfigProvider>
</KeyboardShortcutsProvider>
</ThemeProvider>
</AuthProvider>
</body>
</html>
);

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

@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import { signIn, getSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { TowerLogo } from '@/components/TowerLogo'
import { TowerBackground } from '@/components/TowerBackground'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
setError('Email ou mot de passe incorrect')
} else {
// Vérifier que la session est bien créée
const session = await getSession()
if (session) {
router.push('/')
}
}
} catch (error) {
setError('Une erreur est survenue')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen relative overflow-hidden">
<TowerBackground />
{/* Contenu principal */}
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo et titre */}
<TowerLogo size="md" className="mb-8" />
{/* Formulaire */}
<div className="bg-[var(--card)]/80 backdrop-blur-sm rounded-2xl shadow-xl border border-[var(--border)] p-8">
<h2 className="text-2xl font-mono font-bold text-[var(--foreground)] text-center mb-6">
Connexion
</h2>
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Email
</label>
<Input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder=""
className="w-full"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Mot de passe
</label>
<Input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder=""
className="w-full"
/>
</div>
</div>
{error && (
<div className="text-[var(--destructive)] text-sm text-center bg-[var(--destructive)]/10 p-3 rounded-lg border border-[var(--destructive)]/20">
{error}
</div>
)}
<div>
<Button
type="submit"
disabled={isLoading}
className="w-full py-3 text-lg font-mono"
>
{isLoading ? 'Connexion...' : 'Se connecter'}
</Button>
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
<p>
Pas encore de compte ?{' '}
<Link href="/register" className="text-[var(--primary)] hover:underline font-medium">
Créer un compte
</Link>
</p>
</div>
</form>
</div>
</div>
</div>
</div>
)
}

279
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,279 @@
'use client'
import { useState, useEffect, useTransition } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Header } from '@/components/ui/Header'
import { updateProfile, getProfile } from '@/actions/profile'
interface UserProfile {
id: string
email: string
name: string | null
firstName: string | null
lastName: string | null
avatar: string | null
role: string
createdAt: string
lastLoginAt: string | null
}
export default function ProfilePage() {
const { data: session, update } = useSession()
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [profile, setProfile] = useState<UserProfile | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
// Form data
const [formData, setFormData] = useState({
name: '',
firstName: '',
lastName: '',
avatar: '',
})
useEffect(() => {
if (!session) {
router.push('/login')
return
}
fetchProfile()
}, [session, router])
const fetchProfile = async () => {
try {
const result = await getProfile()
if (!result.success || !result.user) {
throw new Error(result.error || 'Erreur lors du chargement du profil')
}
setProfile(result.user)
setFormData({
name: result.user.name || '',
firstName: result.user.firstName || '',
lastName: result.user.lastName || '',
avatar: result.user.avatar || '',
})
} catch (error) {
setError(error instanceof Error ? error.message : 'Erreur inconnue')
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
startTransition(async () => {
try {
const result = await updateProfile(formData)
if (!result.success || !result.user) {
setError(result.error || 'Erreur lors de la mise à jour')
return
}
setProfile(result.user)
setSuccess('Profil mis à jour avec succès')
// Mettre à jour la session NextAuth
await update({
...session,
user: {
...session?.user,
name: result.user.name || `${result.user.firstName || ''} ${result.user.lastName || ''}`.trim() || result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
avatar: result.user.avatar,
}
})
} catch (error) {
setError(error instanceof Error ? error.message : 'Erreur inconnue')
}
})
}
const handleChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
if (isLoading) {
return (
<div className="min-h-screen bg-[var(--background)]">
<Header title="TowerControl" subtitle="Profil utilisateur" />
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto">
<div className="text-center text-[var(--muted-foreground)]">
Chargement du profil...
</div>
</div>
</div>
</div>
)
}
if (!profile) {
return (
<div className="min-h-screen bg-[var(--background)]">
<Header title="TowerControl" subtitle="Profil utilisateur" />
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto">
<div className="text-center text-[var(--destructive)]">
Erreur lors du chargement du profil
</div>
</div>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-[var(--background)]">
<Header title="TowerControl" subtitle="Profil utilisateur" />
<div className="container mx-auto px-4 py-8">
<div className="max-w-2xl mx-auto">
{/* Informations générales */}
<div className="bg-[var(--card)] rounded-lg p-6 mb-6">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-4">
Informations générales
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1">
Email
</label>
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md">
{profile.email}
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
L&apos;email ne peut pas être modifié
</p>
</div>
<div>
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1">
Rôle
</label>
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md">
{profile.role}
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1">
Membre depuis
</label>
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md">
{new Date(profile.createdAt).toLocaleDateString('fr-FR')}
</div>
</div>
{profile.lastLoginAt && (
<div>
<label className="block text-sm font-medium text-[var(--muted-foreground)] mb-1">
Dernière connexion
</label>
<div className="text-[var(--foreground)] bg-[var(--input)] px-3 py-2 rounded-md">
{new Date(profile.lastLoginAt).toLocaleString('fr-FR')}
</div>
</div>
)}
</div>
</div>
{/* Formulaire de modification */}
<form onSubmit={handleSubmit} className="bg-[var(--card)] rounded-lg p-6">
<h2 className="text-xl font-mono font-bold text-[var(--foreground)] mb-4">
Modifier le profil
</h2>
<div className="space-y-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Prénom
</label>
<Input
id="firstName"
value={formData.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
placeholder="Votre prénom"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom de famille
</label>
<Input
id="lastName"
value={formData.lastName}
onChange={(e) => handleChange('lastName', e.target.value)}
placeholder="Votre nom de famille"
/>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom d&apos;affichage (optionnel)
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
placeholder="Nom d&apos;affichage personnalisé"
/>
<p className="text-xs text-[var(--muted-foreground)] mt-1">
Si vide, sera généré automatiquement à partir du prénom et nom
</p>
</div>
<div>
<label htmlFor="avatar" className="block text-sm font-medium text-[var(--foreground)] mb-2">
URL de l&apos;avatar (optionnel)
</label>
<Input
id="avatar"
value={formData.avatar}
onChange={(e) => handleChange('avatar', e.target.value)}
placeholder="https://example.com/avatar.jpg"
/>
</div>
</div>
{error && (
<div className="mt-4 text-[var(--destructive)] text-sm">
{error}
</div>
)}
{success && (
<div className="mt-4 text-[var(--success)] text-sm">
{success}
</div>
)}
<div className="mt-6">
<Button
type="submit"
disabled={isPending}
className="w-full"
>
{isPending ? 'Sauvegarde...' : 'Sauvegarder les modifications'}
</Button>
</div>
</form>
</div>
</div>
</div>
)
}

213
src/app/register/page.tsx Normal file
View File

@@ -0,0 +1,213 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import Link from 'next/link'
import { TowerLogo } from '@/components/TowerLogo'
import { TowerBackground } from '@/components/TowerBackground'
export default function RegisterPage() {
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
// Validation côté client
if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas')
setIsLoading(false)
return
}
if (password.length < 6) {
setError('Le mot de passe doit contenir au moins 6 caractères')
setIsLoading(false)
return
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
name,
firstName,
lastName,
password,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Une erreur est survenue')
}
// Rediriger vers la page de login avec un message de succès
router.push('/login?message=Compte créé avec succès')
} catch (error) {
setError(error instanceof Error ? error.message : 'Une erreur est survenue')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen relative overflow-hidden">
<TowerBackground />
{/* Contenu principal */}
<div className="relative z-10 min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo et titre */}
<TowerLogo size="md" className="mb-8" />
{/* Formulaire */}
<div className="bg-[var(--card)]/80 backdrop-blur-sm rounded-2xl shadow-xl border border-[var(--border)] p-8">
<h2 className="text-2xl font-mono font-bold text-[var(--foreground)] text-center mb-6">
Créer un compte
</h2>
<form className="space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Email
</label>
<Input
id="email"
name="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="votre@email.com"
className="w-full"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Prénom
</label>
<Input
id="firstName"
name="firstName"
type="text"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Prénom"
className="w-full"
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom
</label>
<Input
id="lastName"
name="lastName"
type="text"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Nom"
className="w-full"
/>
</div>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom d&apos;affichage (optionnel)
</label>
<Input
id="name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Nom d&apos;affichage personnalisé"
className="w-full"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Mot de passe
</label>
<Input
id="password"
name="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Minimum 6 caractères"
className="w-full"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-[var(--foreground)] mb-2">
Confirmer le mot de passe
</label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Répétez le mot de passe"
className="w-full"
/>
</div>
</div>
{error && (
<div className="text-[var(--destructive)] text-sm text-center bg-[var(--destructive)]/10 p-3 rounded-lg border border-[var(--destructive)]/20">
{error}
</div>
)}
<div>
<Button
type="submit"
disabled={isLoading}
className="w-full py-3 text-lg font-mono"
>
{isLoading ? 'Création du compte...' : 'Créer le compte'}
</Button>
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
<p>
Déjà un compte ?{' '}
<Link href="/login" className="text-[var(--primary)] hover:underline font-medium">
Se connecter
</Link>
</p>
</div>
</form>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useSession, signOut } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/Button'
export function AuthButton() {
const { data: session, status } = useSession()
const router = useRouter()
if (status === 'loading') {
return (
<div className="text-[var(--muted-foreground)] text-sm">
Chargement...
</div>
)
}
if (!session) {
return (
<Button
onClick={() => router.push('/login')}
size="sm"
>
Se connecter
</Button>
)
}
return (
<div className="flex items-center gap-1">
<Button
onClick={() => router.push('/profile')}
variant="ghost"
size="sm"
className="p-1 h-auto"
title={`Profil - ${session.user?.email}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</Button>
<Button
onClick={() => signOut({ callbackUrl: '/login' })}
variant="ghost"
size="sm"
className="p-1 h-auto"
title="Déconnexion"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</Button>
</div>
)
}

View File

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

View File

@@ -0,0 +1,11 @@
export function TowerBackground() {
return (
<div className="absolute inset-0 bg-gradient-to-br from-[var(--primary)]/20 via-[var(--background)] to-[var(--accent)]/20">
{/* Effet de profondeur */}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent"></div>
{/* Effet de lumière */}
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-[var(--primary)]/5 to-transparent"></div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
interface TowerLogoProps {
size?: 'sm' | 'md' | 'lg'
showText?: boolean
className?: string
}
export function TowerLogo({ size = 'md', showText = true, className = '' }: TowerLogoProps) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-32 h-32'
}
const textSizes = {
sm: 'text-2xl',
md: 'text-4xl',
lg: 'text-6xl'
}
return (
<div className={`text-center ${className}`}>
<div className={`inline-flex items-center justify-center ${sizeClasses[size]} rounded-2xl mb-4 text-6xl`}>
🗼
</div>
{showText && (
<>
<h1 className={`${textSizes[size]} font-mono font-bold text-[var(--foreground)] mb-2`}>
TowerControl
</h1>
<p className="text-[var(--muted-foreground)] text-lg">
Tour de contrôle de vos projets
</p>
</>
)}
</div>
)
}

View File

@@ -8,6 +8,7 @@ import { useState } from 'react';
import { Theme } from '@/lib/theme-config';
import { THEME_CONFIG, getThemeMetadata } from '@/lib/theme-config';
import { useKeyboardShortcutsModal } from '@/contexts/KeyboardShortcutsContext';
import { AuthButton } from '@/components/AuthButton';
interface HeaderProps {
title?: string;
@@ -173,6 +174,12 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
)}
</button>
</div>
</div>
{/* Auth controls à droite mobile */}
<div className="flex items-center gap-1">
<AuthButton />
</div>
</div>
@@ -264,9 +271,15 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s
</>
)}
</div>
</nav>
</div>
{/* Contrôles à droite */}
<div className="flex items-center gap-2">
<AuthButton />
</div>
</div>
</div>

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

@@ -0,0 +1,72 @@
import { NextAuthOptions } from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { usersService } from "@/services/users"
export const authOptions: NextAuthOptions = {
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
try {
// Chercher l'utilisateur dans la base de données
const user = await usersService.getUserByEmail(credentials.email)
if (!user) {
return null
}
// Vérifier le mot de passe
const isValidPassword = await usersService.verifyPassword(
credentials.password,
user.password
)
if (!isValidPassword) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name || `${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email,
firstName: user.firstName || undefined,
lastName: user.lastName || undefined,
avatar: user.avatar || undefined,
role: user.role,
}
} catch (error) {
console.error('Auth error:', error)
return null
}
}
})
],
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string
}
return session
},
},
}

33
src/middleware.ts Normal file
View File

@@ -0,0 +1,33 @@
import { withAuth } from "next-auth/middleware"
export default withAuth(
function middleware() {
// Le middleware s'exécute seulement si l'utilisateur est authentifié
// grâce à withAuth
},
{
callbacks: {
authorized: ({ token }) => {
// Vérifier si l'utilisateur a un token valide
return !!token
},
},
}
)
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api/auth (NextAuth routes)
* - login (login page)
* - register (registration page)
* - profile (profile page)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (images, etc.)
*/
'/((?!api/auth|login|register|profile|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}

150
src/services/users.ts Normal file
View File

@@ -0,0 +1,150 @@
import { prisma } from './core/database'
import bcrypt from 'bcryptjs'
export interface CreateUserData {
email: string
name?: string
firstName?: string
lastName?: string
avatar?: string
role?: string
password: string
}
export interface User {
id: string
email: string
name: string | null
firstName: string | null
lastName: string | null
avatar: string | null
role: string
isActive: boolean
lastLoginAt: Date | null
createdAt: Date
updatedAt: Date
}
export const usersService = {
async createUser(data: CreateUserData): Promise<User> {
const hashedPassword = await bcrypt.hash(data.password, 12)
const user = await prisma.user.create({
data: {
email: data.email,
name: data.name,
firstName: data.firstName,
lastName: data.lastName,
avatar: data.avatar,
role: data.role || 'user',
password: hashedPassword,
},
select: {
id: true,
email: true,
name: true,
firstName: true,
lastName: true,
avatar: true,
role: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
}
})
return user
},
async getUserByEmail(email: string) {
return await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
name: true,
firstName: true,
lastName: true,
avatar: true,
role: true,
isActive: true,
lastLoginAt: true,
password: true,
createdAt: true,
updatedAt: true,
}
})
},
async getUserById(id: string): Promise<User | null> {
return await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
name: true,
firstName: true,
lastName: true,
avatar: true,
role: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
}
})
},
async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
return await bcrypt.compare(password, hashedPassword)
},
async emailExists(email: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { email },
select: { id: true }
})
return !!user
},
async updateLastLogin(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() }
})
},
async updateUser(userId: string, data: {
name?: string | null
firstName?: string | null
lastName?: string | null
avatar?: string | null
}): Promise<User> {
const user = await prisma.user.update({
where: { id: userId },
data: {
name: data.name,
firstName: data.firstName,
lastName: data.lastName,
avatar: data.avatar,
updatedAt: new Date(),
},
select: {
id: true,
email: true,
name: true,
firstName: true,
lastName: true,
avatar: true,
role: true,
isActive: true,
lastLoginAt: true,
createdAt: true,
updatedAt: true,
}
})
return user
}
}

31
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
import NextAuth from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
email: string
name: string
firstName?: string
lastName?: string
avatar?: string
role: string
}
}
interface User {
id: string
email: string
name: string
firstName?: string
lastName?: string
avatar?: string
role: string
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
}
}