diff --git a/package-lock.json b/package-lock.json index f5116eca..a75f256e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0", "@prisma/client": "^6.19.2", + "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "next": "15.3.0", "next-auth": "^5.0.0-beta.22", @@ -23,6 +24,7 @@ "resend": "^4.0.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", @@ -2617,6 +2619,13 @@ "tslib": "^2.8.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/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -2725,6 +2734,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", diff --git a/package.json b/package.json index ec350d9e..a01f6eb8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0", "@prisma/client": "^6.19.2", + "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "next": "15.3.0", "next-auth": "^5.0.0-beta.22", @@ -26,6 +27,7 @@ "resend": "^4.0.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 69670613..10c5e222 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,6 +41,7 @@ model User { name String? image String? emailVerified DateTime? + password String? locale String @default("fr") role Role @default(LEARNER) createdAt DateTime @default(now()) diff --git a/src/app/[locale]/auth/login/page.tsx b/src/app/[locale]/auth/login/page.tsx index 46169c27..1e47eae1 100644 --- a/src/app/[locale]/auth/login/page.tsx +++ b/src/app/[locale]/auth/login/page.tsx @@ -4,22 +4,25 @@ import { useState } from "react"; import { signIn } from "next-auth/react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { useParams } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; export default function LoginPage() { const t = useTranslations("auth"); const params = useParams(); + const router = useRouter(); const locale = params.locale as string; + + const [tab, setTab] = useState<"magic" | "password">("magic"); const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const handleSubmit = async (e: React.FormEvent) => { + const handleMagicLink = async (e: React.FormEvent) => { e.preventDefault(); if (!email) return; setLoading(true); setError(""); - try { await signIn("resend", { email, @@ -31,6 +34,42 @@ export default function LoginPage() { } }; + const handlePassword = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email || !password) return; + setLoading(true); + setError(""); + try { + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + if (result?.error) { + setError("Email ou mot de passe incorrect."); + setLoading(false); + } else { + router.push(`/${locale}/dashboard`); + } + } catch { + setError("Une erreur est survenue."); + setLoading(false); + } + }; + + const tabStyle = (active: boolean) => ({ + flex: 1, + padding: "10px", + fontSize: 13, + fontWeight: 600, + border: "none", + borderBottom: active ? "2px solid #1d4ed8" : "2px solid transparent", + background: "transparent", + color: active ? "#60a5fa" : "#64748b", + cursor: "pointer", + transition: "all 0.15s", + } as React.CSSProperties); + return (
+ {/* Tabs */} +
+ Pas encore de compte ?{" "} + + Créer un compte gratuit + +
+ ← Retour aux formations+ Créer un compte gratuit +
++ Déjà un compte ?{" "} + + Se connecter + +
++ Définissez d'abord un mot de passe ci-dessus. +
+ ); + } + + return ( + + ); +} diff --git a/src/app/[locale]/profile/NameForm.tsx b/src/app/[locale]/profile/NameForm.tsx new file mode 100644 index 00000000..8ccf8d26 --- /dev/null +++ b/src/app/[locale]/profile/NameForm.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState } from "react"; + +export function NameForm({ currentName }: { currentName: string }) { + const [name, setName] = useState(currentName); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(false); + setLoading(true); + try { + const res = await fetch("/api/profile/name", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }); + if (!res.ok) { + const data = await res.json(); + setError(data.error || "Une erreur est survenue."); + } else { + setSuccess(true); + } + } catch { + setError("Une erreur est survenue."); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/app/[locale]/profile/PasswordForm.tsx b/src/app/[locale]/profile/PasswordForm.tsx new file mode 100644 index 00000000..3444462d --- /dev/null +++ b/src/app/[locale]/profile/PasswordForm.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; + +export function PasswordForm({ hasPassword }: { hasPassword: boolean }) { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setSuccess(false); + + if (newPassword !== confirmPassword) { + setError("Les mots de passe ne correspondent pas."); + return; + } + if (newPassword.length < 8) { + setError("Le mot de passe doit contenir au moins 8 caractères."); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/profile/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + currentPassword: hasPassword ? currentPassword : undefined, + newPassword, + }), + }); + + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Une erreur est survenue."); + } else { + setSuccess(true); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + } + } catch { + setError("Une erreur est survenue."); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/app/[locale]/profile/page.tsx b/src/app/[locale]/profile/page.tsx new file mode 100644 index 00000000..8a34e7e4 --- /dev/null +++ b/src/app/[locale]/profile/page.tsx @@ -0,0 +1,93 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { setRequestLocale } from "next-intl/server"; +import { PasswordForm } from "./PasswordForm"; +import { EmailForm } from "./EmailForm"; +import { NameForm } from "./NameForm"; + +export const dynamic = "force-dynamic"; + +export default async function ProfilePage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + + const session = await auth(); + if (!session?.user) { + redirect(`/${locale}/auth/login`); + } + + const userId = (session.user as any).id as string; + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, name: true, password: true, role: true }, + }); + + if (!user) redirect(`/${locale}/auth/login`); + + const hasPassword = !!user.password; + + return ( ++ {user.email} + {user.role === "ADMIN" && ( + + Admin + + )} +
++ Votre nom tel qu'il apparaît sur la plateforme et vos certificats. +
++ {hasPassword + ? "Modifiez votre mot de passe de connexion." + : "Définissez un mot de passe pour vous connecter sans lien magique."} +
++ Votre email actuel : {user.email}. + {!hasPassword && ( + + ⚠ Définissez d'abord un mot de passe pour pouvoir changer votre email. + + )} +
+