diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..db5ab10 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +pnpm dev # Start dev server +pnpm build # Production build +pnpm lint # ESLint +pnpm typecheck # tsc --noEmit + +pnpm test # Vitest unit tests (run once) +pnpm test:e2e # Playwright E2E (requires dev server running) + +pnpm db:generate # Regenerate Prisma client after schema changes +pnpm db:push # Sync schema to DB (dev, no migration files) +pnpm db:migrate # Apply migrations (production) +pnpm db:seed # Seed with sample data +pnpm db:studio # Open Prisma Studio +``` + +Package manager: **pnpm**. + +## Coding conventions + +### Server-first by default +This codebase maximises server-side rendering and minimises client-side JavaScript: + +- **All pages are server components** by default. Never add `"use client"` to a page file. +- **Data fetching always happens server-side** in `src/lib/server-data.ts` — never `fetch()` from the client, never call Prisma from a client component. +- **All mutations use server actions** (`src/actions/`) — never create new API routes for mutations. The only remaining API routes are the export endpoints and auth (see below). +- **`"use client"` is only added** to components that genuinely need browser APIs or React state (forms, interactive widgets). Keep the surface area small. +- **Auth checks happen in every server action** via `const session = await auth()` before touching the DB. + +### Data flow pattern +- **Server page** calls `src/lib/server-data.ts` functions (which call `auth()` + Prisma internally) +- Page passes serialized data as props to **client components** in `src/components/` +- Client components call **server actions** (`src/actions/`) for mutations +- Server actions call `revalidatePath()` to trigger cache invalidation + +### Auth +`src/auth.ts` — NextAuth v5 with Credentials provider (email + bcrypt password). JWT strategy with `id` and `role` added to the token. Two roles: `evaluator` (default) and `admin`. + +`src/middleware.ts` — Protects all routes. Admin routes redirect non-admins. Auth routes redirect logged-in users to `/dashboard`. + +### Access control +`src/lib/evaluation-access.ts` — `canAccessEvaluation()` is the single source of truth. An evaluation is accessible if: user is admin, user is the evaluator, evaluation is shared with the user (`EvaluationShare`), or `isPublic` is true (read-only). + +### Database +SQLite in dev (`DATABASE_URL=file:./dev.db`), swap to Postgres for production. Schema lives in `prisma/schema.prisma`. Key relations: +- `Evaluation` → `Template` → `TemplateDimension[]` +- `Evaluation` → `DimensionScore[]` (one per dimension, `@@unique([evaluationId, dimensionId])`) +- `Evaluation` → `EvaluationShare[]` (many users) +- `Evaluation` → `AuditLog[]` + +`TemplateDimension.suggestedQuestions` is stored as a JSON string (array). `TemplateDimension.rubric` is stored as a `"1:X;2:Y;..."` string. Both are parsed client-side. + +### API routes (remaining) +Only exports and auth use API routes: +- `GET /api/export/csv?id=` and `GET /api/export/pdf?id=` — use `src/lib/export-utils.ts` +- `POST /api/ai/suggest-followups` — **stub**, returns deterministic suggestions; replace with real LLM call if needed +- `POST /api/auth/signup` — user registration + +### Key types +`src/types/next-auth.d.ts` extends `Session.user` with `id` and `role`. Always use `session.user.id` (never `session.user.email`) as the user identifier in server actions. + +### JWT staleness +`session.user.name` comes from the JWT token frozen at login time. If a page needs the user's current `name` (or any other mutable profile field), query Prisma directly using `session.user.id` — do not rely on the session object for those values. diff --git a/src/actions/password.ts b/src/actions/password.ts index a77d3b4..2f00ecd 100644 --- a/src/actions/password.ts +++ b/src/actions/password.ts @@ -6,6 +6,26 @@ import { prisma } from "@/lib/db"; export type ActionResult = { success: true } | { success: false; error: string }; +export async function changeName(newName: string): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Non authentifié" }; + + const trimmed = newName.trim(); + if (!trimmed) return { success: false, error: "Le nom ne peut pas être vide" }; + if (trimmed.length > 64) return { success: false, error: "Le nom est trop long (64 caractères max)" }; + + try { + await prisma.user.update({ + where: { id: session.user.id }, + data: { name: trimmed }, + }); + return { success: true }; + } catch (e) { + console.error("Name change error:", e); + return { success: false, error: "Erreur lors du changement de nom" }; + } +} + export async function changePassword( currentPassword: string, newPassword: string diff --git a/src/app/evaluations/new/page.tsx b/src/app/evaluations/new/page.tsx index c4b2f48..b0609c7 100644 --- a/src/app/evaluations/new/page.tsx +++ b/src/app/evaluations/new/page.tsx @@ -1,13 +1,19 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; import { getTemplates } from "@/lib/server-data"; import { NewEvaluationForm } from "@/components/NewEvaluationForm"; export default async function NewEvaluationPage() { const [session, templates] = await Promise.all([auth(), getTemplates()]); - if (!session?.user) redirect("/auth/login"); + if (!session?.user?.id) redirect("/auth/login"); - const initialEvaluatorName = session.user.name || session.user.email || ""; + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { name: true, email: true }, + }); + + const initialEvaluatorName = user?.name || user?.email || ""; return ( ; + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { name: true }, + }); + + return ; } diff --git a/src/components/SettingsPasswordForm.tsx b/src/components/SettingsPasswordForm.tsx index 0b9c41c..f84e447 100644 --- a/src/components/SettingsPasswordForm.tsx +++ b/src/components/SettingsPasswordForm.tsx @@ -2,9 +2,35 @@ import { useState } from "react"; import Link from "next/link"; -import { changePassword } from "@/actions/password"; +import { changePassword, changeName } from "@/actions/password"; -export function SettingsPasswordForm() { +export function SettingsPasswordForm({ currentName }: { currentName: string }) { + // --- Nom d'affichage --- + const [name, setName] = useState(currentName); + const [nameError, setNameError] = useState(""); + const [nameSuccess, setNameSuccess] = useState(false); + const [nameLoading, setNameLoading] = useState(false); + + async function handleNameSubmit(e: React.FormEvent) { + e.preventDefault(); + setNameError(""); + setNameSuccess(false); + setNameLoading(true); + try { + const result = await changeName(name); + if (result.success) { + setNameSuccess(true); + } else { + setNameError(result.error); + } + } catch { + setNameError("Erreur de connexion"); + } finally { + setNameLoading(false); + } + } + + // --- Mot de passe --- const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); @@ -49,6 +75,43 @@ export function SettingsPasswordForm() {
+

+ Nom d'affichage +

+
+
+ + { setName(e.target.value); setNameSuccess(false); }} + required + maxLength={64} + autoComplete="name" + 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" + /> +
+ {nameError && ( +

{nameError}

+ )} + {nameSuccess && ( +

+ Nom modifié. +

+ )} + +
+
+ +

Changer mon mot de passe