feat: integrate NextAuth.js for authentication, update database service to use better-sqlite3 adapter, and enhance header component with user session management

This commit is contained in:
Julien Froidefond
2025-11-27 13:08:09 +01:00
parent 68ef3731fa
commit 6a9bf88a65
15 changed files with 965 additions and 31 deletions

View File

@@ -0,0 +1,112 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const email = formData.get('email') as string;
const password = formData.get('password') as string;
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Email ou mot de passe incorrect');
} else {
router.push('/sessions');
router.refresh();
}
} catch {
setError('Une erreur est survenue');
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-3xl">📊</span>
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
</Link>
<p className="mt-2 text-muted">Connectez-vous à votre compte</p>
</div>
<form
onSubmit={handleSubmit}
className="rounded-xl border border-border bg-card p-8 shadow-lg"
>
{error && (
<div className="mb-4 rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="vous@exemple.com"
/>
</div>
<div className="mb-6">
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="current-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Connexion...' : 'Se connecter'}
</button>
<p className="mt-6 text-center text-sm text-muted">
Pas encore de compte ?{' '}
<Link href="/register" className="font-medium text-primary hover:underline">
Créer un compte
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useState } from 'react';
import { signIn } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
export default function RegisterPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setLoading(true);
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string;
if (password !== confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setLoading(false);
return;
}
if (password.length < 6) {
setError('Le mot de passe doit contenir au moins 6 caractères');
setLoading(false);
return;
}
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Une erreur est survenue');
setLoading(false);
return;
}
// Auto sign in after registration
const result = await signIn('credentials', {
email,
password,
redirect: false,
});
if (result?.error) {
setError('Compte créé mais erreur de connexion. Veuillez vous connecter manuellement.');
} else {
router.push('/sessions');
router.refresh();
}
} catch {
setError('Une erreur est survenue');
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href="/" className="inline-flex items-center gap-2">
<span className="text-3xl">📊</span>
<span className="text-2xl font-bold text-foreground">SWOT Manager</span>
</Link>
<p className="mt-2 text-muted">Créez votre compte</p>
</div>
<form
onSubmit={handleSubmit}
className="rounded-xl border border-border bg-card p-8 shadow-lg"
>
{error && (
<div className="mb-4 rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="mb-4">
<label htmlFor="name" className="mb-2 block text-sm font-medium text-foreground">
Nom
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="Jean Dupont"
/>
</div>
<div className="mb-4">
<label htmlFor="email" className="mb-2 block text-sm font-medium text-foreground">
Email
</label>
<input
id="email"
name="email"
type="email"
required
autoComplete="email"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="vous@exemple.com"
/>
</div>
<div className="mb-4">
<label htmlFor="password" className="mb-2 block text-sm font-medium text-foreground">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
autoComplete="new-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<div className="mb-6">
<label
htmlFor="confirmPassword"
className="mb-2 block text-sm font-medium text-foreground"
>
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
autoComplete="new-password"
className="w-full rounded-lg border border-input-border bg-input px-4 py-2.5 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full rounded-lg bg-primary px-4 py-2.5 font-semibold text-primary-foreground transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Création...' : 'Créer mon compte'}
</button>
<p className="mt-6 text-center text-sm text-muted">
Déjà un compte ?{' '}
<Link href="/login" className="font-medium text-primary hover:underline">
Se connecter
</Link>
</p>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
import { handlers } from '@/lib/auth';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { registerUser } from '@/services/auth';
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, password, name } = body;
if (!email || !password) {
return NextResponse.json({ error: 'Email et mot de passe requis' }, { status: 400 });
}
const result = await registerUser({ email, password, name });
if (!result.success) {
return NextResponse.json({ error: result.error }, { status: 400 });
}
return NextResponse.json({ user: result.user }, { status: 201 });
} catch (error) {
console.error('Registration error:', error);
return NextResponse.json({ error: 'Erreur lors de la création du compte' }, { status: 500 });
}
}

View File

