From 5d47b307bd52e7918e0a781619b465caebf4e8b5 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 14 Feb 2025 17:00:50 +0100 Subject: [PATCH] feat(db): Login register and auth --- package-lock.json | 30 ++++ package.json | 1 + src/app/api/auth/login/route.ts | 58 +++++++ src/app/api/auth/logout/route.ts | 9 ++ src/app/api/auth/register/route.ts | 66 ++++++++ src/app/login/LoginContent.tsx | 75 +++++++++ src/app/login/page.tsx | 196 ++--------------------- src/components/auth/LoginForm.tsx | 102 ++++++++++++ src/components/auth/RegisterForm.tsx | 110 +++++++++++++ src/components/layout/SidebarWrapper.tsx | 1 - src/components/ui/tabs.tsx | 54 +++++++ src/lib/models/user.model.ts | 36 +++++ src/lib/services/auth.service.ts | 93 ++++++++--- src/middleware.ts | 15 +- src/types/auth.ts | 9 +- 15 files changed, 630 insertions(+), 225 deletions(-) create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/login/LoginContent.tsx create mode 100644 src/components/auth/LoginForm.tsx create mode 100644 src/components/auth/RegisterForm.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/models/user.model.ts diff --git a/package-lock.json b/package-lock.json index 854eaa5..2fe36ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@types/mongoose": "^5.11.97", "class-variance-authority": "^0.7.1", @@ -1339,6 +1340,35 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz", + "integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-roving-focus": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz", diff --git a/package.json b/package.json index f80a281..dfdb2d5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", "@types/mongoose": "^5.11.97", "class-variance-authority": "^0.7.1", diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..759a88d --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import connectDB from "@/lib/mongodb"; +import { UserModel } from "@/lib/models/user.model"; + +export async function POST(request: Request) { + try { + const { email, password, remember } = await request.json(); + await connectDB(); + + const user = await UserModel.findOne({ email: email.toLowerCase() }); + + if (!user || user.password !== password) { + return NextResponse.json( + { + error: { + code: "INVALID_CREDENTIALS", + message: "Email ou mot de passe incorrect", + }, + }, + { status: 401 } + ); + } + + const userData = { + id: user._id.toString(), + email: user.email, + roles: user.roles, + authenticated: true, + }; + + // Encoder les données utilisateur en base64 + const encodedUserData = Buffer.from(JSON.stringify(userData)).toString("base64"); + + // Définir le cookie avec les données utilisateur + cookies().set("stripUser", encodedUserData, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + // 30 jours si "remember me" est coché, sinon 24 heures + maxAge: remember ? 30 * 24 * 60 * 60 : 24 * 60 * 60, + }); + + return NextResponse.json({ message: "Connexion réussie", user: userData }); + } catch (error) { + console.error("Erreur lors de la connexion:", error); + return NextResponse.json( + { + error: { + code: "SERVER_ERROR", + message: "Une erreur est survenue lors de la connexion", + }, + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..8eee72d --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; + +export async function POST() { + // Supprimer le cookie + cookies().delete("stripUser"); + + return NextResponse.json({ message: "Déconnexion réussie" }); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..9561b13 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { cookies } from "next/headers"; +import connectDB from "@/lib/mongodb"; +import { UserModel } from "@/lib/models/user.model"; + +export async function POST(request: Request) { + try { + const { email, password } = await request.json(); + await connectDB(); + + // Vérifier si l'utilisateur existe déjà + const existingUser = await UserModel.findOne({ email: email.toLowerCase() }); + if (existingUser) { + return NextResponse.json( + { + error: { + code: "EMAIL_EXISTS", + message: "Cet email est déjà utilisé", + }, + }, + { status: 400 } + ); + } + + // Créer le nouvel utilisateur + const user = await UserModel.create({ + email: email.toLowerCase(), + password, + roles: ["ROLE_USER"], + authenticated: true, + }); + + const userData = { + id: user._id.toString(), + email: user.email, + roles: user.roles, + authenticated: true, + }; + + // Encoder les données utilisateur en base64 + const encodedUserData = Buffer.from(JSON.stringify(userData)).toString("base64"); + + // Définir le cookie avec les données utilisateur + cookies().set("stripUser", encodedUserData, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + // 24 heures par défaut pour les nouveaux utilisateurs + maxAge: 24 * 60 * 60, + }); + + return NextResponse.json({ message: "Inscription réussie", user: userData }); + } catch (error) { + console.error("Erreur lors de l'inscription:", error); + return NextResponse.json( + { + error: { + code: "SERVER_ERROR", + message: "Une erreur est survenue lors de l'inscription", + }, + }, + { status: 500 } + ); + } +} diff --git a/src/app/login/LoginContent.tsx b/src/app/login/LoginContent.tsx new file mode 100644 index 0000000..2de8157 --- /dev/null +++ b/src/app/login/LoginContent.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { LoginForm } from "@/components/auth/LoginForm"; +import { RegisterForm } from "@/components/auth/RegisterForm"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +interface LoginContentProps { + searchParams: { + from?: string; + tab?: string; + }; +} + +export function LoginContent({ searchParams }: LoginContentProps) { + const defaultTab = searchParams.tab || "login"; + + return ( +
+
+
+
+
+ + + + StripStream +
+
+
+

+ Profitez de vos BD, mangas et comics préférés avec une expérience de lecture moderne + et fluide. +

+
+
+
+
+
+
+

Bienvenue sur StripStream

+

+ Connectez-vous ou créez un compte pour commencer +

+
+ + + Connexion + Inscription + + + + + + + + +
+
+
+ ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 4e5abfe..5b1d583 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,187 +1,15 @@ -"use client"; +import { Metadata } from "next"; +import { LoginContent } from "./LoginContent"; -import { useState, Suspense } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { authService } from "@/lib/services/auth.service"; -import { AuthError } from "@/types/auth"; +export const metadata: Metadata = { + title: "Connexion", + description: "Connectez-vous à votre compte StripStream", +}; -function LoginForm() { - const router = useRouter(); - const searchParams = useSearchParams(); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - setIsLoading(true); - setError(null); - - const formData = new FormData(event.currentTarget); - const email = formData.get("email") as string; - const password = formData.get("password") as string; - const remember = formData.get("remember") === "on"; - - try { - await authService.login(email, password, remember); - const from = searchParams.get("from") || "/"; - router.push(from); - } catch (error) { - setError(error as AuthError); - } finally { - setIsLoading(false); - } - }; - - return ( -
-
-
-
-
- - - - Stripstream -
-
-
-

- Profitez de vos BD, mangas et comics préférés avec une expérience de lecture moderne - et fluide. -

-
-
-
-
-
-
-
-
-
- - - -
-
-
-

- Stripstream -

-

Votre bibliothèque numérique de BD

-
-
-
-
- -
-
- -
-
-
-

Connexion

-

- Connectez-vous pour accéder à votre bibliothèque -

-
-
-
- - -
-
- - -
-
- - -
- {error && ( -
- {error.message} -
- )} - -
-
-
-
- ); -} - -export default function LoginPage() { - return ( - Chargement...
} - > - - - ); +export default function LoginPage({ + searchParams, +}: { + searchParams: { from?: string; tab?: string }; +}) { + return ; } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..a4c347c --- /dev/null +++ b/src/components/auth/LoginForm.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { authService } from "@/lib/services/auth.service"; +import { AuthError } from "@/types/auth"; + +interface LoginFormProps { + from?: string; +} + +export function LoginForm({ from }: LoginFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsLoading(true); + setError(null); + + const formData = new FormData(event.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const remember = formData.get("remember") === "on"; + + try { + await authService.login(email, password, remember); + router.push(from || "/"); + } catch (error) { + setError(error as AuthError); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ + +
+
+ + +
+
+ + +
+ {error && ( +
+ {error.message} +
+ )} + +
+ ); +} diff --git a/src/components/auth/RegisterForm.tsx b/src/components/auth/RegisterForm.tsx new file mode 100644 index 0000000..4b05fba --- /dev/null +++ b/src/components/auth/RegisterForm.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { authService } from "@/lib/services/auth.service"; +import { AuthError } from "@/types/auth"; + +interface RegisterFormProps { + from?: string; +} + +export function RegisterForm({ from }: RegisterFormProps) { + const router = useRouter(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setIsLoading(true); + setError(null); + + const formData = new FormData(event.currentTarget); + 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({ + code: "SERVER_ERROR", + message: "Les mots de passe ne correspondent pas", + }); + setIsLoading(false); + return; + } + + try { + await authService.register(email, password); + router.push(from || "/"); + } catch (error) { + setError(error as AuthError); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ + +
+
+ + +
+
+ + +
+ {error && ( +
+ {error.message} +
+ )} + +
+ ); +} diff --git a/src/components/layout/SidebarWrapper.tsx b/src/components/layout/SidebarWrapper.tsx index 7a6415a..7f4cecb 100644 --- a/src/components/layout/SidebarWrapper.tsx +++ b/src/components/layout/SidebarWrapper.tsx @@ -1,6 +1,5 @@ import { FavoriteService } from "@/lib/services/favorite.service"; import { LibraryService } from "@/lib/services/library.service"; -import { ClientSidebar } from "./ClientSidebar"; export async function SidebarWrapper() { // Récupérer les favoris depuis le serveur diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..9651cac --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,54 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/src/lib/models/user.model.ts b/src/lib/models/user.model.ts new file mode 100644 index 0000000..2c3fa35 --- /dev/null +++ b/src/lib/models/user.model.ts @@ -0,0 +1,36 @@ +import mongoose from "mongoose"; + +const userSchema = new mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + lowercase: true, + trim: true, + }, + password: { + type: String, + required: true, + }, + roles: { + type: [String], + default: ["ROLE_USER"], + }, + authenticated: { + type: Boolean, + default: true, + }, + }, + { + timestamps: true, + } +); + +// Middleware pour mettre à jour le champ updatedAt avant la sauvegarde +userSchema.pre("save", function (next) { + this.updatedAt = new Date(); + next(); +}); + +export const UserModel = mongoose.models.User || mongoose.model("User", userSchema); diff --git a/src/lib/services/auth.service.ts b/src/lib/services/auth.service.ts index f331255..01f8c1e 100644 --- a/src/lib/services/auth.service.ts +++ b/src/lib/services/auth.service.ts @@ -1,3 +1,5 @@ +"use client"; + import { AuthError } from "@/types/auth"; import { storageService } from "./storage.service"; @@ -7,19 +9,6 @@ interface AuthUser { roles: string[]; authenticated: boolean; } - -// Utilisateur de développement -const DEV_USER = { - email: "demo@stripstream.local", - password: "demo123", - userData: { - id: "1", - email: "demo@stripstream.local", - roles: ["ROLE_USER"], - authenticated: true, - } as AuthUser, -}; - class AuthService { private static instance: AuthService; @@ -36,23 +25,79 @@ class AuthService { * Authentifie un utilisateur */ async login(email: string, password: string, remember: boolean = false): Promise { - // En développement, on vérifie juste l'utilisateur de démo - if (email === DEV_USER.email && password === DEV_USER.password) { - storageService.setUserData(DEV_USER.userData, remember); - return; - } + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password, remember }), + }); - throw { - code: "INVALID_CREDENTIALS", - message: "Email ou mot de passe incorrect", - } as AuthError; + if (!response.ok) { + const data = await response.json(); + throw data.error; + } + + const data = await response.json(); + if (data.user) { + storageService.setUserData(data.user, remember); + } + } catch (error) { + if ((error as AuthError).code) { + throw error; + } + throw { + code: "SERVER_ERROR", + message: "Une erreur est survenue lors de la connexion", + } as AuthError; + } + } + + /** + * Crée un nouvel utilisateur + */ + async register(email: string, password: string): Promise { + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const data = await response.json(); + throw data.error; + } + + const data = await response.json(); + if (data.user) { + storageService.setUserData(data.user, false); + } + } catch (error) { + if ((error as AuthError).code) { + throw error; + } + throw { + code: "SERVER_ERROR", + message: "Une erreur est survenue lors de l'inscription", + } as AuthError; + } } /** * Déconnecte l'utilisateur */ - logout(): void { - storageService.clear(); + async logout(): Promise { + try { + await fetch("/api/auth/logout", { + method: "POST", + }); + } finally { + storageService.clear(); + } } /** diff --git a/src/middleware.ts b/src/middleware.ts index 4cc6cff..4b8653f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -14,14 +14,15 @@ export function middleware(request: NextRequest) { if ( publicRoutes.includes(pathname) || publicApiRoutes.includes(pathname) || - pathname.startsWith("/images/") + pathname.startsWith("/images/") || + pathname.startsWith("/_next/") ) { return NextResponse.next(); } // Pour toutes les routes protégées, vérifier la présence de l'utilisateur const user = request.cookies.get("stripUser"); - if (!user) { + if (!user || !user.value) { if (pathname.startsWith("/api/")) { return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); } @@ -32,13 +33,11 @@ export function middleware(request: NextRequest) { try { const userData = JSON.parse(atob(user.value)); - if (!userData.authenticated) { - if (pathname.startsWith("/api/")) { - return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); - } - throw new Error("User not authenticated"); + if (!userData || !userData.authenticated || !userData.id || !userData.email) { + throw new Error("Invalid user data"); } } catch (error) { + console.error("Erreur de validation du cookie:", error); if (pathname.startsWith("/api/")) { return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); } @@ -59,7 +58,7 @@ export const config = { * 2. /_next/* (Next.js internals) * 3. /fonts/* (inside public directory) * 4. /images/* (inside public directory) - * 5. /favicon.ico, /sitemap.xml (public files) + * 5. /favicon.ico, sitemap.xml (public files) */ "/((?!api/auth|_next/static|_next/image|fonts|images|favicon.ico|sitemap.xml).*)", ], diff --git a/src/types/auth.ts b/src/types/auth.ts index f332763..7397e6e 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -19,11 +19,4 @@ export interface AuthError { message: string; } -export type AuthErrorCode = - | "INVALID_CREDENTIALS" - | "INVALID_SERVER_URL" - | "SERVER_UNREACHABLE" - | "NETWORK_ERROR" - | "UNKNOWN_ERROR" - | "CACHE_CLEAR_ERROR" - | "TEST_CONNECTION_ERROR"; +export type AuthErrorCode = "INVALID_CREDENTIALS" | "SERVER_ERROR" | "EMAIL_EXISTS";