From 8f78cbf25df7b0456825c932e8b0f5b5ec33dd1f Mon Sep 17 00:00:00 2001 From: Romain bogdanovic Date: Sun, 29 Mar 2026 20:34:03 +0200 Subject: [PATCH] feat: user registration, password auth, and profile settings - Add email+password registration at /auth/register (free, self-service) - Add CredentialsProvider to NextAuth for password-based login - Login page now has two tabs: magic link and password - Add /profile settings page: update name, password, and email - Auto-promote rbogdanovic@owlcub.com to ADMIN role on sign-in - Add bcryptjs for secure password hashing - Prisma: add optional password field to User model Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 18 ++ package.json | 2 + prisma/schema.prisma | 1 + src/app/[locale]/auth/login/page.tsx | 261 ++++++++++++++-------- src/app/[locale]/auth/register/page.tsx | 226 +++++++++++++++++++ src/app/[locale]/profile/EmailForm.tsx | 100 +++++++++ src/app/[locale]/profile/NameForm.tsx | 62 +++++ src/app/[locale]/profile/PasswordForm.tsx | 121 ++++++++++ src/app/[locale]/profile/page.tsx | 93 ++++++++ src/app/api/auth/register/route.ts | 36 +++ src/app/api/profile/email/route.ts | 53 +++++ src/app/api/profile/name/route.ts | 20 ++ src/app/api/profile/password/route.ts | 51 +++++ src/auth.ts | 72 +++++- src/components/Nav.tsx | 3 + 15 files changed, 1028 insertions(+), 91 deletions(-) create mode 100644 src/app/[locale]/auth/register/page.tsx create mode 100644 src/app/[locale]/profile/EmailForm.tsx create mode 100644 src/app/[locale]/profile/NameForm.tsx create mode 100644 src/app/[locale]/profile/PasswordForm.tsx create mode 100644 src/app/[locale]/profile/page.tsx create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/profile/email/route.ts create mode 100644 src/app/api/profile/name/route.ts create mode 100644 src/app/api/profile/password/route.ts 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 (
-
+
{/* Logo */}
{/* Form card */} -
-
-
- - setEmail(e.target.value)} - placeholder={t("email_placeholder")} - required - autoFocus - style={{ fontSize: 15 }} - /> -
- - {error && ( -

- {error} -

- )} - - -
- -

+ {/* Tabs */} +

- Nous vous enverrons un lien de connexion sécurisé par email. Aucun mot de passe requis. -

+ + +
+ +
+ {tab === "magic" ? ( +
+
+ + setEmail(e.target.value)} + placeholder={t("email_placeholder")} + required + autoFocus + style={{ fontSize: 15 }} + /> +
+ + {error && ( +

+ {error} +

+ )} + + + +

+ Nous vous enverrons un lien de connexion sécurisé par email. +

+
+ ) : ( +
+
+ + setEmail(e.target.value)} + placeholder={t("email_placeholder")} + required + autoFocus + style={{ fontSize: 15 }} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Votre mot de passe" + required + autoComplete="current-password" + style={{ fontSize: 15 }} + /> +
+ + {error && ( +

+ {error} +

+ )} + + + +

+ Pas encore de mot de passe ?{" "} + + {" "}pour vous connecter, puis définissez-en un dans votre profil. +

+
+ )} +
-
- +
+

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

+ ← Retour aux formations
diff --git a/src/app/[locale]/auth/register/page.tsx b/src/app/[locale]/auth/register/page.tsx new file mode 100644 index 00000000..e619ea65 --- /dev/null +++ b/src/app/[locale]/auth/register/page.tsx @@ -0,0 +1,226 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; + +export default function RegisterPage() { + const params = useParams(); + const router = useRouter(); + const locale = params.locale as string; + + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + + if (password !== confirmPassword) { + setError("Les mots de passe ne correspondent pas."); + return; + } + if (password.length < 8) { + setError("Le mot de passe doit contenir au moins 8 caractères."); + return; + } + + setLoading(true); + try { + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, name }), + }); + + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Une erreur est survenue."); + setLoading(false); + return; + } + + // Auto sign-in after registration + const result = await signIn("credentials", { + email, + password, + redirect: false, + }); + + if (result?.error) { + // Fallback: redirect to login + router.push(`/${locale}/auth/login`); + } else { + router.push(`/${locale}/dashboard`); + } + } catch { + setError("Une erreur est survenue."); + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+
+ 🦉 +
+

+ OwlCub Academy +

+

+ Créer un compte gratuit +

