Add name change functionality to user settings. Update SettingsPasswordForm to handle name updates, including validation and error handling. Fetch current user name for display in settings page.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m44s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m44s
This commit is contained in:
69
CLAUDE.md
Normal file
69
CLAUDE.md
Normal file
@@ -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.
|
||||||
@@ -6,6 +6,26 @@ import { prisma } from "@/lib/db";
|
|||||||
|
|
||||||
export type ActionResult = { success: true } | { success: false; error: string };
|
export type ActionResult = { success: true } | { success: false; error: string };
|
||||||
|
|
||||||
|
export async function changeName(newName: string): Promise<ActionResult> {
|
||||||
|
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(
|
export async function changePassword(
|
||||||
currentPassword: string,
|
currentPassword: string,
|
||||||
newPassword: string
|
newPassword: string
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { getTemplates } from "@/lib/server-data";
|
import { getTemplates } from "@/lib/server-data";
|
||||||
import { NewEvaluationForm } from "@/components/NewEvaluationForm";
|
import { NewEvaluationForm } from "@/components/NewEvaluationForm";
|
||||||
|
|
||||||
export default async function NewEvaluationPage() {
|
export default async function NewEvaluationPage() {
|
||||||
const [session, templates] = await Promise.all([auth(), getTemplates()]);
|
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 (
|
return (
|
||||||
<NewEvaluationForm
|
<NewEvaluationForm
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
import { SettingsPasswordForm } from "@/components/SettingsPasswordForm";
|
import { SettingsPasswordForm } from "@/components/SettingsPasswordForm";
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/auth/login");
|
if (!session?.user?.id) redirect("/auth/login");
|
||||||
|
|
||||||
return <SettingsPasswordForm />;
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return <SettingsPasswordForm currentName={user?.name ?? ""} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,35 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
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 [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
@@ -49,6 +75,43 @@ export function SettingsPasswordForm() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
|
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
|
||||||
|
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
|
Nom d'affichage
|
||||||
|
</h2>
|
||||||
|
<form onSubmit={handleNameSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => { 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{nameError && (
|
||||||
|
<p className="font-mono text-xs text-red-500">{nameError}</p>
|
||||||
|
)}
|
||||||
|
{nameSuccess && (
|
||||||
|
<p className="font-mono text-xs text-emerald-600 dark:text-emerald-400">
|
||||||
|
Nom modifié.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={nameLoading}
|
||||||
|
className="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"
|
||||||
|
>
|
||||||
|
{nameLoading ? "..." : "Modifier le nom"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-4 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
|
||||||
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
Changer mon mot de passe
|
Changer mon mot de passe
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user