refactor: migrate authentication to NextAuth and clean up related services

This commit is contained in:
Julien Froidefond
2025-10-12 15:45:09 +02:00
parent 117ac243f5
commit 7d12a66c12
25 changed files with 558 additions and 353 deletions

174
NEXTAUTH_MIGRATION.md Normal file
View File

@@ -0,0 +1,174 @@
# Migration vers NextAuth v5 - Résumé
## Changements effectués
### 1. Installation
- ✅ Installé `next-auth@5.0.0-beta.29`
### 2. Configuration NextAuth
- ✅ Créé `auth.ts` à la racine avec:
- Credentials provider utilisant `userService.verifyCredentials()`
- Stratégie JWT
- Callbacks pour enrichir la session avec `userId`, `teamId`, `firstName`, `lastName`
- Pages custom (`/login`)
- Middleware callback `authorized` pour protéger les routes
- ✅ Créé `app/api/auth/[...nextauth]/route.ts` pour les handlers NextAuth
- ✅ Créé `types/next-auth.d.ts` pour étendre les types Session et User
### 3. Middleware
- ✅ Remplacé le middleware custom par `export { auth as middleware } from "@/auth"`
- ✅ Les redirections sont gérées dans le callback `authorized`
### 4. Routes API
- ✅ Supprimé `app/api/auth/login/route.ts` (géré par NextAuth)
- ✅ Supprimé `app/api/auth/logout/route.ts` (géré par NextAuth)
- ✅ Supprimé `app/api/auth/profile/route.ts` (utiliser `useSession()` côté client)
- ✅ Adapté `app/api/auth/register/route.ts` pour retourner les infos sans créer de session
- ✅ Mis à jour toutes les routes protégées pour utiliser `auth()` au lieu de `AuthService.requireAuthenticatedUser()`
### 5. Services
- ✅ Supprimé `services/auth-service.ts` (remplacé par NextAuth)
- ✅ Mis à jour `services/evaluation-service.ts` pour ne plus utiliser `AuthService`
- ✅ Mis à jour `services/index.ts` pour retirer l'export d'AuthService
### 6. Clients
- ✅ Mis à jour `clients/domains/auth-client.ts` pour utiliser:
- `signIn("credentials")` pour le login
- `signOut()` pour le logout
- Auto-login après register avec `signIn()`
### 7. Frontend
- ✅ Créé `components/auth/session-provider.tsx`
- ✅ Wrapped l'application avec `<SessionProvider>` dans `app/layout.tsx`
- ✅ Mis à jour tous les composants pour utiliser la session NextAuth
- ✅ Mis à jour toutes les pages serveur pour utiliser `auth()` au lieu de `AuthService`
## Configuration requise
### Variables d'environnement
Créer un fichier `.env.local` avec:
```bash
# Générer un secret avec: openssl rand -base64 32
AUTH_SECRET=your-generated-secret-here
# Pour la production
# AUTH_URL=https://your-domain.com
```
### Pour générer AUTH_SECRET
```bash
openssl rand -base64 32
```
## Utilisation
### Côté serveur (Server Components, API Routes)
```typescript
import { auth } from "@/auth";
export default async function Page() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const userId = session.user.id;
const teamId = session.user.teamId;
const firstName = session.user.firstName;
// ...
}
```
### Côté client (Client Components)
```typescript
"use client";
import { useSession } from "next-auth/react";
import { signIn, signOut } from "next-auth/react";
export function MyComponent() {
const { data: session, status } = useSession();
if (status === "loading") return <div>Loading...</div>;
if (!session) return <div>Not authenticated</div>;
return (
<div>
<p>Welcome {session.user.firstName}!</p>
<button onClick={() => signOut()}>Logout</button>
</div>
);
}
```
### Login/Register
```typescript
// Login
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
// Handle error
}
// Register (via API puis auto-login)
const response = await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
if (response.ok) {
await signIn("credentials", { email, password, redirect: false });
}
// Logout
await signOut({ redirect: false });
```
## Compatibilité
### Utilisateurs existants
**Tous les utilisateurs existants sont compatibles** car:
- La table `users` contient déjà `email` et `password_hash`
- La méthode `userService.verifyCredentials()` vérifie les mots de passe hashés avec bcrypt
- Les UUID sont préservés
### Sessions
- ⚠️ Les anciennes sessions (cookie `session_token`) ne sont plus valides
- Les utilisateurs devront se reconnecter une fois
- NextAuth utilise ses propres cookies de session
## Testing
### Vérifications à faire
1. ✅ Login avec email/password
2. ✅ Logout
3. ✅ Register puis auto-login
4. ✅ Routes protégées redirigent vers /login si non authentifié
5. ✅ /login redirige vers / si déjà authentifié
6. ✅ Les évaluations fonctionnent correctement
7. ✅ Les pages admin fonctionnent correctement
## Rollback (si nécessaire)
Si des problèmes surviennent, voici les commits importants:
- Installation de NextAuth
- Création de auth.ts et configuration
- Mise à jour des routes et services
- Suppression de auth-service.ts
Pour rollback: `git revert <commit-hash>` des commits concernés.
## Notes
- NextAuth v5 utilise des cookies signés et sécurisés automatiquement
- Les sessions JWT sont stateless (pas de stockage en base)
- La configuration `authorized` dans auth.ts gère toute la logique de protection des routes
- Le SessionProvider doit wrapper toute l'application pour que `useSession()` fonctionne

