From 17b86b60871418667530f14973e134b10d41014b Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Tue, 30 Sep 2025 21:49:52 +0200 Subject: [PATCH] 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. --- env.example | 4 + package-lock.json | 181 +++++++++++++++ package.json | 3 + prisma/schema.prisma | 17 ++ src/actions/profile.ts | 103 +++++++++ src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/api/auth/register/route.ts | 59 +++++ src/app/layout.tsx | 29 +-- src/app/login/page.tsx | 127 +++++++++++ src/app/profile/page.tsx | 279 ++++++++++++++++++++++++ src/app/register/page.tsx | 213 ++++++++++++++++++ src/components/AuthButton.tsx | 56 +++++ src/components/AuthProvider.tsx | 7 + src/components/TowerBackground.tsx | 11 + src/components/TowerLogo.tsx | 37 ++++ src/components/ui/Header.tsx | 13 ++ src/lib/auth.ts | 72 ++++++ src/middleware.ts | 33 +++ src/services/users.ts | 150 +++++++++++++ src/types/next-auth.d.ts | 31 +++ 20 files changed, 1418 insertions(+), 13 deletions(-) create mode 100644 src/actions/profile.ts create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/profile/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/components/AuthButton.tsx create mode 100644 src/components/AuthProvider.tsx create mode 100644 src/components/TowerBackground.tsx create mode 100644 src/components/TowerLogo.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/middleware.ts create mode 100644 src/services/users.ts create mode 100644 src/types/next-auth.d.ts diff --git a/env.example b/env.example index a09cbce..772ff72 100644 --- a/env.example +++ b/env.example @@ -14,6 +14,10 @@ JIRA_BASE_URL="" # https://votre-domaine.atlassian.net JIRA_EMAIL="" # votre.email@domaine.com JIRA_API_TOKEN="" # Token API Jira +# NextAuth (requis) +NEXTAUTH_URL="http://localhost:3000" # URL de votre application +NEXTAUTH_SECRET="your-secret-key-here" # Clé secrète pour signer les tokens + # Debug (optionnel) VERBOSE_LOGGING="false" # Logs détaillés en développement NODE_ENV="development" # development | production diff --git a/package-lock.json b/package-lock.json index 3ee4f23..64feaef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.16.1", + "bcryptjs": "^3.0.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", "next": "15.5.3", + "next-auth": "^4.24.11", "prisma": "^6.16.1", "react": "19.1.0", "react-dom": "19.1.0", @@ -24,6 +26,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -47,6 +50,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1746,6 +1758,15 @@ "win32" ] }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@prisma/client": { "version": "6.16.1", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.16.1.tgz", @@ -2173,6 +2194,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3143,6 +3171,15 @@ "dev": true, "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3398,6 +3435,15 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5486,6 +5532,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5974,6 +6029,18 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -6141,6 +6208,38 @@ } } }, + "node_modules/next-auth": { + "version": "4.24.11", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-4.24.11.tgz", + "integrity": "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw==", + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.7.0", + "jose": "^4.15.5", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@auth/core": "0.34.2", + "next": "^12.2.5 || ^13 || ^14 || ^15", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18 || ^19", + "react-dom": "^17.0.2 || ^18 || ^19" + }, + "peerDependenciesMeta": { + "@auth/core": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -6194,6 +6293,12 @@ "node": "^14.16.0 || >=16.10.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6204,6 +6309,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -6323,6 +6437,30 @@ "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.1.tgz", + "integrity": "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6547,6 +6685,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.27.2", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", + "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.6.tgz", + "integrity": "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==", + "license": "MIT", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6557,6 +6717,12 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", + "license": "MIT" + }, "node_modules/prisma": { "version": "6.16.1", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.1.tgz", @@ -7803,6 +7969,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -7950,6 +8125,12 @@ "node": ">=0.10.0" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 63e699f..5ee8f15 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@prisma/client": "^6.16.1", + "bcryptjs": "^3.0.2", "clsx": "^2.1.1", "date-fns": "^4.1.0", "next": "15.5.3", + "next-auth": "^4.24.11", "prisma": "^6.16.1", "react": "19.1.0", "react-dom": "19.1.0", @@ -38,6 +40,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab1c360..679d34a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,6 +7,23 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(cuid()) + email String @unique + name String? + firstName String? + lastName String? + avatar String? // URL de l'avatar + role String @default("user") // user, admin, etc. + isActive Boolean @default(true) + lastLoginAt DateTime? + password String // Hashé avec bcrypt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("users") +} + model Task { id String @id @default(cuid()) title String diff --git a/src/actions/profile.ts b/src/actions/profile.ts new file mode 100644 index 0000000..e4df1ba --- /dev/null +++ b/src/actions/profile.ts @@ -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' } + } +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..b6149fb --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -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 } diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..06cf94e --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -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 } + ) + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index df21886..b9d14e5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ - - - - - - {children} - - - - + + + + + + + {children} + + + + + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ab2bc25 --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+ + + {/* Contenu principal */} +
+
+ {/* Logo et titre */} + + + {/* Formulaire */} +
+