@@ -1,5 +1,6 @@
'use client';
import { SessionProvider } from 'next-auth/react';
import { ThemeProvider } from '@/contexts/ThemeContext';
import { Header } from '@/components/layout/Header';
import { ReactNode } from 'react';
@@ -10,11 +11,13 @@ interface ProvidersProps {
export function Providers({ children }: ProvidersProps) {
return (
<ThemeProvider>
<div className="min-h-screen bg-background">
<Header />
{children}
</div>
</ThemeProvider>
<SessionProvider>
<ThemeProvider>
<div className="min-h-screen bg-background">
<Header />
{children}
</div>
</ThemeProvider>
</SessionProvider>
);
}

View File

@@ -1,10 +1,14 @@
'use client';
import Link from 'next/link';
import { useSession, signOut } from 'next-auth/react';
import { useTheme } from '@/contexts/ThemeContext';
import { useState } from 'react';
export function Header() {
const { theme, toggleTheme } = useTheme();
const { data: session, status } = useSession();
const [menuOpen, setMenuOpen] = useState(false);
return (
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-sm">
@@ -15,12 +19,14 @@ export function Header() {
</Link>
<nav className="flex items-center gap-4">
<Link
href="/sessions"
className="text-muted transition-colors hover:text-foreground"
>
Mes Sessions
</Link>
{status === 'authenticated' && session?.user && (
<Link
href="/sessions"
className="text-muted transition-colors hover:text-foreground"
>
Mes Sessions
</Link>
)}
<button
onClick={toggleTheme}
@@ -29,9 +35,66 @@ export function Header() {
>
{theme === 'light' ? '🌙' : '☀️'}
</button>
{status === 'loading' ? (
<div className="h-9 w-20 animate-pulse rounded-lg bg-card-hover" />
) : status === 'authenticated' && session?.user ? (
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="flex h-9 items-center gap-2 rounded-lg border border-border bg-card px-3 transition-colors hover:bg-card-hover"
>
<span className="text-sm font-medium text-foreground">
{session.user.name || session.user.email?.split('@')[0]}
</span>
<svg
className={`h-4 w-4 text-muted transition-transform ${menuOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 z-20 mt-2 w-48 rounded-lg border border-border bg-card py-1 shadow-lg">
<div className="border-b border-border px-4 py-2">
<p className="text-xs text-muted">Connecté en tant que</p>
<p className="truncate text-sm font-medium text-foreground">
{session.user.email}
</p>
</div>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="w-full px-4 py-2 text-left text-sm text-destructive hover:bg-card-hover"
>
Se déconnecter
</button>
</div>
</>
)}
</div>
) : (
<Link
href="/login"
className="flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-hover"
>
Connexion
</Link>
)}
</nav>
</div>
</header>
);
}

29
src/lib/auth.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { NextAuthConfig } from 'next-auth';
export const authConfig: NextAuthConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnProtectedPage =
nextUrl.pathname.startsWith('/sessions') || nextUrl.pathname.startsWith('/api/sessions');
const isOnAuthPage =
nextUrl.pathname.startsWith('/login') || nextUrl.pathname.startsWith('/register');
if (isOnProtectedPage) {
if (isLoggedIn) return true;
return false; // Redirect to login
}
if (isOnAuthPage && isLoggedIn) {
return Response.redirect(new URL('/sessions', nextUrl));
}
return true;
},
},
providers: [], // Configured in auth.ts
};

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

@@ -0,0 +1,62 @@
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { compare } from 'bcryptjs';
import { prisma } from '@/services/database';
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user) {
return null;
}
const isPasswordValid = await compare(credentials.password as string, user.password);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
],
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});

10
src/middleware.ts Normal file
View File

@@ -0,0 +1,10 @@
import NextAuth from 'next-auth';
import { authConfig } from '@/lib/auth.config';
export default NextAuth(authConfig).auth;
export const config = {
// Match all paths except static files and api routes that don't need auth
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'],
};

68
src/services/auth.ts Normal file
View File

@@ -0,0 +1,68 @@
import { hash } from 'bcryptjs';
import { prisma } from '@/services/database';
export interface RegisterInput {
email: string;
password: string;
name?: string;
}
export interface AuthResult {
success: boolean;
error?: string;
user?: {
id: string;
email: string;
name: string | null;
};
}
export async function registerUser(input: RegisterInput): Promise<AuthResult> {
const { email, password, name } = input;
// Check if user already exists
const existingUser = await prisma.user.findUnique({
where: { email },
});
if (existingUser) {
return {
success: false,
error: 'Un compte existe déjà avec cet email',
};
}
// Hash password
const hashedPassword = await hash(password, 12);
// Create user
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: name || null,
},
});
return {
success: true,
user: {
id: user.id,
email: user.email,
name: user.name,
},
};
}
export async function getUserByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
});
}
export async function getUserById(id: string) {
return prisma.user.findUnique({
where: { id },
});
}

View File

@@ -1,18 +1,25 @@
import { PrismaClient } from '@prisma/client';
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
function createPrismaClient() {
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL ?? 'file:./prisma/dev.db',
});
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
}
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
export default prisma;