View File

@@ -1,18 +1,19 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthService, userService, TeamsService } from "@/services"; import { auth } from "@/auth";
import { userService, TeamsService } from "@/services";
import { AccountForm } from "@/components/account/account-form"; import { AccountForm } from "@/components/account/account-form";
export default async function AccountPage() { export default async function AccountPage() {
try { try {
// Vérifier si l'utilisateur est connecté // Vérifier si l'utilisateur est connecté
const userUuid = await AuthService.getUserUuidFromCookie(); const session = await auth();
if (!userUuid) { if (!session?.user) {
redirect("/login"); redirect("/login");
} }
// Récupérer le profil utilisateur // Récupérer le profil utilisateur
const userProfile = await userService.getUserByUuid(userUuid); const userProfile = await userService.getUserByUuid(session.user.id);
if (!userProfile) { if (!userProfile) {
redirect("/login"); redirect("/login");

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { AuthService, UserService } from "@/services";
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Validation des données
if (!email || !password) {
return NextResponse.json(
{ error: "Email et mot de passe requis" },
{ status: 400 }
);
}
// Vérifier les identifiants
const userService = new UserService();
const user = await userService.verifyCredentials(email, password);
if (!user) {
return NextResponse.json(
{ error: "Email ou mot de passe incorrect" },
{ status: 401 }
);
}
// Créer la réponse avec le cookie de session
const response = NextResponse.json(
{
message: "Connexion réussie",
user: {
id: user.uuid_id,
firstName: user.first_name,
lastName: user.last_name,
email: user.email,
teamId: user.team_id,
},
},
{ status: 200 }
);
// Créer la session et définir le cookie
await AuthService.createSession(user.uuid_id, response);
return response;
} catch (error) {
console.error("Login error:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View File

@@ -1,25 +0,0 @@
import { NextResponse } from "next/server";
import { AuthService } from "@/services";
export async function POST() {
try {
// Créer la réponse
const response = NextResponse.json(
{ message: "Déconnexion réussie" },
{ status: 200 }
);
// Supprimer la session et le cookie
AuthService.removeSession(response);
return response;
} catch (error) {
console.error("Logout error:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View File

@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { AuthService, userService } from "@/services";
export async function PUT(request: NextRequest) {
try {
// Vérifier si l'utilisateur est connecté
const userUuid = await AuthService.getUserUuidFromCookie();
if (!userUuid) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Récupérer les données de mise à jour
const { firstName, lastName, teamId } = await request.json();
// Validation des données
if (!firstName || !lastName || !teamId) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
// Mettre à jour l'utilisateur
await userService.updateUserByUuid(userUuid, {
firstName,
lastName,
teamId,
});
return NextResponse.json({
message: "Profil mis à jour avec succès",
user: {
firstName,
lastName,
teamId,
},
});
} catch (error: any) {
console.error("Profile update error:", error);
return NextResponse.json(
{ error: error.message || "Erreur lors de la mise à jour du profil" },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { AuthService, userService } from "@/services"; import { userService } from "@/services";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -45,8 +44,9 @@ export async function POST(request: NextRequest) {
); );
} }
// Créer la réponse avec le cookie de session // Retourner les informations de l'utilisateur créé
const response = NextResponse.json( // Le client devra appeler signIn() pour créer la session
return NextResponse.json(
{ {
message: "Compte créé avec succès", message: "Compte créé avec succès",
user: { user: {
@@ -59,11 +59,6 @@ export async function POST(request: NextRequest) {
}, },
{ status: 201 } { status: 201 }
); );
// Créer la session et définir le cookie
await AuthService.createSession(newUser.uuid_id, response);
return response;
} catch (error) { } catch (error) {
console.error("Register error:", error); console.error("Register error:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,11 +1,24 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { AuthService } from "@/services/auth-service"; import { auth } from "@/auth";
import { evaluationService } from "@/services/evaluation-service"; import { evaluationService } from "@/services/evaluation-service";
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
try { try {
// Récupérer l'utilisateur depuis le cookie (maintenant un UUID) // Récupérer l'utilisateur depuis la session NextAuth
const { userProfile } = await AuthService.requireAuthenticatedUser(); const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
const userProfile = {
firstName: session.user.firstName,
lastName: session.user.lastName,
teamId: session.user.teamId,
};
const body = await request.json(); const body = await request.json();
const { category, skillId, level, canMentor, wantsToLearn, action } = body; const { category, skillId, level, canMentor, wantsToLearn, action } = body;

View File

@@ -1,13 +1,20 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { TeamReviewService } from "@/services/team-review-service"; import { TeamReviewService } from "@/services/team-review-service";
import { AuthService } from "@/services/auth-service"; import { auth } from "@/auth";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
// Vérifier l'authentification // Vérifier l'authentification
const { userProfile } = await AuthService.requireAuthenticatedUser(); const session = await auth();
const teamId = userProfile.teamId; if (!session?.user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
const teamId = session.user.teamId;
const data = await TeamReviewService.getTeamReviewData(teamId); const data = await TeamReviewService.getTeamReviewData(teamId);
return NextResponse.json(data); return NextResponse.json(data);

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthService } from "@/services"; import { auth } from "@/auth";
import { SkillsService, TeamsService } from "@/services"; import { SkillsService, TeamsService } from "@/services";
import { evaluationService } from "@/services/evaluation-service"; import { evaluationService } from "@/services/evaluation-service";
import { EvaluationClientWrapper } from "@/components/evaluation"; import { EvaluationClientWrapper } from "@/components/evaluation";
@@ -7,12 +7,14 @@ import { SkillEvaluation } from "@/components/skill-evaluation";
export default async function EvaluationPage() { export default async function EvaluationPage() {
// Charger les données côté serveur // Charger les données côté serveur
const userUuid = await AuthService.getUserUuidFromCookie(); const session = await auth();
if (!userUuid) { if (!session?.user) {
redirect("/login"); redirect("/login");
} }
const userUuid = session.user.id;
const [userEvaluation, skillCategories, teams] = await Promise.all([ const [userEvaluation, skillCategories, teams] = await Promise.all([
evaluationService.getServerUserEvaluation(userUuid!), evaluationService.getServerUserEvaluation(userUuid!),
SkillsService.getSkillCategories(), SkillsService.getSkillCategories(),

View File

@@ -7,7 +7,9 @@ import { ThemeProvider } from "@/components/layout/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { UserProvider } from "@/hooks/use-user-context"; import { UserProvider } from "@/hooks/use-user-context";
import { NavigationWrapper } from "@/components/layout/navigation-wrapper"; import { NavigationWrapper } from "@/components/layout/navigation-wrapper";
import { AuthService, TeamsService } from "@/services"; import { SessionProvider } from "@/components/auth/session-provider";
import { auth } from "@/auth";
import { TeamsService } from "@/services";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "PeakSkills - Auto-évaluation de compétences", title: "PeakSkills - Auto-évaluation de compétences",
@@ -20,32 +22,33 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
// Récupérer les infos utilisateur côté serveur // Récupérer les infos utilisateur depuis la session NextAuth
let userInfo = null; let userInfo = null;
try { try {
const { userUuid, userProfile } = const session = await auth();
await AuthService.requireAuthenticatedUser();
// Récupérer le nom de l'équipe if (session?.user) {
let teamName = "Équipe non définie"; // Récupérer le nom de l'équipe
if (userProfile.teamId) { let teamName = "Équipe non définie";
try { if (session.user.teamId) {
const team = await TeamsService.getTeamById(userProfile.teamId); try {
if (team) { const team = await TeamsService.getTeamById(session.user.teamId);
teamName = team.name; if (team) {
teamName = team.name;
}
} catch (error) {
console.error("Failed to fetch team name:", error);
} }
} catch (error) {
console.error("Failed to fetch team name:", error);
} }
}
userInfo = { userInfo = {
firstName: userProfile.firstName, firstName: session.user.firstName,
lastName: userProfile.lastName, lastName: session.user.lastName,
teamName, teamName,
teamId: userProfile.teamId, teamId: session.user.teamId,
uuid: userUuid, uuid: session.user.id,
}; };
}
} catch (error) { } catch (error) {
// Utilisateur non authentifié, userInfo reste null // Utilisateur non authentifié, userInfo reste null
console.log("User not authenticated:", error); console.log("User not authenticated:", error);
@@ -56,18 +59,20 @@ export default async function RootLayout({
<body <body
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`} className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
> >
<ThemeProvider <SessionProvider>
attribute="class" <ThemeProvider
defaultTheme="system" attribute="class"
enableSystem defaultTheme="system"
disableTransitionOnChange enableSystem
> disableTransitionOnChange
<UserProvider initialUserInfo={userInfo}> >
<NavigationWrapper /> <UserProvider initialUserInfo={userInfo}>
<main className="min-h-screen">{children}</main> <NavigationWrapper />
<Toaster /> <main className="min-h-screen">{children}</main>
</UserProvider> <Toaster />
</ThemeProvider> </UserProvider>
</ThemeProvider>
</SessionProvider>
</body> </body>
</html> </html>
); );

View File

@@ -1,5 +1,5 @@
import { TeamsService, userService } from "@/services"; import { TeamsService, userService } from "@/services";
import { AuthService } from "@/services"; import { auth } from "@/auth";
import { LoginLayout, AuthWrapper, LoginLoading } from "@/components/login"; import { LoginLayout, AuthWrapper, LoginLoading } from "@/components/login";
export default async function LoginPage() { export default async function LoginPage() {
@@ -8,13 +8,13 @@ export default async function LoginPage() {
const teams = await TeamsService.getTeams(); const teams = await TeamsService.getTeams();
// Vérifier si l'utilisateur est déjà connecté // Vérifier si l'utilisateur est déjà connecté
const userUuid = await AuthService.getUserUuidFromCookie(); const session = await auth();
let userProfile = null; let userProfile = null;
if (userUuid) { if (session?.user) {
// Si l'utilisateur est connecté, récupérer son profil côté serveur // Si l'utilisateur est connecté, récupérer son profil côté serveur
userProfile = await userService.getUserByUuid(userUuid); userProfile = await userService.getUserByUuid(session.user.id);
} }
// Si l'utilisateur n'est pas connecté, afficher le formulaire d'auth // Si l'utilisateur n'est pas connecté, afficher le formulaire d'auth

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthService } from "@/services"; import { auth } from "@/auth";
import { evaluationService, SkillsService, TeamsService } from "@/services"; import { evaluationService, SkillsService, TeamsService } from "@/services";
import { generateRadarData } from "@/lib/evaluation-utils"; import { generateRadarData } from "@/lib/evaluation-utils";
import { import {
@@ -14,12 +14,14 @@ import {
export default async function HomePage() { export default async function HomePage() {
// Charger les données côté serveur // Charger les données côté serveur
const userUuid = await AuthService.getUserUuidFromCookie(); const session = await auth();
if (!userUuid) { if (!session?.user) {
redirect("/login"); redirect("/login");
} }
const userUuid = session.user.id;
const [userEvaluation, skillCategories, teams] = await Promise.all([ const [userEvaluation, skillCategories, teams] = await Promise.all([
evaluationService.getServerUserEvaluation(userUuid!), evaluationService.getServerUserEvaluation(userUuid!),
SkillsService.getSkillCategories(), SkillsService.getSkillCategories(),

View File

@@ -1,5 +1,5 @@
import { TeamReviewService } from "@/services/team-review-service"; import { TeamReviewService } from "@/services/team-review-service";
import { AuthService } from "@/services/auth-service"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { TeamOverview } from "@/components/team-review/team-overview"; import { TeamOverview } from "@/components/team-review/team-overview";
import { SkillMatrix } from "@/components/team-review/skill-matrix"; import { SkillMatrix } from "@/components/team-review/skill-matrix";
@@ -13,10 +13,14 @@ export const dynamic = "force-dynamic";
async function TeamReviewPage() { async function TeamReviewPage() {
try { try {
const { userProfile } = await AuthService.requireAuthenticatedUser(); const session = await auth();
if (!session?.user) {
redirect("/login");
}
const teamData = await TeamReviewService.getTeamReviewData( const teamData = await TeamReviewService.getTeamReviewData(
userProfile.teamId session.user.teamId
); );
return ( return (

69
auth.ts Normal file
View File

@@ -0,0 +1,69 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
// Import dynamique pour éviter l'erreur Edge Runtime dans le middleware
const { userService } = await import("@/services/user-service");
const user = await userService.verifyCredentials(
credentials.email as string,
credentials.password as string
);
if (!user) {
return null;
}
// Retourner l'utilisateur au format attendu par NextAuth
return {
id: user.uuid_id,
email: user.email,
name: `${user.first_name} ${user.last_name}`,
firstName: user.first_name,
lastName: user.last_name,
teamId: user.team_id,
};
},
}),
],
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
// Lors de la première connexion, ajouter les infos custom au token
if (user) {
token.id = user.id;
token.teamId = (user as any).teamId;
token.firstName = (user as any).firstName;
token.lastName = (user as any).lastName;
}
return token;
},
async session({ session, token }) {
// Ajouter les infos du token à la session
if (token && session.user) {
session.user.id = token.id as string;
session.user.teamId = token.teamId as string;
session.user.firstName = token.firstName as string;
session.user.lastName = token.lastName as string;
}
return session;
},
},
});

View File

@@ -1,5 +1,5 @@
import { signIn, signOut } from "next-auth/react";
import { BaseHttpClient } from "../base/http-client"; import { BaseHttpClient } from "../base/http-client";
import { UserProfile } from "../../lib/types";
export interface LoginCredentials { export interface LoginCredentials {
email: string; email: string;
@@ -24,27 +24,54 @@ export interface AuthUser {
export class AuthClient extends BaseHttpClient { export class AuthClient extends BaseHttpClient {
/** /**
* Connecte un utilisateur avec email/password * Connecte un utilisateur avec email/password via NextAuth
*/ */
async login( async login(credentials: LoginCredentials): Promise<{ success: boolean; error?: string }> {
credentials: LoginCredentials const result = await signIn("credentials", {
): Promise<{ user: AuthUser; message: string }> { email: credentials.email,
return await this.post("/auth/login", credentials); password: credentials.password,
redirect: false,
});
if (result?.error) {
return { success: false, error: "Email ou mot de passe incorrect" };
}
return { success: true };
} }
/** /**
* Crée un nouveau compte utilisateur * Crée un nouveau compte utilisateur puis se connecte automatiquement
*/ */
async register( async register(data: RegisterData): Promise<{ success: boolean; error?: string }> {
data: RegisterData try {
): Promise<{ user: AuthUser; message: string }> { // Créer l'utilisateur via l'API
return await this.post("/auth/register", data); const response = await this.post<{ user: AuthUser; message: string }>("/auth/register", data);
// Se connecter automatiquement après création
const loginResult = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (loginResult?.error) {
return { success: false, error: "Compte créé mais erreur de connexion" };
}
return { success: true };
} catch (error: any) {
return {
success: false,
error: error.message || "Erreur lors de la création du compte"
};
}
} }
/** /**
* Déconnecte l'utilisateur * Déconnecte l'utilisateur via NextAuth
*/ */
async logout(): Promise<{ message: string }> { async logout(): Promise<void> {
return await this.post("/auth/logout"); await signOut({ redirect: false });
} }
} }

View File

@@ -0,0 +1,13 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { ReactNode } from "react";
interface SessionProviderProps {
children: ReactNode;
}
export function SessionProvider({ children }: SessionProviderProps) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View File

@@ -31,19 +31,30 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await authClient.login({ email, password }); const result = await authClient.login({ email, password });
if (!result.success) {
setError(result.error || "Erreur de connexion");
toast({
title: "Erreur de connexion",
description: result.error || "Erreur de connexion",
variant: "destructive",
});
return;
}
toast({ toast({
title: "Connexion réussie", title: "Connexion réussie",
description: response.message, description: "Bienvenue !",
}); });
// Rediriger vers l'accueil après connexion // Rediriger vers l'accueil après connexion
router.push("/"); router.push("/");
router.refresh();
} catch (error: any) { } catch (error: any) {
console.error("Login failed:", error); console.error("Login failed:", error);
const errorMessage = error.response?.data?.error || "Erreur de connexion"; const errorMessage = error.message || "Erreur de connexion";
setError(errorMessage); setError(errorMessage);
toast({ toast({
@@ -66,26 +77,30 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await authClient.register(data); const result = await authClient.register(data);
if (!result.success) {
setError(result.error || "Erreur lors de la création du compte");
toast({
title: "Erreur d'inscription",
description: result.error || "Erreur lors de la création du compte",
variant: "destructive",
});
return;
}
toast({ toast({
title: "Compte créé", title: "Compte créé",
description: response.message, description: "Connexion automatique...",
});
// Après inscription réussie, connecter automatiquement l'utilisateur
toast({
title: "Connexion automatique",
description: "Redirection vers l'accueil...",
}); });
// Rediriger vers l'accueil après inscription réussie // Rediriger vers l'accueil après inscription réussie
router.push("/"); router.push("/");
router.refresh();
} catch (error: any) { } catch (error: any) {
console.error("Register failed:", error); console.error("Register failed:", error);
const errorMessage = const errorMessage = error.message || "Erreur lors de la création du compte";
error.response?.data?.error || "Erreur lors de la création du compte";
setError(errorMessage); setError(errorMessage);
toast({ toast({
@@ -102,11 +117,13 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
try { try {
await authClient.logout(); await authClient.logout();
// Rediriger vers la page de login après déconnexion // Rediriger vers la page de login après déconnexion
window.location.href = "/login"; router.push("/login");
router.refresh();
} catch (error) { } catch (error) {
console.error("Logout failed:", error); console.error("Logout failed:", error);
// En cas d'erreur, forcer le rechargement pour nettoyer l'état // En cas d'erreur, forcer le rechargement pour nettoyer l'état
window.location.reload(); router.push("/login");
router.refresh();
} }
}; };

View File

@@ -1,44 +1,28 @@
import { NextRequest, NextResponse } from "next/server"; import { auth } from "@/auth";
import { NextResponse } from "next/server";
const COOKIE_NAME = "session_token"; export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
const isOnLoginPage = pathname === "/login";
export function middleware(request: NextRequest) { // Pages publiques (API auth et teams)
const { pathname } = request.nextUrl; if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/teams")) {
// Pages qui ne nécessitent pas d'authentification
const publicPaths = ["/login"];
// Pages API qui ne nécessitent pas d'authentification
const publicApiPaths = ["/api/teams", "/api/auth"];
console.log(pathname);
// Vérifier si c'est une route publique
if (
publicPaths.includes(pathname) ||
publicApiPaths.some((path) => pathname.startsWith(path))
) {
return NextResponse.next(); return NextResponse.next();
} }
// Vérifier si c'est un fichier statique // Si connecté et sur login, rediriger vers home
if ( if (isLoggedIn && isOnLoginPage) {
pathname.includes("/_next/") || return NextResponse.redirect(new URL("/", req.url));
pathname.includes("/favicon.ico") ||
pathname.includes("/public/")
) {
return NextResponse.next();
} }
// Vérifier le cookie d'authentification (maintenant un UUID) // Si non connecté et pas sur login, rediriger vers login
const userUuid = request.cookies.get(COOKIE_NAME)?.value; if (!isLoggedIn && !isOnLoginPage) {
if (!userUuid) { return NextResponse.redirect(new URL("/login", req.url));
// Rediriger vers la page de login si pas authentifié
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
} }
return NextResponse.next(); return NextResponse.next();
} });
export const config = { export const config = {
matcher: [ matcher: [

View File

@@ -61,6 +61,7 @@
"input-otp": "1.4.1", "input-otp": "1.4.1",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "15.2.4", "next": "15.2.4",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pg": "^8.12.0", "pg": "^8.12.0",
"react": "^19", "react": "^19",

76
pnpm-lock.yaml generated
View File

@@ -149,6 +149,9 @@ importers:
next: next:
specifier: 15.2.4 specifier: 15.2.4
version: 15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-auth:
specifier: 5.0.0-beta.29
version: 5.0.0-beta.29(next@15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -223,6 +226,20 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@auth/core@0.40.0':
resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/runtime@7.28.3': '@babel/runtime@7.28.3':
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==} resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -607,6 +624,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@radix-ui/number@1.1.0': '@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==} resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@@ -1612,6 +1632,9 @@ packages:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true hasBin: true
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
js-tokens@4.0.0: js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1712,6 +1735,22 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
next-auth@5.0.0-beta.29:
resolution: {integrity: sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0-0
nodemailer: ^6.6.5
react: ^18.2.0 || ^19.0.0-0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
next-themes@0.4.6: next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies: peerDependencies:
@@ -1754,6 +1793,9 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
oauth4webapi@3.8.2:
resolution: {integrity: sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==}
object-assign@4.1.1: object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1822,6 +1864,14 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
prop-types@15.8.1: prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -2055,6 +2105,14 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@auth/core@0.40.0':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.1.0
oauth4webapi: 3.8.2
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@babel/runtime@7.28.3': {} '@babel/runtime@7.28.3': {}
'@date-fns/tz@1.2.0': {} '@date-fns/tz@1.2.0': {}
@@ -2307,6 +2365,8 @@ snapshots:
'@next/swc-win32-x64-msvc@15.2.4': '@next/swc-win32-x64-msvc@15.2.4':
optional: true optional: true
'@panva/hkdf@1.2.1': {}
'@radix-ui/number@1.1.0': {} '@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {} '@radix-ui/primitive@1.1.1': {}
@@ -3341,6 +3401,8 @@ snapshots:
jiti@2.5.1: {} jiti@2.5.1: {}
jose@6.1.0: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
lightningcss-darwin-arm64@1.30.1: lightningcss-darwin-arm64@1.30.1:
@@ -3412,6 +3474,12 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
next-auth@5.0.0-beta.29(next@15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1):
dependencies:
'@auth/core': 0.40.0
next: 15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1): next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies: dependencies:
react: 19.1.1 react: 19.1.1
@@ -3450,6 +3518,8 @@ snapshots:
normalize-range@0.1.2: {} normalize-range@0.1.2: {}
oauth4webapi@3.8.2: {}
object-assign@4.1.1: {} object-assign@4.1.1: {}
pg-cloudflare@1.2.7: pg-cloudflare@1.2.7:
@@ -3513,6 +3583,12 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
prop-types@15.8.1: prop-types@15.8.1:
dependencies: dependencies:
loose-envify: 1.4.0 loose-envify: 1.4.0

View File

@@ -1,96 +0,0 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { UserProfile } from "@/lib/types";
import { userService } from "@/services/user-service";
export const COOKIE_NAME = "session_token" as const;
export const COOKIE_CONFIG = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 60 * 60 * 24 * 7, // 7 jours
path: "/",
} as const;
/**
* Service d'authentification côté serveur
* Implémente les méthodes qui nécessitent next/headers
*/
export class AuthService {
/**
* Récupère l'UUID utilisateur depuis le cookie côté serveur
*/
static async getUserUuidFromCookie(): Promise<string | null> {
const cookieStore = await cookies();
const userUuidCookie = cookieStore.get(COOKIE_NAME);
return userUuidCookie?.value || null;
}
/**
* Vérifie si l'utilisateur est authentifié côté serveur
*/
static async isUserAuthenticated(): Promise<boolean> {
const userUuid = await this.getUserUuidFromCookie();
return !!userUuid;
}
/**
* Vérifie l'authentification et retourne le profil utilisateur
* @throws {Error} avec status 401 si non authentifié ou 404 si utilisateur non trouvé
*/
static async requireAuthenticatedUser(): Promise<{
userUuid: string;
userProfile: UserProfile;
}> {
const userUuid = await this.getUserUuidFromCookie();
if (!userUuid) {
const error = new Error("Utilisateur non authentifié");
(error as any).status = 401;
throw error;
}
const userProfile = await userService.getUserByUuid(userUuid);
if (!userProfile) {
const error = new Error("Utilisateur introuvable");
(error as any).status = 404;
throw error;
}
return { userUuid, userProfile };
}
/**
* Crée une nouvelle session pour un utilisateur et retourne la réponse avec le cookie
*/
static async createSession(userUuid: string, response: NextResponse): Promise<NextResponse> {
// Pour l'instant, on utilise l'UUID comme token de session
// Plus tard, on pourra implémenter un système de JWT ou de sessions en base
const sessionToken = userUuid;
response.cookies.set(COOKIE_NAME, sessionToken, COOKIE_CONFIG);
return response;
}
/**
* Supprime la session et retourne la réponse avec le cookie expiré
*/
static removeSession(response: NextResponse): NextResponse {
response.cookies.set(COOKIE_NAME, "", { ...COOKIE_CONFIG, maxAge: 0 });
return response;
}
/**
* Valide un token de session et retourne l'UUID utilisateur
*/
static async validateSession(sessionToken: string): Promise<string | null> {
// Pour l'instant, on considère que le token est valide s'il correspond à un UUID
// Plus tard, on pourra ajouter une validation plus robuste
if (sessionToken && sessionToken.length > 0) {
return sessionToken;
}
return null;
}
}

View File

@@ -1,6 +1,5 @@
import { getPool } from "./database"; import { getPool } from "./database";
import { userService } from "./user-service"; import { userService } from "./user-service";
import { AuthService } from "./auth-service";
import { import {
UserEvaluation, UserEvaluation,
UserProfile, UserProfile,
@@ -121,8 +120,12 @@ export class EvaluationService {
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
// 1. Upsert user avec UUID // 1. Récupérer le userUuid depuis le profile de l'évaluation
const userUuid = await AuthService.getUserUuidFromCookie(); const existingUser = await userService.findUserByProfile(evaluation.profile);
if (!existingUser) {
throw new Error("Utilisateur non trouvé");
}
const userUuid = existingUser.uuid;
// 2. Upsert user_evaluation avec user_uuid // 2. Upsert user_evaluation avec user_uuid
const userEvalQuery = ` const userEvalQuery = `
@@ -624,7 +627,12 @@ export class EvaluationService {
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
const userUuid = await AuthService.getUserUuidFromCookie(); // Récupérer le userUuid depuis le profile
const existingUser = await userService.findUserByProfile(profile);
if (!existingUser) {
throw new Error("Utilisateur non trouvé");
}
const userUuid = existingUser.uuid;
// Supprimer directement la skill evaluation // Supprimer directement la skill evaluation
const deleteQuery = ` const deleteQuery = `

View File

@@ -22,6 +22,3 @@ export { AdminService } from "./admin-service";
// Admin types (can be imported anywhere) // Admin types (can be imported anywhere)
export type { TeamMember, TeamStats, DirectionStats } from "@/lib/admin-types"; export type { TeamMember, TeamStats, DirectionStats } from "@/lib/admin-types";
// Server auth service (server-side only)
export { AuthService, COOKIE_NAME, COOKIE_MAX_AGE } from "./auth-service";

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

@@ -0,0 +1,28 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
teamId: string;
firstName: string;
lastName: string;
} & DefaultSession["user"];
}
interface User {
teamId: string;
firstName: string;
lastName: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
teamId: string;
firstName: string;
lastName: string;
}
}