Compare commits

...

2 Commits

Author SHA1 Message Date
e4a4e5a869 feat: integrate authentication session into Header component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m46s
- Updated RootLayout to fetch the authentication session and pass it to the Header component.
- Modified Header to accept session as a prop, enhancing user experience by displaying user-specific information and sign-out functionality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 14:16:41 +01:00
2d8d59322d refactor: déduplication — helpers actions, parseurs partagés, types auth
- Crée src/lib/action-helpers.ts avec ActionResult, requireAuth(),
  requireEvaluationAccess() — type et pattern dupliqués 3× supprimés
- evaluations.ts, share.ts, admin.ts importent depuis action-helpers;
  admin.ts: "Forbidden" → "Accès refusé" pour cohérence
- parseQuestions/parseRubric exportées depuis export-utils et supprimées
  de DimensionCard (copie exacte retirée)
- next-auth.d.ts: Session.user.role passe de optional à required string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 13:43:57 +01:00
10 changed files with 77 additions and 82 deletions

View File

@@ -1,14 +1,14 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireAuth, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string }; export type { ActionResult };
export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> { export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (!role || !["admin", "evaluator"].includes(role)) { if (!role || !["admin", "evaluator"].includes(role)) {
return { success: false, error: "Rôle invalide (admin | evaluator)" }; return { success: false, error: "Rôle invalide (admin | evaluator)" };
@@ -25,8 +25,8 @@ export async function setUserRole(userId: string, role: "admin" | "evaluator"):
} }
export async function deleteUser(userId: string): Promise<ActionResult> { export async function deleteUser(userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (userId === session.user.id) { if (userId === session.user.id) {
return { success: false, error: "Impossible de supprimer votre propre compte" }; return { success: false, error: "Impossible de supprimer votre propre compte" };

View File

@@ -1,16 +1,15 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access";
import { getEvaluation } from "@/lib/server-data"; import { getEvaluation } from "@/lib/server-data";
import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string }; export type { ActionResult };
export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> { export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const evaluation = await getEvaluation(id); const evaluation = await getEvaluation(id);
if (!evaluation) return { success: false, error: "Évaluation introuvable" }; if (!evaluation) return { success: false, error: "Évaluation introuvable" };
@@ -19,10 +18,10 @@ export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<
} }
export async function deleteEvaluation(id: string): Promise<ActionResult> { export async function deleteEvaluation(id: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {
@@ -42,8 +41,8 @@ export async function createEvaluation(data: {
evaluationDate: string; evaluationDate: string;
templateId: string; templateId: string;
}): Promise<ActionResult<{ id: string }>> { }): Promise<ActionResult<{ id: string }>> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data; const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data;
if (!candidateName || !candidateRole || !evaluationDate || !templateId) { if (!candidateName || !candidateRole || !evaluationDate || !templateId) {
@@ -113,10 +112,10 @@ export async function updateDimensionScore(
dimensionId: string, dimensionId: string,
data: { score?: number | null; justification?: string | null; examplesObserved?: string | null; confidence?: string | null; candidateNotes?: string | null } data: { score?: number | null; justification?: string | null; examplesObserved?: string | null; confidence?: string | null; candidateNotes?: string | null }
): Promise<ActionResult> { ): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(evaluationId, session.user.id, session.user.role === "admin"); const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {
@@ -133,10 +132,10 @@ export async function updateDimensionScore(
} }
export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> { export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
const existing = await prisma.evaluation.findUnique({ where: { id } }); const existing = await prisma.evaluation.findUnique({ where: { id } });

View File

@@ -1,21 +1,16 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access"; import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string }; export type { ActionResult };
export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> { export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation( const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
evaluationId,
session.user.id,
session.user.role === "admin"
);
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" }; if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" };
@@ -43,14 +38,10 @@ export async function addShare(evaluationId: string, userId: string): Promise<Ac
} }
export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> { export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation( const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
evaluationId,
session.user.id,
session.user.role === "admin"
);
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { auth } from "@/auth";
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 { SessionProvider } from "@/components/SessionProvider";
@@ -26,17 +27,18 @@ export const metadata: Metadata = {
manifest: "/manifest.json", manifest: "/manifest.json",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const session = await auth();
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`}>
<SessionProvider> <SessionProvider>
<ThemeProvider> <ThemeProvider>
<Header /> <Header session={session} />
<main className="mx-auto max-w-7xl px-4 py-6">{children}</main> <main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</ThemeProvider> </ThemeProvider>
</SessionProvider> </SessionProvider>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { updateDimensionScore } from "@/actions/evaluations"; import { updateDimensionScore } from "@/actions/evaluations";
import { parseQuestions, parseRubric } from "@/lib/export-utils";
const STORAGE_KEY_PREFIX = "eval-dim-expanded"; const STORAGE_KEY_PREFIX = "eval-dim-expanded";
@@ -56,25 +57,6 @@ interface DimensionCardProps {
collapseAllTrigger?: number; collapseAllTrigger?: number;
} }
function parseRubric(rubric: string): string[] {
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
const labels: string[] = [];
for (let i = 1; i <= 5; i++) {
const m = rubric.match(new RegExp(`${i}:([^;]+)`));
labels.push(m ? m[1].trim() : String(i));
}
return labels;
}
function parseQuestions(s: string | null | undefined): string[] {
if (!s) return [];
try {
const arr = JSON.parse(s) as unknown;
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
const inputClass = const inputClass =
"w-full 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 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"; "w-full 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 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30";

View File

@@ -1,12 +1,9 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { signOut, useSession } from "next-auth/react"; import type { Session } from "next-auth";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { SignOutButton } from "./SignOutButton";
export function Header() { export function Header({ session }: { session: Session | null }) {
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">
@@ -17,7 +14,7 @@ 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">
{status === "authenticated" ? ( {session ? (
<> <>
<Link <Link
href="/dashboard" href="/dashboard"
@@ -31,7 +28,7 @@ export function Header() {
> >
/new /new
</Link> </Link>
{session?.user?.role === "admin" && ( {session.user.role === "admin" && (
<Link <Link
href="/admin" href="/admin"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
@@ -46,18 +43,9 @@ export function Header() {
/paramètres /paramètres
</Link> </Link>
<span className="text-zinc-400 dark:text-zinc-500"> <span className="text-zinc-400 dark:text-zinc-500">
{session?.user?.email} {session.user.email}
</span> </span>
<button <SignOutButton />
type="button"
onClick={async () => {
await signOut({ redirect: false });
window.location.href = "/auth/login";
}}
className="text-zinc-500 hover:text-red-500 dark:text-zinc-400 dark:hover:text-red-400 transition-colors"
>
déconnexion
</button>
</> </>
) : ( ) : (
<Link <Link

View File

@@ -0,0 +1,18 @@
"use client";
import { signOut } from "next-auth/react";
export function SignOutButton() {
return (
<button
type="button"
onClick={async () => {
await signOut({ redirect: false });
window.location.href = "/auth/login";
}}
className="text-zinc-500 hover:text-red-500 dark:text-zinc-400 dark:hover:text-red-400 transition-colors"
>
déconnexion
</button>
);
}

15
src/lib/action-helpers.ts Normal file
View File

@@ -0,0 +1,15 @@
import { auth } from "@/auth";
import { canAccessEvaluation } from "@/lib/evaluation-access";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function requireAuth() {
const session = await auth();
if (!session?.user) return null;
return session;
}
export async function requireEvaluationAccess(evaluationId: string, userId: string, isAdmin: boolean) {
const hasAccess = await canAccessEvaluation(evaluationId, userId, isAdmin);
return hasAccess;
}

View File

@@ -6,7 +6,7 @@ export interface EvaluationWithScores extends Evaluation {
} }
/** Parse suggestedQuestions JSON array */ /** Parse suggestedQuestions JSON array */
function parseQuestions(s: string | null | undefined): string[] { export function parseQuestions(s: string | null | undefined): string[] {
if (!s) return []; if (!s) return [];
try { try {
const arr = JSON.parse(s) as unknown; const arr = JSON.parse(s) as unknown;
@@ -17,7 +17,7 @@ function parseQuestions(s: string | null | undefined): string[] {
} }
/** Parse rubric "1:X;2:Y;..." into labels */ /** Parse rubric "1:X;2:Y;..." into labels */
function parseRubric(rubric: string): string[] { export function parseRubric(rubric: string): string[] {
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"]; if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
const labels: string[] = []; const labels: string[] = [];
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {

View File

@@ -10,7 +10,7 @@ declare module "next-auth" {
id: string; id: string;
email?: string | null; email?: string | null;
name?: string | null; name?: string | null;
role?: string; role: string;
}; };
} }
} }