Auth multi-rôles (NextAuth) — sur base schéma propre #6

Merged
tarzzan merged 3 commits from feat/auth into main 2026-05-29 21:26:33 +00:00
11 changed files with 1280 additions and 15 deletions

1029
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,21 +10,26 @@
"postinstall": "prisma generate"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1056.0",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"next": "16.2.6",
"next-auth": "^5.0.0-beta.31",
"pg": "^8.21.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/node": "^20.19.41",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"eslint": "^9.39.4",
"eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^5.9.3"
}
}

14
src/app/admin/page.tsx Normal file
View file

@ -0,0 +1,14 @@
import { requireRole } from "@/lib/authorization";
export default async function AdminPage() {
const session = await requireRole(["ADMIN"]);
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<h1 className="text-3xl font-semibold">Espace administrateur</h1>
<p className="mt-4 text-zinc-700">
Accès autorisé pour {session.user.email} ({session.user.role}).
</p>
</main>
);
}

View file

@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View file

@ -0,0 +1,54 @@
import { redirect } from "next/navigation";
import { auth, signIn } from "@/auth";
export default async function SignInPage() {
const session = await auth();
if (session?.user?.id) {
redirect("/");
}
return (
<main className="mx-auto flex min-h-[70vh] max-w-md items-center px-6 py-12">
<form
action={async (formData) => {
"use server";
await signIn("credentials", formData);
}}
className="w-full space-y-4 rounded-xl border border-zinc-200 bg-white p-6"
>
<h1 className="text-2xl font-semibold">Connexion</h1>
<div className="space-y-1">
<label htmlFor="email" className="text-sm text-zinc-700">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-zinc-300 px-3 py-2"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-sm text-zinc-700">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
className="w-full rounded-md border border-zinc-300 px-3 py-2"
/>
</div>
<button
type="submit"
className="w-full rounded-md bg-black px-3 py-2 text-white"
>
Se connecter
</button>
</form>
</main>
);
}

View file

@ -0,0 +1,25 @@
import Link from "next/link";
import { requireRole } from "@/lib/authorization";
export default async function HostPage() {
const session = await requireRole(["OWNER", "ADMIN"]);
return (
<main className="mx-auto max-w-4xl px-6 py-12">
<h1 className="text-3xl font-semibold">Espace hôte</h1>
<p className="mt-4 text-zinc-700">
Accès autorisé pour {session.user.email} ({session.user.role}).
</p>
<div className="mt-8">
<Link
href="/espace-hote/carbets"
className="inline-block rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
Gérer mes carbets
</Link>
</div>
</main>
);
}

75
src/auth.ts Normal file
View file

@ -0,0 +1,75 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: "jwt",
},
providers: [
Credentials({
name: "Email et mot de passe",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email?.toString().trim().toLowerCase();
const password = credentials?.password?.toString() ?? "";
if (!email || !password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
passwordHash: true,
},
});
if (!user || !user.isActive) {
return null;
}
const isValid = await verifyPassword(password, user.passwordHash);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: `${user.firstName} ${user.lastName}`.trim(),
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user?.role) {
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub ?? "";
session.user.role = token.role;
}
return session;
},
},
pages: {
signIn: "/connexion",
},
});

23
src/lib/authorization.ts Normal file
View file

@ -0,0 +1,23 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import type { UserRole } from "@/generated/prisma/enums";
export async function requireAuth() {
const session = await auth();
if (!session?.user?.id) {
redirect("/connexion");
}
return session;
}
export async function requireRole(allowedRoles: UserRole[]) {
const session = await requireAuth();
if (!session.user.role || !allowedRoles.includes(session.user.role)) {
redirect("/");
}
return session;
}

12
src/lib/password.ts Normal file
View file

@ -0,0 +1,12 @@
import bcrypt from "bcryptjs";
export async function verifyPassword(
plainTextPassword: string,
hashedPassword: string,
): Promise<boolean> {
return bcrypt.compare(plainTextPassword, hashedPassword);
}
export async function hashPassword(plainTextPassword: string): Promise<string> {
return bcrypt.hash(plainTextPassword, 12);
}

20
src/lib/prisma.ts Normal file
View file

@ -0,0 +1,20 @@
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/generated/prisma/client";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL is required");
}
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
const adapter = new PrismaPg({ connectionString });
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

23
src/types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
import type { DefaultSession } from "next-auth";
import type { JWT as DefaultJWT } from "next-auth/jwt";
import type { UserRole } from "@/generated/prisma/enums";
declare module "next-auth" {
interface Session {
user: {
id: string;
role?: UserRole;
} & DefaultSession["user"];
}
interface User {
role?: UserRole;
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role?: UserRole;
}
}