diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 9c2c33c..2daa3e0 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -16,11 +16,6 @@ jobs: env: DOCKER_BUILDKIT: 1 COMPOSE_DOCKER_CLI_BUILD: 1 - NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }} - NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }} - PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }} - UPLOADS_PATH: ${{ vars.UPLOADS_PATH }} - POSTGRES_DATA_PATH: ${{ vars.POSTGRES_DATA_PATH }} - POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} run: | docker compose up -d --build diff --git a/Dockerfile b/Dockerfile index 650223b..075bd94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,4 +48,4 @@ RUN apt-get update -y && apt-get install -y gosu && rm -rf /var/lib/apt/lists/* && chmod +x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] -CMD ["sh", "-c", "npx prisma db push && npx prisma db seed && node server.js"] +CMD ["sh", "-c", "npx prisma migrate deploy && npx prisma db seed && node server.js"] diff --git a/README.md b/README.md index 82226b3..808a701 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,13 @@ Production-ready web app for evaluating IA/GenAI maturity of candidates. Built f pnpm install cp .env.example .env pnpm db:generate -pnpm db:push +pnpm db:push # ou pnpm db:migrate pour une DB vide pnpm db:seed ``` +**Note** : Si la DB existe déjà (créée avec `db push`), pour basculer sur les migrations : +`pnpm prisma migrate resolve --applied 20250220000000_init` + ## Run ```bash @@ -91,7 +94,7 @@ Run `pnpm exec playwright install` once to install browsers for E2E. ## Deploy 1. Set `DATABASE_URL` to Postgres (e.g. Supabase, Neon). -2. Run migrations: `pnpm db:push` +2. Run migrations: `pnpm db:migrate` (ou `pnpm db:push` en dev) 3. Seed if needed: `pnpm db:seed` 4. Build: `pnpm build && pnpm start` 5. Or deploy to Vercel (set env, use Vercel Postgres or external DB). diff --git a/package.json b/package.json index db6f927..0725075 100644 --- a/package.json +++ b/package.json @@ -16,19 +16,23 @@ "db:seed": "tsx prisma/seed.ts", "db:studio": "prisma studio", "test": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "db:migrate": "prisma migrate deploy" }, "dependencies": { + "bcryptjs": "^2.4.3", "date-fns": "^4.1.0", "jspdf": "^4.2.0", "jspdf-autotable": "^5.0.7", "next": "16.1.6", + "next-auth": "^5.0.0-beta.25", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.7.0", "@prisma/client": "^5.22.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@types/node": "^20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17e7a53..a3eb1d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@prisma/client': specifier: ^5.22.0 version: 5.22.0(prisma@5.22.0) + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -23,6 +26,9 @@ importers: next: specifier: 16.1.6 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next-auth: + specifier: ^5.0.0-beta.25 + version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) react: specifier: 19.2.3 version: 19.2.3 @@ -39,6 +45,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.2.0 + '@types/bcryptjs': + specifier: ^2.4.6 + version: 2.4.6 '@types/node': specifier: ^20 version: 20.19.33 @@ -94,6 +103,20 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@auth/core@0.41.0': + resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^6.8.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -670,6 +693,9 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -953,6 +979,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcryptjs@2.4.6': + resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1302,6 +1331,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -2028,6 +2060,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2225,6 +2260,22 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-auth@5.0.0-beta.30: + resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + next@16.1.6: resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} @@ -2253,6 +2304,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + oauth4webapi@3.8.5: + resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2364,6 +2418,14 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2929,6 +2991,14 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@auth/core@0.41.0': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.1.3 + oauth4webapi: 3.8.5 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -3389,6 +3459,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@panva/hkdf@1.2.1': {} + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 @@ -3612,6 +3684,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@types/bcryptjs@2.4.6': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3983,6 +4057,8 @@ snapshots: baseline-browser-mapping@2.10.0: {} + bcryptjs@2.4.3: {} + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -4896,6 +4972,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -5078,6 +5156,12 @@ snapshots: natural-compare@1.4.0: {} + next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3): + dependencies: + '@auth/core': 0.41.0 + next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@next/env': 16.1.6 @@ -5112,6 +5196,8 @@ snapshots: node-releases@2.0.27: {} + oauth4webapi@3.8.5: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5228,6 +5314,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.24.3: {} + prelude-ls@1.2.1: {} prisma@5.22.0: diff --git a/prisma/migrations/20250220000000_init/migration.sql b/prisma/migrations/20250220000000_init/migration.sql new file mode 100644 index 0000000..58eed12 --- /dev/null +++ b/prisma/migrations/20250220000000_init/migration.sql @@ -0,0 +1,100 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "name" TEXT, + "passwordHash" TEXT, + "role" TEXT NOT NULL DEFAULT 'evaluator', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Template" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "TemplateDimension" ( + "id" TEXT NOT NULL PRIMARY KEY, + "templateId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "orderIndex" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "rubric" TEXT NOT NULL, + "suggestedQuestions" TEXT, + CONSTRAINT "TemplateDimension_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Evaluation" ( + "id" TEXT NOT NULL PRIMARY KEY, + "candidateName" TEXT NOT NULL, + "candidateRole" TEXT NOT NULL, + "candidateTeam" TEXT, + "evaluatorName" TEXT NOT NULL, + "evaluatorId" TEXT, + "evaluationDate" DATETIME NOT NULL, + "templateId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'draft', + "findings" TEXT, + "recommendations" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Evaluation_evaluatorId_fkey" FOREIGN KEY ("evaluatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Evaluation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "EvaluationShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "evaluationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "EvaluationShare_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "Evaluation" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "EvaluationShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "DimensionScore" ( + "id" TEXT NOT NULL PRIMARY KEY, + "evaluationId" TEXT NOT NULL, + "dimensionId" TEXT NOT NULL, + "score" INTEGER, + "justification" TEXT, + "examplesObserved" TEXT, + "confidence" TEXT, + "candidateNotes" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "DimensionScore_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "Evaluation" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "DimensionScore_dimensionId_fkey" FOREIGN KEY ("dimensionId") REFERENCES "TemplateDimension" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "evaluationId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "field" TEXT, + "oldValue" TEXT, + "newValue" TEXT, + "userId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditLog_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "Evaluation" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "TemplateDimension_templateId_slug_key" ON "TemplateDimension"("templateId", "slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "EvaluationShare_evaluationId_userId_key" ON "EvaluationShare"("evaluationId", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DimensionScore_evaluationId_dimensionId_key" ON "DimensionScore"("evaluationId", "dimensionId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d393632..2c31c99 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,12 +12,15 @@ datasource db { } model User { - id String @id @default(cuid()) - email String @unique - name String? - role String @default("evaluator") // evaluator | admin - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + name String? + passwordHash String? + role String @default("evaluator") // evaluator | admin + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + evaluations Evaluation[] @relation("Evaluator") + sharedEvaluations EvaluationShare[] } model Template { @@ -48,17 +51,31 @@ model Evaluation { candidateName String candidateRole String candidateTeam String? // équipe du candidat - evaluatorName String - evaluationDate DateTime - templateId String - template Template @relation(fields: [templateId], references: [id]) - status String @default("draft") // draft | submitted - findings String? // auto-generated summary + evaluatorName String + evaluatorId String? + evaluator User? @relation("Evaluator", fields: [evaluatorId], references: [id], onDelete: SetNull) + evaluationDate DateTime + templateId String + template Template @relation(fields: [templateId], references: [id]) + status String @default("draft") // draft | submitted + findings String? // auto-generated summary recommendations String? dimensionScores DimensionScore[] - auditLogs AuditLog[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + auditLogs AuditLog[] + sharedWith EvaluationShare[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model EvaluationShare { + id String @id @default(cuid()) + evaluationId String + evaluation Evaluation @relation(fields: [evaluationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@unique([evaluationId, userId]) } model DimensionScore { diff --git a/prisma/seed.ts b/prisma/seed.ts index a35ffb7..4486a8c 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -434,15 +434,18 @@ async function main() { }); if (!template) throw new Error("Template not found"); - await prisma.user.upsert({ - where: { email: "admin@cars-front.local" }, - create: { - email: "admin@cars-front.local", - name: "Admin User", - role: "admin", - }, - update: {}, - }); + const bcrypt = require("bcryptjs"); + const adminHash = bcrypt.hashSync("admin123", 10); + const admin = await prisma.user.upsert({ + where: { email: "admin@cars-front.local" }, + create: { + email: "admin@cars-front.local", + name: "Admin User", + passwordHash: adminHash, + role: "admin", + }, + update: { passwordHash: adminHash }, + }); const dims = await prisma.templateDimension.findMany({ where: { templateId: template.id }, @@ -485,6 +488,7 @@ async function main() { candidateRole: r.role, candidateTeam: r.team, evaluatorName: r.evaluator, + evaluatorId: admin.id, evaluationDate: new Date(2025, 1, 15 + i), templateId: template.id, status: i === 0 ? "submitted" : "draft", @@ -526,6 +530,13 @@ async function main() { }); } } + + // Rattacher les évaluations orphelines (sans evaluatorId) à l'admin + await prisma.evaluation.updateMany({ + where: { evaluatorId: null }, + data: { evaluatorId: admin.id }, + }); + console.log("Seed complete: templates synced, répondants upserted (évaluations non vidées)"); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dd50bbc..8759253 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,9 @@ "use client"; import { useState, useEffect } from "react"; +import { format } from "date-fns"; +import { useSession } from "next-auth/react"; +import { ConfirmModal } from "@/components/ConfirmModal"; interface Template { id: string; @@ -8,14 +11,51 @@ interface Template { dimensions: { id: string; title: string; orderIndex: number }[]; } +interface User { + id: string; + email: string; + name: string | null; + role: string; + createdAt: string; +} + export default function AdminPage() { const [templates, setTemplates] = useState([]); + const { data: session } = useSession(); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); + const [updatingId, setUpdatingId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + async function setRole(userId: string, role: "admin" | "evaluator") { + setUpdatingId(userId); + try { + const res = await fetch(`/api/admin/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role }), + }); + if (res.ok) { + setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role } : u))); + } else { + const data = await res.json().catch(() => ({})); + alert(data.error ?? "Erreur"); + } + } finally { + setUpdatingId(null); + } + } useEffect(() => { - fetch("/api/templates") - .then((r) => r.json()) - .then(setTemplates) + Promise.all([ + fetch("/api/templates").then((r) => r.json()), + fetch("/api/admin/users").then((r) => r.json()), + ]) + .then(([templatesData, usersData]) => { + setTemplates(Array.isArray(templatesData) ? templatesData : []); + setUsers(Array.isArray(usersData) ? usersData : []); + }) + .catch(() => {}) .finally(() => setLoading(false)); }, []); @@ -26,11 +66,105 @@ export default function AdminPage() { return (

Admin

-

- Modèles. CRUD via /api/templates -

+

Users

+
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
EmailNomRôleCréé le
{u.email}{u.name ?? "—"} + + {u.role} + + + {format(new Date(u.createdAt), "yyyy-MM-dd HH:mm")} + + + {u.role === "admin" ? ( + + ) : ( + + )} + {u.id !== session?.user?.id && ( + + )} + +
+
+
+ + { + if (!deleteTarget) return; + const res = await fetch(`/api/admin/users/${deleteTarget.id}`, { method: "DELETE" }); + if (res.ok) { + setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)); + setDeleteTarget(null); + } else { + const data = await res.json().catch(() => ({})); + alert(data.error ?? "Erreur"); + } + }} + onCancel={() => setDeleteTarget(null)} + /> + +

Modèles

{templates.map((t) => ( @@ -54,13 +188,6 @@ export default function AdminPage() { ))}
- -
-

Users

-

- admin@cars-front.local -

-
); } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..f76126b --- /dev/null +++ b/src/app/api/admin/users/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (session?.user?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { id } = await params; + const body = await req.json(); + const { role } = body; + + if (!role || !["admin", "evaluator"].includes(role)) { + return NextResponse.json({ error: "Rôle invalide (admin | evaluator)" }, { status: 400 }); + } + + const user = await prisma.user.update({ + where: { id }, + data: { role }, + }); + return NextResponse.json(user); +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth(); + if (session?.user?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { id } = await params; + + if (id === session.user.id) { + return NextResponse.json({ error: "Impossible de supprimer votre propre compte" }, { status: 400 }); + } + + await prisma.user.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..d1423d7 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function GET() { + const session = await auth(); + if (session?.user?.role !== "admin") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const users = await prisma.user.findMany({ + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + name: true, + role: true, + createdAt: true, + }, + }); + return NextResponse.json(users); +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..3ed9b0d --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; +import bcrypt from "bcryptjs"; + +export async function POST(req: NextRequest) { + try { + const { email, password, name } = await req.json(); + if (!email || !password) { + return NextResponse.json( + { error: "Email et mot de passe requis" }, + { status: 400 } + ); + } + const existing = await prisma.user.findUnique({ + where: { email: String(email).toLowerCase().trim() }, + }); + if (existing) { + return NextResponse.json( + { error: "Un compte existe déjà avec cet email" }, + { status: 409 } + ); + } + const passwordHash = await bcrypt.hash(String(password), 10); + await prisma.user.create({ + data: { + email: String(email).toLowerCase().trim(), + passwordHash, + name: name?.trim() || null, + }, + }); + return NextResponse.json({ ok: true }); + } catch (e) { + console.error("Signup error:", e); + return NextResponse.json( + { error: "Erreur lors de l'inscription" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/evaluations/[id]/route.ts b/src/app/api/evaluations/[id]/route.ts index d752799..0788f89 100644 --- a/src/app/api/evaluations/[id]/route.ts +++ b/src/app/api/evaluations/[id]/route.ts @@ -1,12 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; import { Prisma } from "@prisma/client"; +import { auth } from "@/auth"; import { prisma } from "@/lib/db"; +async function canAccessEvaluation(evaluationId: string, userId: string, isAdmin: boolean) { + if (isAdmin) return true; + const eval_ = await prisma.evaluation.findUnique({ + where: { id: evaluationId }, + select: { evaluatorId: true, sharedWith: { select: { userId: true } } }, + }); + if (!eval_) return false; + if (eval_.evaluatorId === userId) return true; + if (eval_.sharedWith.some((s) => s.userId === userId)) return true; + return false; +} + export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } const { id } = await params; const evaluation = await prisma.evaluation.findUnique({ where: { id }, @@ -17,6 +34,7 @@ export async function GET( }, }, dimensionScores: { include: { dimension: true } }, + sharedWith: { include: { user: { select: { id: true, email: true, name: true } } } }, }, }); @@ -24,6 +42,15 @@ export async function GET( return NextResponse.json({ error: "Evaluation not found" }, { status: 404 }); } + const hasAccess = await canAccessEvaluation( + id, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + // Prisma ORM omits suggestedQuestions in some contexts — fetch via raw const templateId = evaluation.templateId; const dimsRaw = evaluation.template @@ -77,6 +104,10 @@ export async function PUT( { params }: { params: Promise<{ id: string }> } ) { try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } const { id } = await params; const body = await req.json(); @@ -87,6 +118,15 @@ export async function PUT( return NextResponse.json({ error: "Evaluation not found" }, { status: 404 }); } + const hasAccess = await canAccessEvaluation( + id, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + const updateData: Record = {}; if (candidateName != null) updateData.candidateName = candidateName; if (candidateRole != null) updateData.candidateRole = candidateRole; @@ -169,7 +209,21 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } const { id } = await params; + + const hasAccess = await canAccessEvaluation( + id, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + await prisma.evaluation.delete({ where: { id } }); return NextResponse.json({ ok: true }); } catch (e) { diff --git a/src/app/api/evaluations/[id]/share/[userId]/route.ts b/src/app/api/evaluations/[id]/share/[userId]/route.ts new file mode 100644 index 0000000..5c164ec --- /dev/null +++ b/src/app/api/evaluations/[id]/share/[userId]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +async function canAccessEvaluation(evaluationId: string, userId: string, isAdmin: boolean) { + if (isAdmin) return true; + const eval_ = await prisma.evaluation.findUnique({ + where: { id: evaluationId }, + select: { evaluatorId: true, sharedWith: { select: { userId: true } } }, + }); + if (!eval_) return false; + if (eval_.evaluatorId === userId) return true; + if (eval_.sharedWith.some((s) => s.userId === userId)) return true; + return false; +} + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string; userId: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const { id, userId } = await params; + + const hasAccess = await canAccessEvaluation( + id, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + await prisma.evaluationShare.deleteMany({ + where: { evaluationId: id, userId }, + }); + + return NextResponse.json({ ok: true }); + } catch (e) { + console.error(e); + return NextResponse.json({ error: "Erreur" }, { status: 500 }); + } +} diff --git a/src/app/api/evaluations/[id]/share/route.ts b/src/app/api/evaluations/[id]/share/route.ts new file mode 100644 index 0000000..ca4232c --- /dev/null +++ b/src/app/api/evaluations/[id]/share/route.ts @@ -0,0 +1,111 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +async function canAccessEvaluation(evaluationId: string, userId: string, isAdmin: boolean) { + if (isAdmin) return true; + const eval_ = await prisma.evaluation.findUnique({ + where: { id: evaluationId }, + select: { evaluatorId: true, sharedWith: { select: { userId: true } } }, + }); + if (!eval_) return false; + if (eval_.evaluatorId === userId) return true; + if (eval_.sharedWith.some((s) => s.userId === userId)) return true; + return false; +} + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const { id } = await params; + + const hasAccess = await canAccessEvaluation( + id, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + const sharedWith = await prisma.evaluationShare.findMany({ + where: { evaluationId: id }, + include: { user: { select: { id: true, email: true, name: true } } }, + }); + return NextResponse.json(sharedWith); + } catch (e) { + console.error(e); + return NextResponse.json({ error: "Erreur" }, { status: 500 }); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const { id } = await params; + const body = await req.json(); + const { email, userId } = body; + + if (!userId && !email) { + return NextResponse.json({ error: "userId ou email requis" }, { status: 400 }); + } + + let user; + if (userId && typeof userId === "string") { + user = await prisma.user.findUnique({ where: { id: userId } }); + } else if (email && typeof email === "string") { + user = await prisma.user.findUnique({ + where: { email: String(email).toLowerCase().trim() }, + }); + } + if (!user) { + return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 }); + } + + const hasAccess = await canAccessEvaluation( + id, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) { + return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + } + + if (user.id === session.user.id) { + return NextResponse.json({ error: "Vous avez déjà accès" }, { status: 400 }); + } + + const evaluation = await prisma.evaluation.findUnique({ + where: { id }, + select: { evaluatorId: true }, + }); + if (evaluation?.evaluatorId === user.id) { + return NextResponse.json({ error: "L'évaluateur a déjà accès" }, { status: 400 }); + } + + await prisma.evaluationShare.upsert({ + where: { + evaluationId_userId: { evaluationId: id, userId: user.id }, + }, + create: { evaluationId: id, userId: user.id }, + update: {}, + }); + + return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } }); + } catch (e) { + console.error(e); + return NextResponse.json({ error: "Erreur" }, { status: 500 }); + } +} diff --git a/src/app/api/evaluations/route.ts b/src/app/api/evaluations/route.ts index b36ae19..aee6f47 100644 --- a/src/app/api/evaluations/route.ts +++ b/src/app/api/evaluations/route.ts @@ -1,16 +1,30 @@ import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/auth"; import { prisma } from "@/lib/db"; export async function GET(req: NextRequest) { try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } const { searchParams } = new URL(req.url); const status = searchParams.get("status"); const templateId = searchParams.get("templateId"); + const isAdmin = session.user.role === "admin"; + const userId = session.user.id; + const evaluations = await prisma.evaluation.findMany({ where: { ...(status && { status }), ...(templateId && { templateId }), + ...(!isAdmin && { + OR: [ + { evaluatorId: userId }, + { sharedWith: { some: { userId } } }, + ], + }), }, include: { template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } }, @@ -28,16 +42,22 @@ export async function GET(req: NextRequest) { export async function POST(req: NextRequest) { try { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } const body = await req.json(); - const { candidateName, candidateRole, candidateTeam, evaluatorName, evaluationDate, templateId } = body; + const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = body; - if (!candidateName || !candidateRole || !evaluatorName || !evaluationDate || !templateId) { + if (!candidateName || !candidateRole || !evaluationDate || !templateId) { return NextResponse.json( - { error: "Missing required fields: candidateName, candidateRole, evaluatorName, evaluationDate, templateId" }, + { error: "Missing required fields: candidateName, candidateRole, evaluationDate, templateId" }, { status: 400 } ); } + const evaluatorName = session.user.name || session.user.email || "Évaluateur"; + const template = await prisma.template.findUnique({ where: { id: templateId }, include: { dimensions: { orderBy: { orderIndex: "asc" } } }, @@ -52,6 +72,7 @@ export async function POST(req: NextRequest) { candidateRole, candidateTeam: candidateTeam || null, evaluatorName, + evaluatorId: session.user.id, evaluationDate: new Date(evaluationDate), templateId, status: "draft", diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..74ecea4 --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +/** Liste des utilisateurs (pour partage d'évaluations) — accessible à tout utilisateur connecté */ +export async function GET() { + const session = await auth(); + if (!session?.user) { + return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); + } + const users = await prisma.user.findMany({ + orderBy: { email: "asc" }, + select: { + id: true, + email: true, + name: true, + }, + }); + return NextResponse.json(users); +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx new file mode 100644 index 0000000..a9340b6 --- /dev/null +++ b/src/app/auth/login/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import Link from "next/link"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await signIn("credentials", { + email, + password, + redirect: false, + }); + if (res?.error) { + setError("Email ou mot de passe incorrect"); + return; + } + window.location.href = "/"; + } catch { + setError("Erreur de connexion"); + } finally { + setLoading(false); + } + } + + return ( +
+

+ Connexion +

+
+
+ + setEmail(e.target.value)} + required + className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30" + placeholder="vous@example.com" + /> +
+
+ + setPassword(e.target.value)} + required + className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30" + /> +
+ {error && ( +

{error}

+ )} + +
+

+ Pas de compte ?{" "} + + S'inscrire + +

+
+ ); +} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..4f1c5af --- /dev/null +++ b/src/app/auth/signup/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import Link from "next/link"; + +export default function SignupPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await fetch("/api/auth/signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, name: name || undefined }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error ?? "Erreur lors de l'inscription"); + return; + } + const signInRes = await signIn("credentials", { + email, + password, + redirect: false, + }); + if (signInRes?.error) { + setError("Compte créé mais connexion échouée. Essayez de vous connecter."); + return; + } + window.location.href = "/"; + } catch { + setError("Erreur lors de l'inscription"); + } finally { + setLoading(false); + } + } + + return ( +
+

+ Inscription +

+
+
+ + setEmail(e.target.value)} + required + className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30" + placeholder="vous@example.com" + /> +
+
+ + setName(e.target.value)} + className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30" + placeholder="Jean Dupont" + /> +
+
+ + setPassword(e.target.value)} + required + minLength={6} + className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30" + /> +
+ {error && ( +

{error}

+ )} + +
+

+ Déjà un compte ?{" "} + + Se connecter + +

+
+ ); +} diff --git a/src/app/evaluations/[id]/page.tsx b/src/app/evaluations/[id]/page.tsx index b8646e5..d3650e3 100644 --- a/src/app/evaluations/[id]/page.tsx +++ b/src/app/evaluations/[id]/page.tsx @@ -7,6 +7,7 @@ import { CandidateForm } from "@/components/CandidateForm"; import { DimensionCard } from "@/components/DimensionCard"; import { RadarChart } from "@/components/RadarChart"; import { ExportModal } from "@/components/ExportModal"; +import { ShareModal } from "@/components/ShareModal"; import { ConfirmModal } from "@/components/ConfirmModal"; import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils"; @@ -35,6 +36,7 @@ interface Evaluation { candidateRole: string; candidateTeam?: string | null; evaluatorName: string; + evaluatorId?: string | null; evaluationDate: string; templateId: string; template: { id: string; name: string; dimensions: Dimension[] }; @@ -42,6 +44,7 @@ interface Evaluation { findings: string | null; recommendations: string | null; dimensionScores: DimensionScore[]; + sharedWith?: { id: string; user: { id: string; email: string; name: string | null } }[]; } export default function EvaluationDetailPage() { @@ -53,17 +56,21 @@ export default function EvaluationDetailPage() { const [saving, setSaving] = useState(false); const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]); const [exportOpen, setExportOpen] = useState(false); + const [shareOpen, setShareOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [collapseAllTrigger, setCollapseAllTrigger] = useState(0); + const [users, setUsers] = useState<{ id: string; email: string; name: string | null }[]>([]); const fetchEval = useCallback(() => { setLoading(true); Promise.all([ fetch(`/api/evaluations/${id}`).then((r) => r.json()), fetch("/api/templates").then((r) => r.json()), + fetch("/api/users").then((r) => r.json()), ]) - .then(([evalData, templatesData]) => { + .then(([evalData, templatesData, usersData]) => { setTemplates(Array.isArray(templatesData) ? templatesData : []); + setUsers(Array.isArray(usersData) ? usersData : []); if (evalData?.error) { setEvaluation(null); return; @@ -251,6 +258,12 @@ export default function EvaluationDetailPage() { > {saving ? "..." : "save"} + + + {sharedWith.length > 0 && ( +
    + {sharedWith.map((s) => ( +
  • + {s.user.name || s.user.email} + +
  • + ))} +
