Auth multi-rôles (NextAuth) — sur base schéma propre #6
11 changed files with 1280 additions and 15 deletions
1029
package-lock.json
generated
1029
package-lock.json
generated
File diff suppressed because it is too large
Load diff
17
package.json
17
package.json
|
|
@ -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
14
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
54
src/app/connexion/page.tsx
Normal file
54
src/app/connexion/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/espace-hote/page.tsx
Normal file
25
src/app/espace-hote/page.tsx
Normal 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
75
src/auth.ts
Normal 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
23
src/lib/authorization.ts
Normal 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
12
src/lib/password.ts
Normal 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
20
src/lib/prisma.ts
Normal 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
23
src/types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue