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

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