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 <noreply@anthropic.com>
This commit is contained in:
Romain bogdanovic 2026-03-29 20:34:03 +02:00
parent 0c3f375a4b
commit 8f78cbf25d
15 changed files with 1028 additions and 91 deletions

18
package-lock.json generated
View File

@ -13,6 +13,7 @@
"@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"next": "15.3.0", "next": "15.3.0",
"next-auth": "^5.0.0-beta.22", "next-auth": "^5.0.0-beta.22",
@ -23,6 +24,7 @@
"resend": "^4.0.0" "resend": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
@ -2617,6 +2619,13 @@
"tslib": "^2.8.0" "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": { "node_modules/@types/node": {
"version": "22.19.15", "version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
@ -2725,6 +2734,15 @@
"node": ">=6.0.0" "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": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",

View File

@ -16,6 +16,7 @@
"@aws-sdk/client-s3": "^3.600.0", "@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0", "@aws-sdk/s3-request-presigner": "^3.600.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"next": "15.3.0", "next": "15.3.0",
"next-auth": "^5.0.0-beta.22", "next-auth": "^5.0.0-beta.22",
@ -26,6 +27,7 @@
"resend": "^4.0.0" "resend": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View File

@ -41,6 +41,7 @@ model User {
name String? name String?
image String? image String?
emailVerified DateTime? emailVerified DateTime?
password String?
locale String @default("fr") locale String @default("fr")
role Role @default(LEARNER) role Role @default(LEARNER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -4,22 +4,25 @@ import { useState } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations("auth"); const t = useTranslations("auth");
const params = useParams(); const params = useParams();
const router = useRouter();
const locale = params.locale as string; const locale = params.locale as string;
const [tab, setTab] = useState<"magic" | "password">("magic");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => { const handleMagicLink = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!email) return; if (!email) return;
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
await signIn("resend", { await signIn("resend", {
email, 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 ( return (
<div <div
style={{ style={{
@ -41,12 +80,7 @@ export default function LoginPage() {
padding: "24px", padding: "24px",
}} }}
> >
<div <div style={{ width: "100%", maxWidth: 420 }}>
style={{
width: "100%",
maxWidth: 420,
}}
>
{/* Logo */} {/* Logo */}
<div style={{ textAlign: "center", marginBottom: 32 }}> <div style={{ textAlign: "center", marginBottom: 32 }}>
<div <div
@ -73,89 +107,142 @@ export default function LoginPage() {
</div> </div>
{/* Form card */} {/* Form card */}
<div className="card"> <div className="card" style={{ padding: 0, overflow: "hidden" }}>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}> {/* Tabs */}
<div> <div
<label
htmlFor="email"
style={{
display: "block",
fontSize: 13,
fontWeight: 600,
color: "#94a3b8",
marginBottom: 6,
}}
>
Adresse email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("email_placeholder")}
required
autoFocus
style={{ fontSize: 15 }}
/>
</div>
{error && (
<p
style={{
fontSize: 13,
color: "#f87171",
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 6,
padding: "10px 12px",
}}
>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email}
className="btn btn-primary"
style={{
justifyContent: "center",
opacity: loading || !email ? 0.7 : 1,
cursor: loading || !email ? "not-allowed" : "pointer",
padding: "12px",
fontSize: 15,
}}
>
{loading ? (
<span>Envoi en cours</span>
) : (
<>
<span></span>
<span>{t("send_link")}</span>
</>
)}
</button>
</form>
<p
style={{ style={{
fontSize: 12, display: "flex",
color: "#64748b", borderBottom: "1px solid rgba(255,255,255,0.08)",
textAlign: "center",
marginTop: 20,
lineHeight: 1.5,
}} }}
> >
Nous vous enverrons un lien de connexion sécurisé par email. Aucun mot de passe requis. <button style={tabStyle(tab === "magic")} onClick={() => { setTab("magic"); setError(""); }}>
</p> Lien magique
</button>
<button style={tabStyle(tab === "password")} onClick={() => { setTab("password"); setError(""); }}>
🔑 Mot de passe
</button>
</div>
<div style={{ padding: 24 }}>
{tab === "magic" ? (
<form onSubmit={handleMagicLink} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label
htmlFor="email-magic"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Adresse email
</label>
<input
id="email-magic"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("email_placeholder")}
required
autoFocus
style={{ fontSize: 15 }}
/>
</div>
{error && (
<p style={{ fontSize: 13, color: "#f87171", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 6, padding: "10px 12px" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email}
className="btn btn-primary"
style={{ justifyContent: "center", opacity: loading || !email ? 0.7 : 1, cursor: loading || !email ? "not-allowed" : "pointer", padding: "12px", fontSize: 15 }}
>
{loading ? <span>Envoi en cours</span> : <><span></span><span>{t("send_link")}</span></>}
</button>
<p style={{ fontSize: 12, color: "#64748b", textAlign: "center", lineHeight: 1.5 }}>
Nous vous enverrons un lien de connexion sécurisé par email.
</p>
</form>
) : (
<form onSubmit={handlePassword} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label
htmlFor="email-pwd"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Adresse email
</label>
<input
id="email-pwd"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("email_placeholder")}
required
autoFocus
style={{ fontSize: 15 }}
/>
</div>
<div>
<label
htmlFor="password"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Votre mot de passe"
required
autoComplete="current-password"
style={{ fontSize: 15 }}
/>
</div>
{error && (
<p style={{ fontSize: 13, color: "#f87171", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 6, padding: "10px 12px" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email || !password}
className="btn btn-primary"
style={{ justifyContent: "center", opacity: loading || !email || !password ? 0.7 : 1, cursor: loading || !email || !password ? "not-allowed" : "pointer", padding: "12px", fontSize: 15 }}
>
{loading ? "Connexion…" : "Se connecter"}
</button>
<p style={{ fontSize: 12, color: "#64748b", textAlign: "center", lineHeight: 1.5 }}>
Pas encore de mot de passe ?{" "}
<button
type="button"
onClick={() => { setTab("magic"); setError(""); }}
style={{ background: "none", border: "none", color: "#60a5fa", cursor: "pointer", fontSize: 12, padding: 0 }}
>
Utilisez le lien magique
</button>
{" "}pour vous connecter, puis définissez-en un dans votre profil.
</p>
</form>
)}
</div>
</div> </div>
<div style={{ textAlign: "center", marginTop: 20 }}> <div style={{ textAlign: "center", marginTop: 20, display: "flex", flexDirection: "column", gap: 10 }}>
<Link <p style={{ fontSize: 13, color: "#64748b" }}>
href={`/${locale}/courses`} Pas encore de compte ?{" "}
style={{ fontSize: 13, color: "#94a3b8" }} <Link href={`/${locale}/auth/register`} style={{ color: "#60a5fa" }}>
> Créer un compte gratuit
</Link>
</p>
<Link href={`/${locale}/courses`} style={{ fontSize: 13, color: "#475569" }}>
Retour aux formations Retour aux formations
</Link> </Link>
</div> </div>

View File

@ -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 (
<div
style={{
minHeight: "calc(100vh - 64px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
}}
>
<div style={{ width: "100%", maxWidth: 420 }}>
{/* Logo */}
<div style={{ textAlign: "center", marginBottom: 32 }}>
<div
style={{
width: 56,
height: 56,
background: "#1d4ed8",
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 28,
margin: "0 auto 16px",
}}
>
🦉
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, color: "#f1f5f9" }}>
OwlCub Academy
</h1>
<p style={{ fontSize: 14, color: "#94a3b8", marginTop: 6 }}>
Créer un compte gratuit
</p>
</div>
<div className="card">
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div>
<label
htmlFor="name"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Prénom et nom <span style={{ color: "#475569", fontWeight: 400 }}>(optionnel)</span>
</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Jean Dupont"
autoFocus
style={{ fontSize: 15 }}
/>
</div>
<div>
<label
htmlFor="email"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Adresse email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="vous@exemple.com"
required
style={{ fontSize: 15 }}
/>
</div>
<div>
<label
htmlFor="password"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Mot de passe
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="8 caractères minimum"
required
autoComplete="new-password"
style={{ fontSize: 15 }}
/>
</div>
<div>
<label
htmlFor="confirm"
style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}
>
Confirmer le mot de passe
</label>
<input
id="confirm"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Répétez le mot de passe"
required
autoComplete="new-password"
style={{ fontSize: 15 }}
/>
</div>
{error && (
<p
style={{
fontSize: 13,
color: "#f87171",
background: "rgba(239,68,68,0.1)",
border: "1px solid rgba(239,68,68,0.3)",
borderRadius: 6,
padding: "10px 12px",
}}
>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email || !password || !confirmPassword}
className="btn btn-primary"
style={{
justifyContent: "center",
opacity: loading || !email || !password || !confirmPassword ? 0.7 : 1,
cursor: loading || !email || !password || !confirmPassword ? "not-allowed" : "pointer",
padding: "12px",
fontSize: 15,
}}
>
{loading ? "Création en cours…" : "Créer mon compte"}
</button>
</form>
<p style={{ fontSize: 13, color: "#64748b", textAlign: "center", marginTop: 20 }}>
Déjà un compte ?{" "}
<Link href={`/${locale}/auth/login`} style={{ color: "#60a5fa" }}>
Se connecter
</Link>
</p>
</div>
<div style={{ textAlign: "center", marginTop: 20 }}>
<Link href={`/${locale}/courses`} style={{ fontSize: 13, color: "#94a3b8" }}>
Retour aux formations
</Link>
</div>
</div>
</div>
);
}

View File

@ -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 (
<p style={{ fontSize: 13, color: "#64748b", fontStyle: "italic" }}>
Définissez d'abord un mot de passe ci-dessus.
</p>
);
}
return (
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}>
Nouvel email
</label>
<input
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="nouveau@exemple.com"
required
style={{ fontSize: 14 }}
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}>
Confirmez votre mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Votre mot de passe actuel"
required
autoComplete="current-password"
style={{ fontSize: 14 }}
/>
</div>
{error && (
<p style={{ fontSize: 13, color: "#f87171", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 6, padding: "10px 12px" }}>
{error}
</p>
)}
{success && (
<p style={{ fontSize: 13, color: "#4ade80", background: "rgba(34,197,94,0.1)", border: "1px solid rgba(34,197,94,0.3)", borderRadius: 6, padding: "10px 12px" }}>
Email mis à jour. Reconnexion en cours
</p>
)}
<button
type="submit"
disabled={loading || !newEmail || !password}
className="btn btn-primary"
style={{ justifyContent: "center", opacity: loading || !newEmail || !password ? 0.7 : 1, cursor: loading || !newEmail || !password ? "not-allowed" : "pointer" }}
>
{loading ? "Enregistrement…" : "Changer l'email"}
</button>
</form>
);
}

View File

@ -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 (
<form onSubmit={handleSubmit} style={{ display: "flex", gap: 12, alignItems: "flex-end" }}>
<div style={{ flex: 1 }}>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setSuccess(false); }}
placeholder="Votre nom complet"
style={{ fontSize: 14 }}
/>
{error && (
<p style={{ fontSize: 12, color: "#f87171", marginTop: 6 }}>{error}</p>
)}
{success && (
<p style={{ fontSize: 12, color: "#4ade80", marginTop: 6 }}> Nom mis à jour.</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="btn btn-primary"
style={{ fontSize: 13, opacity: loading ? 0.7 : 1 }}
>
{loading ? "…" : "Enregistrer"}
</button>
</form>
);
}

View File

@ -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 (
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{hasPassword && (
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}>
Mot de passe actuel
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
)}
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}>
Nouveau mot de passe
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="8 caractères minimum"
required
autoComplete="new-password"
/>
</div>
<div>
<label style={{ display: "block", fontSize: 13, fontWeight: 600, color: "#94a3b8", marginBottom: 6 }}>
Confirmer le mot de passe
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Répétez le mot de passe"
required
autoComplete="new-password"
/>
</div>
{error && (
<p style={{ fontSize: 13, color: "#f87171", background: "rgba(239,68,68,0.1)", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 6, padding: "10px 12px" }}>
{error}
</p>
)}
{success && (
<p style={{ fontSize: 13, color: "#4ade80", background: "rgba(34,197,94,0.1)", border: "1px solid rgba(34,197,94,0.3)", borderRadius: 6, padding: "10px 12px" }}>
Mot de passe mis à jour avec succès.
</p>
)}
<button
type="submit"
disabled={loading}
className="btn btn-primary"
style={{ justifyContent: "center", opacity: loading ? 0.7 : 1, cursor: loading ? "not-allowed" : "pointer" }}
>
{loading ? "Enregistrement…" : hasPassword ? "Changer le mot de passe" : "Définir le mot de passe"}
</button>
</form>
);
}

View File

@ -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 (
<div style={{ maxWidth: 640, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 40 }}>
<h1 style={{ fontSize: 26, fontWeight: 800, color: "#f1f5f9", marginBottom: 4 }}>
Mon profil
</h1>
<p style={{ color: "#64748b", fontSize: 14 }}>
{user.email}
{user.role === "ADMIN" && (
<span style={{ marginLeft: 10, fontSize: 12, background: "rgba(29,78,216,0.2)", color: "#60a5fa", border: "1px solid rgba(29,78,216,0.4)", borderRadius: 4, padding: "2px 8px", fontWeight: 600 }}>
Admin
</span>
)}
</p>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* Name */}
<div className="card">
<h2 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 4 }}>
Nom affiché
</h2>
<p style={{ fontSize: 13, color: "#64748b", marginBottom: 20 }}>
Votre nom tel qu'il apparaît sur la plateforme et vos certificats.
</p>
<NameForm currentName={user.name || ""} />
</div>
{/* Password */}
<div className="card">
<h2 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 4 }}>
{hasPassword ? "Changer le mot de passe" : "Définir un mot de passe"}
</h2>
<p style={{ fontSize: 13, color: "#64748b", marginBottom: 20 }}>
{hasPassword
? "Modifiez votre mot de passe de connexion."
: "Définissez un mot de passe pour vous connecter sans lien magique."}
</p>
<PasswordForm hasPassword={hasPassword} />
</div>
{/* Email */}
<div className="card">
<h2 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 4 }}>
Changer l'adresse email
</h2>
<p style={{ fontSize: 13, color: "#64748b", marginBottom: 20 }}>
Votre email actuel : <strong style={{ color: "#94a3b8" }}>{user.email}</strong>.
{!hasPassword && (
<span style={{ display: "block", marginTop: 6, color: "#f59e0b" }}>
Définissez d'abord un mot de passe pour pouvoir changer votre email.
</span>
)}
</p>
<EmailForm hasPassword={hasPassword} />
</div>
</div>
</div>
);
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -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 });
}