+
+ +
+
+
+ + setName(e.target.value)} + placeholder="Jean Dupont" + autoFocus + style={{ fontSize: 15 }} + /> +
+ +
+ + setEmail(e.target.value)} + placeholder="vous@exemple.com" + required + style={{ fontSize: 15 }} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="8 caractères minimum" + required + autoComplete="new-password" + style={{ fontSize: 15 }} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Répétez le mot de passe" + required + autoComplete="new-password" + style={{ fontSize: 15 }} + /> +
+ + {error && ( +

+ {error} +

+ )} + + +
+ +

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

+
+ +
+ + ← Retour aux formations + +
+
+
+ ); +} diff --git a/src/app/[locale]/profile/EmailForm.tsx b/src/app/[locale]/profile/EmailForm.tsx new file mode 100644 index 00000000..dcbfda95 --- /dev/null +++ b/src/app/[locale]/profile/EmailForm.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { signOut } from "next-auth/react"; + +export function EmailForm({ hasPassword }: { hasPassword: boolean }) { + const [newEmail, setNewEmail] = useState(""); + const [password, setPassword] = 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); + setLoading(true); + try { + const res = await fetch("/api/profile/email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newEmail, password }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Une erreur est survenue."); + } else { + setSuccess(true); + // Sign out so user logs back in with new email + setTimeout(() => signOut({ redirectTo: "/auth/login" }), 2000); + } + } catch { + setError("Une erreur est survenue."); + } finally { + setLoading(false); + } + }; + + if (!hasPassword) { + return ( +

+ Définissez d'abord un mot de passe ci-dessus. +

+ ); + } + + return ( +
+
+ + setNewEmail(e.target.value)} + placeholder="nouveau@exemple.com" + required + style={{ fontSize: 14 }} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Votre mot de passe actuel" + required + autoComplete="current-password" + style={{ fontSize: 14 }} + /> +
+ + {error && ( +

+ {error} +

+ )} + + {success && ( +

+ ✓ Email mis à jour. Reconnexion en cours… +

+ )} + + +
+ ); +} 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 ( +
+
+ { setName(e.target.value); setSuccess(false); }} + placeholder="Votre nom complet" + style={{ fontSize: 14 }} + /> + {error && ( +

{error}

+ )} + {success && ( +

✓ Nom mis à jour.

+ )} +
+ +
+ ); +} 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 ( +
+ {hasPassword && ( +
+ + setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + /> +
+ )} + +
+ + setNewPassword(e.target.value)} + placeholder="8 caractères minimum" + required + autoComplete="new-password" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Répétez le mot de passe" + required + autoComplete="new-password" + /> +
+ + {error && ( +

+ {error} +

+ )} + + {success && ( +

+ ✓ Mot de passe mis à jour avec succès. +

+ )} + + +
+ ); +} 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 ( +
+
+

+ Mon profil +

+

+ {user.email} + {user.role === "ADMIN" && ( + + Admin + + )} +

+
+ +
+ {/* Name */} +
+

+ Nom affiché +

+

+ Votre nom tel qu'il apparaît sur la plateforme et vos certificats. +

+ +
+ + {/* Password */} +
+

+ {hasPassword ? "Changer le mot de passe" : "Définir un mot de passe"} +

+

+ {hasPassword + ? "Modifiez votre mot de passe de connexion." + : "Définissez un mot de passe pour vous connecter sans lien magique."} +

+ +
+ + {/* Email */} +
+

+ Changer l'adresse email +

+

+ Votre email actuel : {user.email}. + {!hasPassword && ( + + ⚠ Définissez d'abord un mot de passe pour pouvoir changer votre email. + + )} +

+ +
+
+
+ ); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 00000000..0aaec0b8 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +export async function POST(req: NextRequest) { + const { email, password, name } = await req.json(); + + if (!email || !password) { + return NextResponse.json({ error: "Email et mot de passe requis." }, { status: 400 }); + } + + if (password.length < 8) { + return NextResponse.json( + { error: "Le mot de passe doit contenir au moins 8 caractères." }, + { status: 400 } + ); + } + + const existing = await db.user.findUnique({ where: { email } }); + if (existing) { + return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 }); + } + + const hashed = await bcrypt.hash(password, 12); + + await db.user.create({ + data: { + email, + name: name || null, + password: hashed, + emailVerified: new Date(), + }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/profile/email/route.ts b/src/app/api/profile/email/route.ts new file mode 100644 index 00000000..d85bbe0b --- /dev/null +++ b/src/app/api/profile/email/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const userId = (session.user as any).id as string; + const { newEmail, password } = await req.json(); + + if (!newEmail || !newEmail.includes("@")) { + return NextResponse.json({ error: "Adresse email invalide." }, { status: 400 }); + } + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + // Require password verification to change email + if (!password) { + return NextResponse.json({ error: "Mot de passe requis pour modifier l'email." }, { status: 400 }); + } + + if (!user.password) { + return NextResponse.json( + { error: "Définissez d'abord un mot de passe avant de changer votre email." }, + { status: 400 } + ); + } + + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) { + return NextResponse.json({ error: "Mot de passe incorrect." }, { status: 400 }); + } + + // Check email not already taken + const emailTaken = await db.user.findUnique({ where: { email: newEmail } }); + if (emailTaken) { + return NextResponse.json({ error: "Cet email est déjà utilisé." }, { status: 409 }); + } + + await db.user.update({ + where: { id: userId }, + data: { email: newEmail }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/profile/name/route.ts b/src/app/api/profile/name/route.ts new file mode 100644 index 00000000..415a9b3a --- /dev/null +++ b/src/app/api/profile/name/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const userId = (session.user as any).id as string; + const { name } = await req.json(); + + await db.user.update({ + where: { id: userId }, + data: { name: name || null }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/profile/password/route.ts b/src/app/api/profile/password/route.ts new file mode 100644 index 00000000..84b9b6d3 --- /dev/null +++ b/src/app/api/profile/password/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const userId = (session.user as any).id as string; + const { currentPassword, newPassword } = await req.json(); + + if (!newPassword || newPassword.length < 8) { + return NextResponse.json( + { error: "Le mot de passe doit contenir au moins 8 caractères." }, + { status: 400 } + ); + } + + const user = await db.user.findUnique({ where: { id: userId } }); + if (!user) { + return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + // If the user already has a password, verify the current one + if (user.password) { + if (!currentPassword) { + return NextResponse.json( + { error: "Mot de passe actuel requis." }, + { status: 400 } + ); + } + const isValid = await bcrypt.compare(currentPassword, user.password); + if (!isValid) { + return NextResponse.json( + { error: "Mot de passe actuel incorrect." }, + { status: 400 } + ); + } + } + + const hashed = await bcrypt.hash(newPassword, 12); + await db.user.update({ + where: { id: userId }, + data: { password: hashed }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/auth.ts b/src/auth.ts index 2e9366aa..e9fe159f 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,7 +1,11 @@ import NextAuth from "next-auth"; import { PrismaAdapter } from "@auth/prisma-adapter"; import Resend from "next-auth/providers/resend"; +import Credentials from "next-auth/providers/credentials"; import { db } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +const ADMIN_EMAIL = "rbogdanovic@owlcub.com"; export const { handlers, auth, signIn, signOut } = NextAuth({ trustHost: true, @@ -31,6 +35,38 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }); }, }), + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Mot de passe", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) return null; + + const user = await db.user.findUnique({ + where: { email: credentials.email as string }, + }); + + if (!user || !user.password) return null; + + const isValid = await bcrypt.compare( + credentials.password as string, + user.password + ); + if (!isValid) return null; + + // Promote to admin immediately if needed + if (user.email === ADMIN_EMAIL && user.role !== "ADMIN") { + await db.user.update({ + where: { id: user.id }, + data: { role: "ADMIN" }, + }); + return { ...user, role: "ADMIN" }; + } + + return user; + }, + }), ], session: { strategy: "database" }, pages: { @@ -38,12 +74,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ verifyRequest: "/auth/verify", newUser: "/dashboard", }, + events: { + // Auto-promote admin when signing in via magic link + async signIn({ user }) { + if (user.email === ADMIN_EMAIL && (user as any).role !== "ADMIN") { + await db.user.update({ + where: { id: user.id }, + data: { role: "ADMIN" }, + }); + } + }, + }, callbacks: { - session({ session, user }) { + // jwt callback is called for Credentials sessions (which use JWT regardless of strategy) + jwt({ token, user }) { + if (user) { + token.id = user.id; + token.role = (user as any).role; + token.locale = (user as any).locale; + } + return token; + }, + session({ session, user, token }) { if (session.user) { - (session.user as any).id = user.id; - (session.user as any).role = (user as any).role; - (session.user as any).locale = (user as any).locale; + if (user) { + // Database session (magic link) + (session.user as any).id = user.id; + (session.user as any).role = (user as any).role; + (session.user as any).locale = (user as any).locale; + } else if (token) { + // JWT session (credentials) + (session.user as any).id = token.id; + (session.user as any).role = token.role; + (session.user as any).locale = token.locale; + } } return session; }, diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 1fb2ea2f..b928293d 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -81,6 +81,9 @@ export function Nav({ locale, userRole, isLoggedIn }: NavProps) { {isLoggedIn && ( )} + {isLoggedIn && ( + + )} {userRole === "ADMIN" && ( )}