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.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m4s

This commit is contained in:
Julien Froidefond
2026-02-20 12:58:47 +01:00
parent 9a734dc1ed
commit f5cbc578b7
30 changed files with 1284 additions and 75 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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).

View File

@@ -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",

92
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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)");
}

View File

@@ -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<Template[]>([]);
const { data: session } = useSession();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(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 (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
<p className="mb-6 font-mono text-xs text-zinc-600 dark:text-zinc-500">
Modèles. CRUD via /api/templates
</p>
<section>
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
<div className="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none">
<table className="min-w-full">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Email</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Nom</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Créé le</th>
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-200 dark:border-zinc-600/50 last:border-0">
<td className="px-4 py-2.5 text-sm text-zinc-800 dark:text-zinc-200">{u.email}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{u.name ?? "—"}</td>
<td className="px-4 py-2.5">
<span
className={`font-mono text-xs px-1.5 py-0.5 rounded ${
u.role === "admin" ? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400" : "bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400"
}`}
>
{u.role}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{format(new Date(u.createdAt), "yyyy-MM-dd HH:mm")}
</td>
<td className="px-4 py-2.5 text-right">
<span className="inline-flex items-center gap-2">
{u.role === "admin" ? (
<button
type="button"
onClick={() => setRole(u.id, "evaluator")}
disabled={updatingId === u.id}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-50"
title="Rétrograder en évaluateur"
>
{updatingId === u.id ? "..." : "rétrograder"}
</button>
) : (
<button
type="button"
onClick={() => setRole(u.id, "admin")}
disabled={updatingId === u.id}
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 disabled:opacity-50"
title="Promouvoir admin"
>
{updatingId === u.id ? "..." : "promouvoir admin"}
</button>
)}
{u.id !== session?.user?.id && (
<button
type="button"
onClick={() => setDeleteTarget(u)}
className="font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'utilisateur"
message={
deleteTarget
? `Supprimer ${deleteTarget.name || deleteTarget.email} ? Les évaluations créées par cet utilisateur resteront (évaluateur mis à null).`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
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)}
/>
<section className="mt-8">
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Modèles</h2>
<div className="space-y-3">
{templates.map((t) => (
@@ -54,13 +188,6 @@ export default function AdminPage() {
))}
</div>
</section>
<section className="mt-8">
<h2 className="mb-2 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
<p className="font-mono text-xs text-zinc-600 dark:text-zinc-500">
admin@cars-front.local
</p>
</section>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

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

View File

@@ -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<string, unknown> = {};
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) {

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -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 (
<div className="mx-auto max-w-sm">
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
Connexion
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-500">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{loading ? "..." : "Se connecter"}
</button>
</form>
<p className="mt-4 font-mono text-xs text-zinc-500 dark:text-zinc-400">
Pas de compte ?{" "}
<Link href="/auth/signup" className="text-cyan-600 dark:text-cyan-400 hover:underline">
S'inscrire
</Link>
</p>
</div>
);
}

View File

@@ -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 (
<div className="mx-auto max-w-sm">
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
Inscription
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Nom (optionnel)
</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-500">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{loading ? "..." : "S'inscrire"}
</button>
</form>
<p className="mt-4 font-mono text-xs text-zinc-500 dark:text-zinc-400">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Se connecter
</Link>
</p>
</div>
);
}

View File

@@ -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"}
</button>
<button
onClick={() => setShareOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
>
partager
</button>
<button
onClick={() => setExportOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
@@ -266,8 +279,11 @@ export default function EvaluationDetailPage() {
</div>
)}
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<section className="relative overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-600 bg-gradient-to-br from-zinc-50 to-white dark:from-zinc-800/80 dark:to-zinc-800 p-5 shadow-sm dark:shadow-none">
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-cyan-500/60 to-cyan-400/40" aria-hidden />
<h2 className="mb-4 font-mono text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Session
</h2>
<CandidateForm
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
@@ -393,6 +409,16 @@ export default function EvaluationDetailPage() {
evaluationId={id}
/>
<ShareModal
isOpen={shareOpen}
onClose={() => setShareOpen(false)}
evaluationId={id}
evaluatorId={evaluation.evaluatorId}
users={users}
sharedWith={evaluation.sharedWith ?? []}
onUpdate={fetchEval}
/>
<ConfirmModal
isOpen={deleteConfirmOpen}
title="Supprimer l'évaluation"

View File

@@ -2,10 +2,12 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { CandidateForm } from "@/components/CandidateForm";
export default function NewEvaluationPage() {
const router = useRouter();
const { data: session } = useSession();
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -18,6 +20,13 @@ export default function NewEvaluationPage() {
templateId: "",
});
useEffect(() => {
if (session?.user) {
const display = session.user.name || session.user.email || "";
setForm((f) => ({ ...f, evaluatorName: display }));
}
}, [session?.user?.name, session?.user?.email]);
useEffect(() => {
fetch("/api/templates")
.then((r) => r.json())

View File

@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import { SessionProvider } from "@/components/SessionProvider";
import "./globals.css";
const geistSans = Geist({
@@ -27,10 +28,12 @@ export default function RootLayout({
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}>
<ThemeProvider>
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
</ThemeProvider>
<SessionProvider>
<ThemeProvider>
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
</ThemeProvider>
</SessionProvider>
</body>
</html>
);

59
src/auth.ts Normal file
View File

@@ -0,0 +1,59 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: String(credentials.email) },
});
if (!user?.passwordHash) return null;
const ok = await bcrypt.compare(String(credentials.password), user.passwordHash);
if (!ok) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
pages: {
signIn: "/auth/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.email = user.email;
token.role = (user as { role?: string }).role;
} else if (token.id && !token.role) {
const u = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true },
});
token.role = u?.role;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
});

View File

@@ -14,9 +14,9 @@ interface CandidateFormProps {
}
const inputClass =
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-3 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/50 transition-colors";
"w-full h-10 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-700/60 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20 transition-all box-border";
const labelClass = "mb-0.5 block text-xs font-medium text-zinc-600 dark:text-zinc-400";
const labelClass = "mb-1 block text-xs font-medium text-zinc-500 dark:text-zinc-400";
export function CandidateForm({
candidateName,
@@ -31,8 +31,8 @@ export function CandidateForm({
templateDisabled,
}: CandidateFormProps) {
return (
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
<div className="min-w-[140px]">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-1">
<label className={labelClass}>Candidat</label>
<input
type="text"
@@ -43,7 +43,7 @@ export function CandidateForm({
placeholder="Alice Chen"
/>
</div>
<div className="min-w-[140px]">
<div>
<label className={labelClass}>Rôle</label>
<input
type="text"
@@ -54,7 +54,7 @@ export function CandidateForm({
placeholder="ML Engineer"
/>
</div>
<div className="min-w-[140px]">
<div>
<label className={labelClass}>Équipe</label>
<input
type="text"
@@ -65,7 +65,8 @@ export function CandidateForm({
placeholder="Cars Front"
/>
</div>
<div className="min-w-[120px]">
<div className="border-t border-zinc-200 dark:border-zinc-600 pt-4 sm:col-span-2 lg:col-span-3" />
<div>
<label className={labelClass}>Évaluateur</label>
<input
type="text"
@@ -76,7 +77,7 @@ export function CandidateForm({
placeholder="Jean D."
/>
</div>
<div className="min-w-[120px]">
<div>
<label className={labelClass}>Date</label>
<input
type="date"
@@ -86,7 +87,7 @@ export function CandidateForm({
disabled={disabled}
/>
</div>
<div className="min-w-[160px]">
<div>
<label className={labelClass}>Modèle</label>
<select
value={templateId}

View File

@@ -1,9 +1,12 @@
"use client";
import Link from "next/link";
import { signOut, useSession } from "next-auth/react";
import { ThemeToggle } from "./ThemeToggle";
export function Header() {
const { data: session, status } = useSession();
return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 shadow-sm dark:shadow-none backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
@@ -11,15 +14,29 @@ export function Header() {
iag-eval
</Link>
<nav className="flex items-center gap-6 font-mono text-xs">
<Link href="/" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/dashboard
</Link>
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/new
</Link>
<Link href="/admin" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/admin
</Link>
{status === "authenticated" && (
<>
<Link href="/" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/dashboard
</Link>
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/new
</Link>
{session?.user?.role === "admin" && (
<Link href="/admin" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/admin
</Link>
)}
<span className="text-zinc-400 dark:text-zinc-500">{session?.user?.email}</span>
<button
type="button"
onClick={() => signOut({ callbackUrl: "/auth/login" })}
className="text-zinc-500 hover:text-red-500 dark:text-zinc-400 dark:hover:text-red-400 transition-colors"
>
déconnexion
</button>
</>
)}
<ThemeToggle />
</nav>
</div>

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
interface User {
id: string;
email: string;
name: string | null;
}
interface SharedUser {
id: string;
user: { id: string; email: string; name: string | null };
}
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
evaluationId: string;
evaluatorId?: string | null;
users: User[];
sharedWith: SharedUser[];
onUpdate: () => void;
}
export function ShareModal({
isOpen,
onClose,
evaluationId,
evaluatorId,
users,
sharedWith,
onUpdate,
}: ShareModalProps) {
if (!isOpen) return null;
const [shareUserId, setShareUserId] = useState("");
const [loading, setLoading] = useState(false);
const availableUsers = users.filter(
(u) => u.id !== evaluatorId && !sharedWith.some((s) => s.user.id === u.id)
);
async function handleAdd() {
if (!shareUserId) return;
setLoading(true);
try {
const res = await fetch(`/api/evaluations/${evaluationId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: shareUserId }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
setShareUserId("");
onUpdate();
} else {
alert(data.error ?? "Erreur");
}
} finally {
setLoading(false);
}
}
async function handleRemove(userId: string) {
const res = await fetch(`/api/evaluations/${evaluationId}/share/${userId}`, {
method: "DELETE",
});
if (res.ok) onUpdate();
}
return (
<>
<div className="fixed inset-0 z-40 bg-black/60" onClick={onClose} aria-hidden="true" />
<div
className="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
role="dialog"
aria-label="Partager"
>
<h3 className="mb-4 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-200">
Partager
</h3>
<p className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Ajouter un utilisateur pour lui donner accès.
</p>
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<select
value={shareUserId}
onChange={(e) => setShareUserId(e.target.value)}
className="w-full min-w-0 rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 sm:flex-1"
>
<option value=""> choisir </option>
{availableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email} ({u.email})
</option>
))}
</select>
<button
type="button"
disabled={!shareUserId || loading}
onClick={handleAdd}
className="w-full shrink-0 rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50 sm:w-auto"
>
{loading ? "..." : "ajouter"}
</button>
</div>
{sharedWith.length > 0 && (
<ul className="mb-4 space-y-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{sharedWith.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2">
<span>{s.user.name || s.user.email}</span>
<button
type="button"
onClick={() => handleRemove(s.user.id)}
className="text-red-500 hover:text-red-400"
title="Retirer"
>
×
</button>
</li>
))}
</ul>
)}
<button
type="button"
onClick={onClose}
className="w-full rounded border border-zinc-300 dark:border-zinc-700 py-2 font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
fermer
</button>
</div>
</>
);
}

27
src/middleware.ts Normal file
View File

@@ -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).*)"],
};

23
src/types/next-auth.d.ts vendored Normal file
View File

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