feat(auth): add multi-role NextAuth with role guards
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e6a9bb7d64
commit
88a7d01d55
15 changed files with 1826 additions and 21 deletions
17
README.md
17
README.md
|
|
@ -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
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
292
prisma/migrations/20260529000000_init_karbe_schema/migration.sql
Normal file
292
prisma/migrations/20260529000000_init_karbe_schema/migration.sql
Normal 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;
|
||||
|
||||
1
prisma/migrations/migration_lock.toml
Normal file
1
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
provider = "postgresql"
|
||||
|
|
@ -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
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