feat(auth): add multi-role NextAuth with role guards

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Karbé Architect 2026-05-29 10:46:54 +00:00
parent e6a9bb7d64
commit 88a7d01d55
15 changed files with 1826 additions and 21 deletions

View file

@ -65,3 +65,20 @@ npx prisma migrate dev
| `npm run build` | Build de production |
| `npm run start` | Démarre le serveur de production |
| `npm run lint` | Lance ESLint |
## Authentification multi-rôles (NextAuth)
Implémentation de base disponible avec NextAuth (Credentials):
- Route d'auth: `/api/auth/[...nextauth]`
- Page de connexion: `/connexion`
- Contrôle d'accès par rôle côté serveur via `requireRole()`
- Exemples de pages protégées:
- `/admin` (ADMIN)
- `/espace-hote` (OWNER ou ADMIN)
Variables d'environnement à définir:
- `DATABASE_URL`
- `NEXTAUTH_SECRET` (compatibilité)
- `AUTH_SECRET` (recommandé)

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"
}
}

View file

@ -0,0 +1,292 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('OWNER', 'CE_MANAGER', 'CE_MEMBER', 'TOURIST', 'ADMIN');
-- CreateEnum
CREATE TYPE "CarbetStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "MediaType" AS ENUM ('PHOTO', 'VIDEO');
-- CreateEnum
CREATE TYPE "AvailabilityScope" AS ENUM ('PUBLIC', 'CE_ONLY');
-- CreateEnum
CREATE TYPE "AvailabilityBlockReason" AS ENUM ('NONE', 'CE_BLOCKED', 'WEEKEND_BLOCKED');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'AUTHORIZED', 'SUCCEEDED', 'FAILED', 'REFUNDED');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('TRIAL', 'ACTIVE', 'PAST_DUE', 'CANCELED');
-- CreateTable
CREATE TABLE "Organization" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Organization_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT,
"role" "UserRole" NOT NULL,
"organizationId" TEXT,
"avatarUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Carbet" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"river" TEXT NOT NULL,
"latitude" DECIMAL(9,6) NOT NULL,
"longitude" DECIMAL(9,6) NOT NULL,
"embarkPoint" TEXT NOT NULL,
"pirogueDurationMin" INTEGER NOT NULL,
"capacity" INTEGER NOT NULL,
"status" "CarbetStatus" NOT NULL DEFAULT 'DRAFT',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Carbet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Amenity" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Amenity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CarbetAmenity" (
"carbetId" TEXT NOT NULL,
"amenityId" TEXT NOT NULL,
CONSTRAINT "CarbetAmenity_pkey" PRIMARY KEY ("carbetId","amenityId")
);
-- CreateTable
CREATE TABLE "Media" (
"id" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"type" "MediaType" NOT NULL,
"s3Key" TEXT NOT NULL,
"s3Url" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Availability" (
"id" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"scope" "AvailabilityScope" NOT NULL DEFAULT 'PUBLIC',
"blockReason" "AvailabilityBlockReason" NOT NULL DEFAULT 'NONE',
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Availability_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"guestCount" INTEGER NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING',
"amount" DECIMAL(10,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'EUR',
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerSubId" TEXT,
"status" "SubscriptionStatus" NOT NULL DEFAULT 'TRIAL',
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"renewedAt" TIMESTAMP(3),
"canceledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Review" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"carbetId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"hostResponse" TEXT,
"hostRespondedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Organization_slug_key" ON "Organization"("slug");
-- CreateIndex
CREATE INDEX "Organization_name_idx" ON "Organization"("name");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_organizationId_idx" ON "User"("organizationId");
-- CreateIndex
CREATE INDEX "User_role_idx" ON "User"("role");
-- CreateIndex
CREATE UNIQUE INDEX "Carbet_slug_key" ON "Carbet"("slug");
-- CreateIndex
CREATE INDEX "Carbet_ownerId_idx" ON "Carbet"("ownerId");
-- CreateIndex
CREATE INDEX "Carbet_status_idx" ON "Carbet"("status");
-- CreateIndex
CREATE INDEX "Carbet_river_idx" ON "Carbet"("river");
-- CreateIndex
CREATE UNIQUE INDEX "Amenity_key_key" ON "Amenity"("key");
-- CreateIndex
CREATE INDEX "CarbetAmenity_amenityId_idx" ON "CarbetAmenity"("amenityId");
-- CreateIndex
CREATE INDEX "Media_carbetId_sortOrder_idx" ON "Media"("carbetId", "sortOrder");
-- CreateIndex
CREATE INDEX "Availability_carbetId_idx" ON "Availability"("carbetId");
-- CreateIndex
CREATE INDEX "Availability_scope_blockReason_idx" ON "Availability"("scope", "blockReason");
-- CreateIndex
CREATE INDEX "Availability_startDate_endDate_idx" ON "Availability"("startDate", "endDate");
-- CreateIndex
CREATE INDEX "Booking_carbetId_idx" ON "Booking"("carbetId");
-- CreateIndex
CREATE INDEX "Booking_tenantId_idx" ON "Booking"("tenantId");
-- CreateIndex
CREATE INDEX "Booking_status_paymentStatus_idx" ON "Booking"("status", "paymentStatus");
-- CreateIndex
CREATE INDEX "Booking_startDate_endDate_idx" ON "Booking"("startDate", "endDate");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_providerSubId_key" ON "Subscription"("providerSubId");
-- CreateIndex
CREATE INDEX "Subscription_ownerId_idx" ON "Subscription"("ownerId");
-- CreateIndex
CREATE INDEX "Subscription_carbetId_idx" ON "Subscription"("carbetId");
-- CreateIndex
CREATE INDEX "Subscription_status_idx" ON "Subscription"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Review_bookingId_key" ON "Review"("bookingId");
-- CreateIndex
CREATE INDEX "Review_carbetId_idx" ON "Review"("carbetId");
-- CreateIndex
CREATE INDEX "Review_authorId_idx" ON "Review"("authorId");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Carbet" ADD CONSTRAINT "Carbet_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CarbetAmenity" ADD CONSTRAINT "CarbetAmenity_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CarbetAmenity" ADD CONSTRAINT "CarbetAmenity_amenityId_fkey" FOREIGN KEY ("amenityId") REFERENCES "Amenity"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Media" ADD CONSTRAINT "Media_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Availability" ADD CONSTRAINT "Availability_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_carbetId_fkey" FOREIGN KEY ("carbetId") REFERENCES "Carbet"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View file

@ -0,0 +1 @@
provider = "postgresql"

View file

@ -1,8 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
@ -12,4 +7,239 @@ datasource db {
provider = "postgresql"
}
// Les modèles Karbé (carbets, réservations, utilisateurs…) seront ajoutés ici.
enum UserRole {
OWNER
CE_MANAGER
CE_MEMBER
TOURIST
ADMIN
}
enum CarbetStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum MediaType {
PHOTO
VIDEO
}
enum AvailabilityScope {
PUBLIC
CE_ONLY
}
enum AvailabilityBlockReason {
NONE
CE_BLOCKED
WEEKEND_BLOCKED
}
enum BookingStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
enum PaymentStatus {
PENDING
AUTHORIZED
SUCCEEDED
FAILED
REFUNDED
}
enum SubscriptionStatus {
TRIAL
ACTIVE
PAST_DUE
CANCELED
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members User[]
@@index([name])
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
phone String?
role UserRole
organizationId String?
avatarUrl String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
carbets Carbet[] @relation("CarbetOwner")
bookings Booking[] @relation("BookingTenant")
reviews Review[] @relation("ReviewAuthor")
subscriptions Subscription[]
@@index([organizationId])
@@index([role])
}
model Carbet {
id String @id @default(cuid())
ownerId String
title String
slug String @unique
description String
river String
latitude Decimal @db.Decimal(9, 6)
longitude Decimal @db.Decimal(9, 6)
embarkPoint String
pirogueDurationMin Int
capacity Int
status CarbetStatus @default(DRAFT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("CarbetOwner", fields: [ownerId], references: [id], onDelete: Restrict)
amenities CarbetAmenity[]
media Media[]
availabilities Availability[]
bookings Booking[]
reviews Review[]
subscriptions Subscription[]
@@index([ownerId])
@@index([status])
@@index([river])
}
model Amenity {
id String @id @default(cuid())
key String @unique
label String
description String?
createdAt DateTime @default(now())
carbets CarbetAmenity[]
}
model CarbetAmenity {
carbetId String
amenityId String
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
amenity Amenity @relation(fields: [amenityId], references: [id], onDelete: Cascade)
@@id([carbetId, amenityId])
@@index([amenityId])
}
model Media {
id String @id @default(cuid())
carbetId String
type MediaType
s3Key String
s3Url String
sortOrder Int @default(0)
createdAt DateTime @default(now())
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@index([carbetId, sortOrder])
}
model Availability {
id String @id @default(cuid())
carbetId String
startDate DateTime
endDate DateTime
scope AvailabilityScope @default(PUBLIC)
blockReason AvailabilityBlockReason @default(NONE)
isAvailable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@index([carbetId])
@@index([scope, blockReason])
@@index([startDate, endDate])
}
model Booking {
id String @id @default(cuid())
carbetId String
tenantId String
startDate DateTime
endDate DateTime
guestCount Int
status BookingStatus @default(PENDING)
amount Decimal @db.Decimal(10, 2)
currency String @default("EUR")
paymentStatus PaymentStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
review Review?
@@index([carbetId])
@@index([tenantId])
@@index([status, paymentStatus])
@@index([startDate, endDate])
}
model Subscription {
id String @id @default(cuid())
ownerId String
carbetId String
provider String
providerSubId String? @unique
status SubscriptionStatus @default(TRIAL)
startedAt DateTime @default(now())
renewedAt DateTime?
canceledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Cascade)
@@index([ownerId])
@@index([carbetId])
@@index([status])
}
model Review {
id String @id @default(cuid())
bookingId String @unique
carbetId String
authorId String
rating Int
comment String?
hostResponse String?
hostRespondedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
author User @relation("ReviewAuthor", fields: [authorId], references: [id], onDelete: Restrict)
@@index([carbetId])
@@index([authorId])
}

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