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:
parent
0c3f375a4b
commit
8f78cbf25d
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
72
src/auth.ts
72
src/auth.ts
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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")} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue