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/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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -41,12 +80,7 @@ export default function LoginPage() {
|
|||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 420,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: "100%", maxWidth: 420 }}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: "center", marginBottom: 32 }}>
|
||||
<div
|
||||
|
|
@ -73,89 +107,142 @@ export default function LoginPage() {
|
|||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="card">
|
||||
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<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
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "#64748b",
|
||||
textAlign: "center",
|
||||
marginTop: 20,
|
||||
lineHeight: 1.5,
|
||||
display: "flex",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
Nous vous enverrons un lien de connexion sécurisé par email. Aucun mot de passe requis.
|
||||
</p>
|
||||
<button style={tabStyle(tab === "magic")} onClick={() => { setTab("magic"); setError(""); }}>
|
||||
✉️ 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 style={{ textAlign: "center", marginTop: 20 }}>
|
||||
<Link
|
||||
href={`/${locale}/courses`}
|
||||
style={{ fontSize: 13, color: "#94a3b8" }}
|
||||
>
|
||||
<div style={{ textAlign: "center", marginTop: 20, display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<p style={{ fontSize: 13, color: "#64748b" }}>
|
||||
Pas encore de compte ?{" "}
|
||||
<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
|
||||
</Link>
|
||||
</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 { 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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ export function Nav({ locale, userRole, isLoggedIn }: NavProps) {
|
|||
{isLoggedIn && (
|
||||
<NavLink href={localePath("/dashboard")} label={t("dashboard")} />
|
||||
)}
|
||||
{isLoggedIn && (
|
||||
<NavLink href={localePath("/profile")} label="Profil" />
|
||||
)}
|
||||
{userRole === "ADMIN" && (
|
||||
<NavLink href={localePath("/admin")} label={t("admin")} />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue