From f5cbc578b71f64d9fbad0834dd0d384a2a45e03b Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 20 Feb 2026 12:58:47 +0100 Subject: [PATCH] Update Dockerfile and package.json to use Prisma migrations, add bcryptjs and next-auth dependencies, and enhance README instructions for database setup. Refactor Prisma schema to include password hashing for users and implement evaluation sharing functionality. Improve admin page with user management features and integrate session handling for authentication. Enhance evaluation detail page with sharing options and update API routes for access control based on user roles. --- .gitea/workflows/deploy.yml | 7 +- Dockerfile | 2 +- README.md | 7 +- package.json | 6 +- pnpm-lock.yaml | 92 +++++++++++ .../20250220000000_init/migration.sql | 100 ++++++++++++ prisma/schema.prisma | 47 ++++-- prisma/seed.ts | 29 ++-- src/app/admin/page.tsx | 153 ++++++++++++++++-- src/app/api/admin/users/[id]/route.ts | 44 +++++ src/app/api/admin/users/route.ts | 21 +++ src/app/api/auth/[...nextauth]/route.ts | 3 + src/app/api/auth/signup/route.ts | 39 +++++ src/app/api/evaluations/[id]/route.ts | 54 +++++++ .../evaluations/[id]/share/[userId]/route.ts | 46 ++++++ src/app/api/evaluations/[id]/share/route.ts | 111 +++++++++++++ src/app/api/evaluations/route.ts | 27 +++- src/app/api/users/route.ts | 20 +++ src/app/auth/login/page.tsx | 85 ++++++++++ src/app/auth/signup/page.tsx | 109 +++++++++++++ src/app/evaluations/[id]/page.tsx | 32 +++- src/app/evaluations/new/page.tsx | 9 ++ src/app/layout.tsx | 11 +- src/auth.ts | 59 +++++++ src/components/CandidateForm.tsx | 19 +-- src/components/Header.tsx | 35 ++-- src/components/SessionProvider.tsx | 7 + src/components/ShareModal.tsx | 135 ++++++++++++++++ src/middleware.ts | 27 ++++ src/types/next-auth.d.ts | 23 +++ 30 files changed, 1284 insertions(+), 75 deletions(-) create mode 100644 prisma/migrations/20250220000000_init/migration.sql create mode 100644 src/app/api/admin/users/[id]/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/signup/route.ts create mode 100644 src/app/api/evaluations/[id]/share/[userId]/route.ts create mode 100644 src/app/api/evaluations/[id]/share/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/auth/login/page.tsx create mode 100644 src/app/auth/signup/page.tsx create mode 100644 src/auth.ts create mode 100644 src/components/SessionProvider.tsx create mode 100644 src/components/ShareModal.tsx create mode 100644 src/middleware.ts create mode 100644 src/types/next-auth.d.ts 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; + } +}