+ Connexion +

+
+
+
+ + setEmail(e.target.value)} + placeholder="" + className="w-full" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="" + className="w-full" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+

+ Pas encore de compte ?{' '} + + Créer un compte + +

+
+
+
+
+
+
+ ) +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..70b2dc3 --- /dev/null +++ b/src/app/profile/page.tsx @@ -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(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 ( +
+
+
+
+
+ Chargement du profil... +
+
+
+
+ ) + } + + if (!profile) { + return ( +
+
+
+
+
+ Erreur lors du chargement du profil +
+
+
+
+ ) + } + + return ( +
+
+ +
+
+ {/* Informations générales */} +
+

+ Informations générales +

+ +
+
+ +
+ {profile.email} +
+

+ L'email ne peut pas être modifié +

+
+ +
+ +
+ {profile.role} +
+
+ +
+ +
+ {new Date(profile.createdAt).toLocaleDateString('fr-FR')} +
+
+ + {profile.lastLoginAt && ( +
+ +
+ {new Date(profile.lastLoginAt).toLocaleString('fr-FR')} +
+
+ )} +
+
+ + {/* Formulaire de modification */} +
+

+ Modifier le profil +

+ +
+
+ + handleChange('firstName', e.target.value)} + placeholder="Votre prénom" + /> +
+ +
+ + handleChange('lastName', e.target.value)} + placeholder="Votre nom de famille" + /> +
+ +
+ + handleChange('name', e.target.value)} + placeholder="Nom d'affichage personnalisé" + /> +

+ Si vide, sera généré automatiquement à partir du prénom et nom +

+
+ +
+ + handleChange('avatar', e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + + {success && ( +
+ {success} +
+ )} + +
+ +
+
+
+
+
+ ) +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..54c9e65 --- /dev/null +++ b/src/app/register/page.tsx @@ -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 ( +
+ + + {/* Contenu principal */} +
+
+ {/* Logo et titre */} + + + {/* Formulaire */} +
+

+ Créer un compte +

+
+
+
+ + setEmail(e.target.value)} + placeholder="votre@email.com" + className="w-full" + /> +
+ +
+
+ + setFirstName(e.target.value)} + placeholder="Prénom" + className="w-full" + /> +
+ +
+ + setLastName(e.target.value)} + placeholder="Nom" + className="w-full" + /> +
+
+ +
+ + setName(e.target.value)} + placeholder="Nom d'affichage personnalisé" + className="w-full" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Minimum 6 caractères" + className="w-full" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Répétez le mot de passe" + className="w-full" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ +
+ +
+

+ Déjà un compte ?{' '} + + Se connecter + +

+
+
+
+
+
+
+ ) +} diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx new file mode 100644 index 0000000..bbc80b5 --- /dev/null +++ b/src/components/AuthButton.tsx @@ -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 ( +
+ Chargement... +
+ ) + } + + if (!session) { + return ( + + ) + } + + return ( +
+ + +
+ ) +} diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..d5d7004 --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -0,0 +1,7 @@ +'use client' + +import { SessionProvider } from "next-auth/react" + +export function AuthProvider({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/src/components/TowerBackground.tsx b/src/components/TowerBackground.tsx new file mode 100644 index 0000000..c7b5644 --- /dev/null +++ b/src/components/TowerBackground.tsx @@ -0,0 +1,11 @@ +export function TowerBackground() { + return ( +
+ {/* Effet de profondeur */} +
+ + {/* Effet de lumière */} +
+
+ ) +} diff --git a/src/components/TowerLogo.tsx b/src/components/TowerLogo.tsx new file mode 100644 index 0000000..4e71dbf --- /dev/null +++ b/src/components/TowerLogo.tsx @@ -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 ( +
+
+ 🗼 +
+ {showText && ( + <> +

+ TowerControl +

+

+ Tour de contrôle de vos projets +

+ + )} +
+ ) +} diff --git a/src/components/ui/Header.tsx b/src/components/ui/Header.tsx index 05d0946..ca335f7 100644 --- a/src/components/ui/Header.tsx +++ b/src/components/ui/Header.tsx @@ -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 )} + + + + {/* Auth controls à droite mobile */} +
+
@@ -264,9 +271,15 @@ export function Header({ title = "TowerControl", subtitle = "Task Management", s )} + + {/* Contrôles à droite */} +
+ +
+ diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..b085946 --- /dev/null +++ b/src/lib/auth.ts @@ -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 + }, + }, +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..b4c5a80 --- /dev/null +++ b/src/middleware.ts @@ -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)$).*)', + ], +} diff --git a/src/services/users.ts b/src/services/users.ts new file mode 100644 index 0000000..4479f6b --- /dev/null +++ b/src/services/users.ts @@ -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 { + 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 { + 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 { + return await bcrypt.compare(password, hashedPassword) + }, + + async emailExists(email: string): Promise { + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true } + }) + return !!user + }, + + async updateLastLogin(userId: string): Promise { + 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 { + 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 + } +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..636bdea --- /dev/null +++ b/src/types/next-auth.d.ts @@ -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 + } +}