+ )} + + + + ); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..7dcc001 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,27 @@ +import { auth } from "@/auth"; +import { NextResponse } from "next/server"; + +export default auth((req) => { + const isLoggedIn = !!req.auth; + const isAuthRoute = + req.nextUrl.pathname.startsWith("/auth/login") || + req.nextUrl.pathname.startsWith("/auth/signup"); + const isApiAuth = req.nextUrl.pathname.startsWith("/api/auth"); + const isAdminRoute = req.nextUrl.pathname.startsWith("/admin"); + + if (isApiAuth) return NextResponse.next(); + if (isAuthRoute && isLoggedIn) { + return NextResponse.redirect(new URL("/", req.nextUrl)); + } + if (!isLoggedIn && !isAuthRoute) { + return NextResponse.redirect(new URL("/auth/login", req.nextUrl)); + } + if (isAdminRoute && req.auth?.user?.role !== "admin") { + return NextResponse.redirect(new URL("/", req.nextUrl)); + } + return NextResponse.next(); +}); + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..eaacf76 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,23 @@ +import "next-auth"; + +declare module "next-auth" { + interface User { + id?: string; + role?: string; + } + interface Session { + user: { + id: string; + email?: string | null; + name?: string | null; + role?: string; + }; + } +} + +declare module "next-auth/jwt" { + interface JWT { + id?: string; + role?: string; + } +}