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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m4s
This commit is contained in:
@@ -16,11 +16,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DOCKER_BUILDKIT: 1
|
DOCKER_BUILDKIT: 1
|
||||||
COMPOSE_DOCKER_CLI_BUILD: 1
|
COMPOSE_DOCKER_CLI_BUILD: 1
|
||||||
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
|
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
|
||||||
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 }}
|
|
||||||
run: |
|
run: |
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ RUN apt-get update -y && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
|
|||||||
&& chmod +x /entrypoint.sh
|
&& chmod +x /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/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"]
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ Production-ready web app for evaluating IA/GenAI maturity of candidates. Built f
|
|||||||
pnpm install
|
pnpm install
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
pnpm db:generate
|
pnpm db:generate
|
||||||
pnpm db:push
|
pnpm db:push # ou pnpm db:migrate pour une DB vide
|
||||||
pnpm db:seed
|
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
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -91,7 +94,7 @@ Run `pnpm exec playwright install` once to install browsers for E2E.
|
|||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
1. Set `DATABASE_URL` to Postgres (e.g. Supabase, Neon).
|
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`
|
3. Seed if needed: `pnpm db:seed`
|
||||||
4. Build: `pnpm build && pnpm start`
|
4. Build: `pnpm build && pnpm start`
|
||||||
5. Or deploy to Vercel (set env, use Vercel Postgres or external DB).
|
5. Or deploy to Vercel (set env, use Vercel Postgres or external DB).
|
||||||
|
|||||||
@@ -16,19 +16,23 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"db:migrate": "prisma migrate deploy"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"jspdf": "^4.2.0",
|
"jspdf": "^4.2.0",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
"@prisma/client": "^5.22.0"
|
"@prisma/client": "^5.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
|||||||
92
pnpm-lock.yaml
generated
92
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
|||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^5.22.0
|
specifier: ^5.22.0
|
||||||
version: 5.22.0(prisma@5.22.0)
|
version: 5.22.0(prisma@5.22.0)
|
||||||
|
bcryptjs:
|
||||||
|
specifier: ^2.4.3
|
||||||
|
version: 2.4.3
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
@@ -23,6 +26,9 @@ importers:
|
|||||||
next:
|
next:
|
||||||
specifier: 16.1.6
|
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)
|
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:
|
react:
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
@@ -39,6 +45,9 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.2.0
|
version: 4.2.0
|
||||||
|
'@types/bcryptjs':
|
||||||
|
specifier: ^2.4.6
|
||||||
|
version: 2.4.6
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.33
|
version: 20.19.33
|
||||||
@@ -94,6 +103,20 @@ packages:
|
|||||||
'@asamuzakjp/nwsapi@2.3.9':
|
'@asamuzakjp/nwsapi@2.3.9':
|
||||||
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
|
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':
|
'@babel/code-frame@7.29.0':
|
||||||
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -670,6 +693,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||||
engines: {node: '>=12.4.0'}
|
engines: {node: '>=12.4.0'}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1':
|
||||||
|
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.58.2':
|
||||||
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -953,6 +979,9 @@ packages:
|
|||||||
'@types/babel__traverse@7.28.0':
|
'@types/babel__traverse@7.28.0':
|
||||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||||
|
|
||||||
|
'@types/bcryptjs@2.4.6':
|
||||||
|
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||||
|
|
||||||
@@ -1302,6 +1331,9 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
bcryptjs@2.4.3:
|
||||||
|
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||||
|
|
||||||
@@ -2028,6 +2060,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jose@6.1.3:
|
||||||
|
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
@@ -2225,6 +2260,22 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
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:
|
next@16.1.6:
|
||||||
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
||||||
engines: {node: '>=20.9.0'}
|
engines: {node: '>=20.9.0'}
|
||||||
@@ -2253,6 +2304,9 @@ packages:
|
|||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.5:
|
||||||
|
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
|
||||||
|
|
||||||
object-assign@4.1.1:
|
object-assign@4.1.1:
|
||||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2364,6 +2418,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
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:
|
prelude-ls@1.2.1:
|
||||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2929,6 +2991,14 @@ snapshots:
|
|||||||
|
|
||||||
'@asamuzakjp/nwsapi@2.3.9': {}
|
'@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':
|
'@babel/code-frame@7.29.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
@@ -3389,6 +3459,8 @@ snapshots:
|
|||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
|
'@panva/hkdf@1.2.1': {}
|
||||||
|
|
||||||
'@playwright/test@1.58.2':
|
'@playwright/test@1.58.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
playwright: 1.58.2
|
playwright: 1.58.2
|
||||||
@@ -3612,6 +3684,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.29.0
|
'@babel/types': 7.29.0
|
||||||
|
|
||||||
|
'@types/bcryptjs@2.4.6': {}
|
||||||
|
|
||||||
'@types/chai@5.2.3':
|
'@types/chai@5.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/deep-eql': 4.0.2
|
'@types/deep-eql': 4.0.2
|
||||||
@@ -3983,6 +4057,8 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.10.0: {}
|
baseline-browser-mapping@2.10.0: {}
|
||||||
|
|
||||||
|
bcryptjs@2.4.3: {}
|
||||||
|
|
||||||
bidi-js@1.0.3:
|
bidi-js@1.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
@@ -4896,6 +4972,8 @@ snapshots:
|
|||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
jose@6.1.3: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-yaml@4.1.1:
|
js-yaml@4.1.1:
|
||||||
@@ -5078,6 +5156,12 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
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):
|
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:
|
dependencies:
|
||||||
'@next/env': 16.1.6
|
'@next/env': 16.1.6
|
||||||
@@ -5112,6 +5196,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
oauth4webapi@3.8.5: {}
|
||||||
|
|
||||||
object-assign@4.1.1: {}
|
object-assign@4.1.1: {}
|
||||||
|
|
||||||
object-inspect@1.13.4: {}
|
object-inspect@1.13.4: {}
|
||||||
@@ -5228,6 +5314,12 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.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: {}
|
prelude-ls@1.2.1: {}
|
||||||
|
|
||||||
prisma@5.22.0:
|
prisma@5.22.0:
|
||||||
|
|||||||
100
prisma/migrations/20250220000000_init/migration.sql
Normal file
100
prisma/migrations/20250220000000_init/migration.sql
Normal 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");
|
||||||
@@ -12,12 +12,15 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
name String?
|
name String?
|
||||||
role String @default("evaluator") // evaluator | admin
|
passwordHash String?
|
||||||
createdAt DateTime @default(now())
|
role String @default("evaluator") // evaluator | admin
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
evaluations Evaluation[] @relation("Evaluator")
|
||||||
|
sharedEvaluations EvaluationShare[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Template {
|
model Template {
|
||||||
@@ -48,17 +51,31 @@ model Evaluation {
|
|||||||
candidateName String
|
candidateName String
|
||||||
candidateRole String
|
candidateRole String
|
||||||
candidateTeam String? // équipe du candidat
|
candidateTeam String? // équipe du candidat
|
||||||
evaluatorName String
|
evaluatorName String
|
||||||
evaluationDate DateTime
|
evaluatorId String?
|
||||||
templateId String
|
evaluator User? @relation("Evaluator", fields: [evaluatorId], references: [id], onDelete: SetNull)
|
||||||
template Template @relation(fields: [templateId], references: [id])
|
evaluationDate DateTime
|
||||||
status String @default("draft") // draft | submitted
|
templateId String
|
||||||
findings String? // auto-generated summary
|
template Template @relation(fields: [templateId], references: [id])
|
||||||
|
status String @default("draft") // draft | submitted
|
||||||
|
findings String? // auto-generated summary
|
||||||
recommendations String?
|
recommendations String?
|
||||||
dimensionScores DimensionScore[]
|
dimensionScores DimensionScore[]
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
createdAt DateTime @default(now())
|
sharedWith EvaluationShare[]
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
model DimensionScore {
|
||||||
|
|||||||
@@ -434,15 +434,18 @@ async function main() {
|
|||||||
});
|
});
|
||||||
if (!template) throw new Error("Template not found");
|
if (!template) throw new Error("Template not found");
|
||||||
|
|
||||||
await prisma.user.upsert({
|
const bcrypt = require("bcryptjs");
|
||||||
where: { email: "admin@cars-front.local" },
|
const adminHash = bcrypt.hashSync("admin123", 10);
|
||||||
create: {
|
const admin = await prisma.user.upsert({
|
||||||
email: "admin@cars-front.local",
|
where: { email: "admin@cars-front.local" },
|
||||||
name: "Admin User",
|
create: {
|
||||||
role: "admin",
|
email: "admin@cars-front.local",
|
||||||
},
|
name: "Admin User",
|
||||||
update: {},
|
passwordHash: adminHash,
|
||||||
});
|
role: "admin",
|
||||||
|
},
|
||||||
|
update: { passwordHash: adminHash },
|
||||||
|
});
|
||||||
|
|
||||||
const dims = await prisma.templateDimension.findMany({
|
const dims = await prisma.templateDimension.findMany({
|
||||||
where: { templateId: template.id },
|
where: { templateId: template.id },
|
||||||
@@ -485,6 +488,7 @@ async function main() {
|
|||||||
candidateRole: r.role,
|
candidateRole: r.role,
|
||||||
candidateTeam: r.team,
|
candidateTeam: r.team,
|
||||||
evaluatorName: r.evaluator,
|
evaluatorName: r.evaluator,
|
||||||
|
evaluatorId: admin.id,
|
||||||
evaluationDate: new Date(2025, 1, 15 + i),
|
evaluationDate: new Date(2025, 1, 15 + i),
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
status: i === 0 ? "submitted" : "draft",
|
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)");
|
console.log("Seed complete: templates synced, répondants upserted (évaluations non vidées)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { ConfirmModal } from "@/components/ConfirmModal";
|
||||||
|
|
||||||
interface Template {
|
interface Template {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,14 +11,51 @@ interface Template {
|
|||||||
dimensions: { id: string; title: string; orderIndex: number }[];
|
dimensions: { id: string; title: string; orderIndex: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string | null;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [templates, setTemplates] = useState<Template[]>([]);
|
const [templates, setTemplates] = useState<Template[]>([]);
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/templates")
|
Promise.all([
|
||||||
.then((r) => r.json())
|
fetch("/api/templates").then((r) => r.json()),
|
||||||
.then(setTemplates)
|
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));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -26,11 +66,105 @@ export default function AdminPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
|
<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>
|
<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>
|
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Modèles</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{templates.map((t) => (
|
{templates.map((t) => (
|
||||||
@@ -54,13 +188,6 @@ export default function AdminPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/app/api/admin/users/[id]/route.ts
Normal file
44
src/app/api/admin/users/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
21
src/app/api/admin/users/route.ts
Normal file
21
src/app/api/admin/users/route.ts
Normal 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);
|
||||||
|
}
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
39
src/app/api/auth/signup/route.ts
Normal file
39
src/app/api/auth/signup/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,29 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/db";
|
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(
|
export async function GET(
|
||||||
_req: NextRequest,
|
_req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const evaluation = await prisma.evaluation.findUnique({
|
const evaluation = await prisma.evaluation.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -17,6 +34,7 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
dimensionScores: { include: { dimension: true } },
|
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 });
|
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
|
// Prisma ORM omits suggestedQuestions in some contexts — fetch via raw
|
||||||
const templateId = evaluation.templateId;
|
const templateId = evaluation.templateId;
|
||||||
const dimsRaw = evaluation.template
|
const dimsRaw = evaluation.template
|
||||||
@@ -77,6 +104,10 @@ export async function PUT(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
|
|
||||||
@@ -87,6 +118,15 @@ export async function PUT(
|
|||||||
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
|
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> = {};
|
const updateData: Record<string, unknown> = {};
|
||||||
if (candidateName != null) updateData.candidateName = candidateName;
|
if (candidateName != null) updateData.candidateName = candidateName;
|
||||||
if (candidateRole != null) updateData.candidateRole = candidateRole;
|
if (candidateRole != null) updateData.candidateRole = candidateRole;
|
||||||
@@ -169,7 +209,21 @@ export async function DELETE(
|
|||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
const { id } = await params;
|
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 } });
|
await prisma.evaluation.delete({ where: { id } });
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
46
src/app/api/evaluations/[id]/share/[userId]/route.ts
Normal file
46
src/app/api/evaluations/[id]/share/[userId]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/app/api/evaluations/[id]/share/route.ts
Normal file
111
src/app/api/evaluations/[id]/share/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,30 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const status = searchParams.get("status");
|
const status = searchParams.get("status");
|
||||||
const templateId = searchParams.get("templateId");
|
const templateId = searchParams.get("templateId");
|
||||||
|
|
||||||
|
const isAdmin = session.user.role === "admin";
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
const evaluations = await prisma.evaluation.findMany({
|
const evaluations = await prisma.evaluation.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(status && { status }),
|
...(status && { status }),
|
||||||
...(templateId && { templateId }),
|
...(templateId && { templateId }),
|
||||||
|
...(!isAdmin && {
|
||||||
|
OR: [
|
||||||
|
{ evaluatorId: userId },
|
||||||
|
{ sharedWith: { some: { userId } } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||||
@@ -28,16 +42,22 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
}
|
||||||
const body = await req.json();
|
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(
|
return NextResponse.json(
|
||||||
{ error: "Missing required fields: candidateName, candidateRole, evaluatorName, evaluationDate, templateId" },
|
{ error: "Missing required fields: candidateName, candidateRole, evaluationDate, templateId" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evaluatorName = session.user.name || session.user.email || "Évaluateur";
|
||||||
|
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: { id: templateId },
|
where: { id: templateId },
|
||||||
include: { dimensions: { orderBy: { orderIndex: "asc" } } },
|
include: { dimensions: { orderBy: { orderIndex: "asc" } } },
|
||||||
@@ -52,6 +72,7 @@ export async function POST(req: NextRequest) {
|
|||||||
candidateRole,
|
candidateRole,
|
||||||
candidateTeam: candidateTeam || null,
|
candidateTeam: candidateTeam || null,
|
||||||
evaluatorName,
|
evaluatorName,
|
||||||
|
evaluatorId: session.user.id,
|
||||||
evaluationDate: new Date(evaluationDate),
|
evaluationDate: new Date(evaluationDate),
|
||||||
templateId,
|
templateId,
|
||||||
status: "draft",
|
status: "draft",
|
||||||
|
|||||||
20
src/app/api/users/route.ts
Normal file
20
src/app/api/users/route.ts
Normal 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);
|
||||||
|
}
|
||||||
85
src/app/auth/login/page.tsx
Normal file
85
src/app/auth/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/app/auth/signup/page.tsx
Normal file
109
src/app/auth/signup/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { CandidateForm } from "@/components/CandidateForm";
|
|||||||
import { DimensionCard } from "@/components/DimensionCard";
|
import { DimensionCard } from "@/components/DimensionCard";
|
||||||
import { RadarChart } from "@/components/RadarChart";
|
import { RadarChart } from "@/components/RadarChart";
|
||||||
import { ExportModal } from "@/components/ExportModal";
|
import { ExportModal } from "@/components/ExportModal";
|
||||||
|
import { ShareModal } from "@/components/ShareModal";
|
||||||
import { ConfirmModal } from "@/components/ConfirmModal";
|
import { ConfirmModal } from "@/components/ConfirmModal";
|
||||||
import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils";
|
import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils";
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ interface Evaluation {
|
|||||||
candidateRole: string;
|
candidateRole: string;
|
||||||
candidateTeam?: string | null;
|
candidateTeam?: string | null;
|
||||||
evaluatorName: string;
|
evaluatorName: string;
|
||||||
|
evaluatorId?: string | null;
|
||||||
evaluationDate: string;
|
evaluationDate: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
template: { id: string; name: string; dimensions: Dimension[] };
|
template: { id: string; name: string; dimensions: Dimension[] };
|
||||||
@@ -42,6 +44,7 @@ interface Evaluation {
|
|||||||
findings: string | null;
|
findings: string | null;
|
||||||
recommendations: string | null;
|
recommendations: string | null;
|
||||||
dimensionScores: DimensionScore[];
|
dimensionScores: DimensionScore[];
|
||||||
|
sharedWith?: { id: string; user: { id: string; email: string; name: string | null } }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EvaluationDetailPage() {
|
export default function EvaluationDetailPage() {
|
||||||
@@ -53,17 +56,21 @@ export default function EvaluationDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
|
const [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
|
||||||
|
const [users, setUsers] = useState<{ id: string; email: string; name: string | null }[]>([]);
|
||||||
|
|
||||||
const fetchEval = useCallback(() => {
|
const fetchEval = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`/api/evaluations/${id}`).then((r) => r.json()),
|
fetch(`/api/evaluations/${id}`).then((r) => r.json()),
|
||||||
fetch("/api/templates").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 : []);
|
setTemplates(Array.isArray(templatesData) ? templatesData : []);
|
||||||
|
setUsers(Array.isArray(usersData) ? usersData : []);
|
||||||
if (evalData?.error) {
|
if (evalData?.error) {
|
||||||
setEvaluation(null);
|
setEvaluation(null);
|
||||||
return;
|
return;
|
||||||
@@ -251,6 +258,12 @@ export default function EvaluationDetailPage() {
|
|||||||
>
|
>
|
||||||
{saving ? "..." : "save"}
|
{saving ? "..." : "save"}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setExportOpen(true)}
|
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"
|
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>
|
</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">
|
<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">
|
||||||
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
|
<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
|
<CandidateForm
|
||||||
candidateName={evaluation.candidateName}
|
candidateName={evaluation.candidateName}
|
||||||
candidateRole={evaluation.candidateRole}
|
candidateRole={evaluation.candidateRole}
|
||||||
@@ -393,6 +409,16 @@ export default function EvaluationDetailPage() {
|
|||||||
evaluationId={id}
|
evaluationId={id}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ShareModal
|
||||||
|
isOpen={shareOpen}
|
||||||
|
onClose={() => setShareOpen(false)}
|
||||||
|
evaluationId={id}
|
||||||
|
evaluatorId={evaluation.evaluatorId}
|
||||||
|
users={users}
|
||||||
|
sharedWith={evaluation.sharedWith ?? []}
|
||||||
|
onUpdate={fetchEval}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={deleteConfirmOpen}
|
isOpen={deleteConfirmOpen}
|
||||||
title="Supprimer l'évaluation"
|
title="Supprimer l'évaluation"
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { CandidateForm } from "@/components/CandidateForm";
|
import { CandidateForm } from "@/components/CandidateForm";
|
||||||
|
|
||||||
export default function NewEvaluationPage() {
|
export default function NewEvaluationPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { data: session } = useSession();
|
||||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -18,6 +20,13 @@ export default function NewEvaluationPage() {
|
|||||||
templateId: "",
|
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(() => {
|
useEffect(() => {
|
||||||
fetch("/api/templates")
|
fetch("/api/templates")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import { Header } from "@/components/Header";
|
import { Header } from "@/components/Header";
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
|
import { SessionProvider } from "@/components/SessionProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -27,10 +28,12 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="fr" suppressHydrationWarning>
|
<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`}>
|
<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>
|
<SessionProvider>
|
||||||
<Header />
|
<ThemeProvider>
|
||||||
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
|
<Header />
|
||||||
</ThemeProvider>
|
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
|
||||||
|
</ThemeProvider>
|
||||||
|
</SessionProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
59
src/auth.ts
Normal file
59
src/auth.ts
Normal 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 },
|
||||||
|
});
|
||||||
@@ -14,9 +14,9 @@ interface CandidateFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputClass =
|
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({
|
export function CandidateForm({
|
||||||
candidateName,
|
candidateName,
|
||||||
@@ -31,8 +31,8 @@ export function CandidateForm({
|
|||||||
templateDisabled,
|
templateDisabled,
|
||||||
}: CandidateFormProps) {
|
}: CandidateFormProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="min-w-[140px]">
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
<label className={labelClass}>Candidat</label>
|
<label className={labelClass}>Candidat</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -43,7 +43,7 @@ export function CandidateForm({
|
|||||||
placeholder="Alice Chen"
|
placeholder="Alice Chen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[140px]">
|
<div>
|
||||||
<label className={labelClass}>Rôle</label>
|
<label className={labelClass}>Rôle</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -54,7 +54,7 @@ export function CandidateForm({
|
|||||||
placeholder="ML Engineer"
|
placeholder="ML Engineer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[140px]">
|
<div>
|
||||||
<label className={labelClass}>Équipe</label>
|
<label className={labelClass}>Équipe</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -65,7 +65,8 @@ export function CandidateForm({
|
|||||||
placeholder="Cars Front"
|
placeholder="Cars Front"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<label className={labelClass}>Évaluateur</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -76,7 +77,7 @@ export function CandidateForm({
|
|||||||
placeholder="Jean D."
|
placeholder="Jean D."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[120px]">
|
<div>
|
||||||
<label className={labelClass}>Date</label>
|
<label className={labelClass}>Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@@ -86,7 +87,7 @@ export function CandidateForm({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[160px]">
|
<div>
|
||||||
<label className={labelClass}>Modèle</label>
|
<label className={labelClass}>Modèle</label>
|
||||||
<select
|
<select
|
||||||
value={templateId}
|
value={templateId}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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
|
iag-eval
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-6 font-mono text-xs">
|
<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">
|
{status === "authenticated" && (
|
||||||
/dashboard
|
<>
|
||||||
</Link>
|
<Link href="/" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
||||||
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
/dashboard
|
||||||
/new
|
</Link>
|
||||||
</Link>
|
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
||||||
<Link href="/admin" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
|
/new
|
||||||
/admin
|
</Link>
|
||||||
</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 />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
src/components/SessionProvider.tsx
Normal file
7
src/components/SessionProvider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
135
src/components/ShareModal.tsx
Normal file
135
src/components/ShareModal.tsx
Normal 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
27
src/middleware.ts
Normal 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
23
src/types/next-auth.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user