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";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { requireAuth, type ActionResult } from "@/lib/action-helpers";
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> {
const session = await auth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" };
const session = await requireAuth();
if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (!role || !["admin", "evaluator"].includes(role)) {
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> {
const session = await auth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" };
const session = await requireAuth();
if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (userId === session.user.id) {
return { success: false, error: "Impossible de supprimer votre propre compte" };

View File

@@ -1,16 +1,15 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access";
import { getEvaluation } from "@/lib/server-data";
import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
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>>>> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const evaluation = await getEvaluation(id);
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> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
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é" };
try {
@@ -42,8 +41,8 @@ export async function createEvaluation(data: {
evaluationDate: string;
templateId: string;
}): Promise<ActionResult<{ id: string }>> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data;
if (!candidateName || !candidateRole || !evaluationDate || !templateId) {
@@ -113,10 +112,10 @@ export async function updateDimensionScore(
dimensionId: string,
data: { score?: number | null; justification?: string | null; examplesObserved?: string | null; confidence?: string | null; candidateNotes?: string | null }
): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
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é" };
try {
@@ -133,10 +132,10 @@ export async function updateDimensionScore(
}
export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
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é" };
const existing = await prisma.evaluation.findUnique({ where: { id } });

View File

@@ -1,21 +1,16 @@
"use server";
import { auth } from "@/auth";
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";
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> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
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 (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> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const session = await requireAuth();
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é" };
try {

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { auth } from "@/auth";
import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import { SessionProvider } from "@/components/SessionProvider";
@@ -26,17 +27,18 @@ export const metadata: Metadata = {
manifest: "/manifest.json",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
return (
<html lang="fr" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}>
<SessionProvider>
<ThemeProvider>
<Header />
<Header session={session} />
<main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</ThemeProvider>
</SessionProvider>

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import { updateDimensionScore } from "@/actions/evaluations";
import { parseQuestions, parseRubric } from "@/lib/export-utils";
const STORAGE_KEY_PREFIX = "eval-dim-expanded";
@@ -56,25 +57,6 @@ interface DimensionCardProps {
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 =
"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 { signOut, useSession } from "next-auth/react";
import type { Session } from "next-auth";
import { ThemeToggle } from "./ThemeToggle";
import { SignOutButton } from "./SignOutButton";
export function Header() {
const { data: session, status } = useSession();
export function Header({ session }: { session: Session | null }) {
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">
@@ -17,7 +14,7 @@ export function Header() {
iag-eval
</Link>
<nav className="flex items-center gap-6 font-mono text-xs">
{status === "authenticated" ? (
{session ? (
<>
<Link
href="/dashboard"
@@ -31,7 +28,7 @@ export function Header() {
>
/new
</Link>
{session?.user?.role === "admin" && (
{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"
@@ -46,18 +43,9 @@ export function Header() {
/paramètres
</Link>
<span className="text-zinc-400 dark:text-zinc-500">
{session?.user?.email}
{session.user.email}
</span>
<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>
<SignOutButton />
</>
) : (
<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 */
function parseQuestions(s: string | null | undefined): string[] {
export function parseQuestions(s: string | null | undefined): string[] {
if (!s) return [];
try {
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 */
function parseRubric(rubric: string): string[] {
export 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++) {

View File

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