View File

@ -1,7 +1,11 @@
import NextAuth from "next-auth"; import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter"; import { PrismaAdapter } from "@auth/prisma-adapter";
import Resend from "next-auth/providers/resend"; import Resend from "next-auth/providers/resend";
import Credentials from "next-auth/providers/credentials";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import bcrypt from "bcryptjs";
const ADMIN_EMAIL = "rbogdanovic@owlcub.com";
export const { handlers, auth, signIn, signOut } = NextAuth({ export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true, 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" }, session: { strategy: "database" },
pages: { pages: {
@ -38,12 +74,40 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
verifyRequest: "/auth/verify", verifyRequest: "/auth/verify",
newUser: "/dashboard", 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: { 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) { if (session.user) {
(session.user as any).id = user.id; if (user) {
(session.user as any).role = (user as any).role; // Database session (magic link)
(session.user as any).locale = (user as any).locale; (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; return session;
}, },

View File

@ -81,6 +81,9 @@ export function Nav({ locale, userRole, isLoggedIn }: NavProps) {
{isLoggedIn && ( {isLoggedIn && (
<NavLink href={localePath("/dashboard")} label={t("dashboard")} /> <NavLink href={localePath("/dashboard")} label={t("dashboard")} />
)} )}
{isLoggedIn && (
<NavLink href={localePath("/profile")} label="Profil" />
)}
{userRole === "ADMIN" && ( {userRole === "ADMIN" && (
<NavLink href={localePath("/admin")} label={t("admin")} /> <NavLink href={localePath("/admin")} label={t("admin")} />
)} )}