From 6c0870065fd0a52dc03b631fca663b18fc83effe Mon Sep 17 00:00:00 2001 From: Thierry Date: Thu, 4 Jun 2026 18:59:14 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(profile):=20THI-344=20=E2=80=94=20edit?= =?UTF-8?q?=20the=20display=5Fname=20in=20Mon=20Profil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Profile Hub shell (THI-42 #255) was read-only. Make the display_name editable inline: - Section Identité: "Modifier" button → Input (shadcn) + Enregistrer/Annuler. - Zod validation (trim, 1–50 chars) before calling supabase.auth.updateUser ({ data: { full_name } }). updateUser acts on the caller's own JWT (no cross-user surface); the emitted USER_UPDATED event refreshes the user via AuthContext, so the name re-renders without a manual refetch. - Error surfaced inline (role=alert), stays in edit mode to retry. - Enter saves, Escape cancels. Hooks hoisted above the auth guard (rules-of-hooks). 6 new tests (enter/save/trim/empty-reject/cancel/error). 2065 tests pass, type-check + lint clean. Export + account deletion (RGPD danger zone) = THI-345. Co-Authored-By: Claude Opus 4.8 --- src/app/components/ProfilePage.tsx | 126 ++++++++++++++++++++++++++++- src/test/profilePage.test.tsx | 81 ++++++++++++++++++- 2 files changed, 201 insertions(+), 6 deletions(-) diff --git a/src/app/components/ProfilePage.tsx b/src/app/components/ProfilePage.tsx index ac7ed504..1517691b 100644 --- a/src/app/components/ProfilePage.tsx +++ b/src/app/components/ProfilePage.tsx @@ -18,14 +18,27 @@ * PR #3 ajoutera les sections role-aware (Mes classes pour teacher, Admin * Panel pour admin) dans le UserMenu dropdown ET ici via tabs. */ +import { useState } from 'react'; import { Link } from 'react-router'; import { Helmet } from 'react-helmet-async'; -import { ArrowLeft, Github, KeyRound, Mail, Monitor, Sliders } from 'lucide-react'; +import { z } from 'zod'; +import { ArrowLeft, Check, Github, KeyRound, Mail, Monitor, Pencil, Sliders, X } from 'lucide-react'; import { useAuth } from '../context/AuthContext'; import { useEnvironment, ENV_META } from '../context/EnvironmentContext'; import { RequireAuth } from './auth/RequireAuth'; import { UserAvatar } from './auth/UserAvatar'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; + +// THI-344 — display_name edit. user_metadata is free-form JSON (Supabase does +// not validate it), so we constrain it client-side. Rendering is React-escaped +// (no XSS), but we still trim + cap length to keep the UI sane. +const NAME_SCHEMA = z + .string() + .trim() + .min(1, 'Le nom ne peut pas être vide') + .max(50, 'Maximum 50 caractères'); // OAuth provider label + icon — mirrors the subset of providers we currently // support in LoginModal (GitHub + Google email/password). Anything else @@ -79,6 +92,12 @@ function ProfilePageContent() { // RequireAuth above guarantees user is non-null and session is initialized. const { user } = useAuth(); const { selectedEnv } = useEnvironment(); + // Hooks must run before any early return (rules-of-hooks). nameValue is seeded + // from displayName in startEdit() (displayName is derived after the guard). + const [isEditing, setIsEditing] = useState(false); + const [nameValue, setNameValue] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); if (!user) return null; // type-narrowing for TS — never reached at runtime @@ -95,6 +114,44 @@ function ProfilePageContent() { const envMeta = ENV_META[selectedEnv]; + const startEdit = () => { + setNameValue(displayName); + setError(null); + setIsEditing(true); + }; + + const handleSave = async () => { + const parsed = NAME_SCHEMA.safeParse(nameValue); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? 'Nom invalide'); + return; + } + setSaving(true); + setError(null); + try { + const { supabase } = await import('../../lib/supabase'); + if (!supabase) { + setError('Service indisponible.'); + return; + } + // updateUser acts on the caller's own JWT — no cross-user surface. The + // emitted USER_UPDATED event refreshes `user` via AuthContext, so the + // displayed name re-renders without a manual refetch. + const { error: updateError } = await supabase.auth.updateUser({ + data: { full_name: parsed.data }, + }); + if (updateError) { + setError(updateError.message); + return; + } + setIsEditing(false); + } catch { + setError('Erreur réseau, réessayez.'); + } finally { + setSaving(false); + } + }; + return (
@@ -112,7 +169,7 @@ function ProfilePageContent() {

Mon profil

- Informations de compte, environnement actif et paramètres. Édition de profil et danger zone disponibles bientôt. + Informations de compte, environnement actif et paramètres. Export de données et suppression de compte bientôt disponibles.

{/* ── Identité ──────────────────────────────────────────────────── */} @@ -123,8 +180,69 @@ function ProfilePageContent() {
-

{displayName}

-

{user.email}

+ {isEditing ? ( +
+ + setNameValue(e.target.value)} + maxLength={50} + autoFocus + disabled={saving} + aria-invalid={error ? true : undefined} + className="text-base" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') setIsEditing(false); + }} + /> + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ) : ( +
+

{displayName}

+ +
+ )} +

{user.email}

diff --git a/src/test/profilePage.test.tsx b/src/test/profilePage.test.tsx index 69f068e8..3883bbde 100644 --- a/src/test/profilePage.test.tsx +++ b/src/test/profilePage.test.tsx @@ -12,10 +12,10 @@ * - back-link points to /app dashboard * - unauthenticated user → renders fallback guard message */ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import { HelmetProvider } from 'react-helmet-async'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -47,6 +47,13 @@ vi.mock('../app/context/EnvironmentContext', async () => { }; }); +// THI-344 — ProfilePage edits the display_name via supabase.auth.updateUser +// (dynamic import). hoisted so the factory can reference the spy. +const { mockUpdateUser } = vi.hoisted(() => ({ mockUpdateUser: vi.fn() })); +vi.mock('../lib/supabase', () => ({ + supabase: { auth: { updateUser: mockUpdateUser } }, +})); + import { ProfilePage } from '../app/components/ProfilePage'; function renderProfile() { @@ -154,3 +161,73 @@ describe('ProfilePage — authenticated user', () => { expect(backLink).toHaveAttribute('href', '/app'); }); }); + +// ── Display name edit (THI-344) ─────────────────────────────────────────────── + +describe('ProfilePage — display name edit', () => { + beforeEach(() => { + mockUpdateUser.mockReset(); + mockUpdateUser.mockResolvedValue({ error: null }); + authState.user = { + email: 'edit@example.com', + user_metadata: { full_name: 'Old Name' }, + app_metadata: { provider: 'email' }, + }; + authState.initialized = true; + }); + + it('enters edit mode showing the current name', () => { + renderProfile(); + fireEvent.click(screen.getByRole('button', { name: /modifier le nom affiché/i })); + expect(screen.getByRole('textbox', { name: /nom affiché/i })).toHaveValue('Old Name'); + }); + + it('saves an edited display name via updateUser and exits edit mode', async () => { + renderProfile(); + fireEvent.click(screen.getByRole('button', { name: /modifier le nom affiché/i })); + fireEvent.change(screen.getByRole('textbox', { name: /nom affiché/i }), { target: { value: 'New Name' } }); + fireEvent.click(screen.getByRole('button', { name: /enregistrer/i })); + await waitFor(() => + expect(mockUpdateUser).toHaveBeenCalledWith({ data: { full_name: 'New Name' } }), + ); + await waitFor(() => expect(screen.queryByRole('textbox')).not.toBeInTheDocument()); + }); + + it('trims whitespace before saving', async () => { + renderProfile(); + fireEvent.click(screen.getByRole('button', { name: /modifier le nom affiché/i })); + fireEvent.change(screen.getByRole('textbox', { name: /nom affiché/i }), { target: { value: ' Spaced ' } }); + fireEvent.click(screen.getByRole('button', { name: /enregistrer/i })); + await waitFor(() => + expect(mockUpdateUser).toHaveBeenCalledWith({ data: { full_name: 'Spaced' } }), + ); + }); + + it('rejects an empty name without calling updateUser', async () => { + renderProfile(); + fireEvent.click(screen.getByRole('button', { name: /modifier le nom affiché/i })); + fireEvent.change(screen.getByRole('textbox', { name: /nom affiché/i }), { target: { value: ' ' } }); + fireEvent.click(screen.getByRole('button', { name: /enregistrer/i })); + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(mockUpdateUser).not.toHaveBeenCalled(); + }); + + it('cancels edit mode without calling updateUser', () => { + renderProfile(); + fireEvent.click(screen.getByRole('button', { name: /modifier le nom affiché/i })); + fireEvent.click(screen.getByRole('button', { name: /annuler/i })); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + expect(mockUpdateUser).not.toHaveBeenCalled(); + }); + + it('surfaces an error when updateUser fails', async () => { + mockUpdateUser.mockResolvedValue({ error: { message: 'Network down' } }); + renderProfile(); + fireEvent.click(screen.getByRole('button', { name: /modifier le nom affiché/i })); + fireEvent.change(screen.getByRole('textbox', { name: /nom affiché/i }), { target: { value: 'X' } }); + fireEvent.click(screen.getByRole('button', { name: /enregistrer/i })); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent(/network down/i)); + // stays in edit mode so the user can retry + expect(screen.getByRole('textbox', { name: /nom affiché/i })).toBeInTheDocument(); + }); +}); From ece75e4e303360e91bcd8de38a27e8b6a51df1e1 Mon Sep 17 00:00:00 2001 From: Thierry Date: Thu, 4 Jun 2026 19:04:18 +0200 Subject: [PATCH 2/3] fix(profile): apply ui-auditor a11y findings (THI-344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H1: link the error

via id + aria-describedby on the Input (WCAG 1.3.1/4.1.3 — screen readers announce the error in form mode). - H2: inline-edit buttons min-h-9 → min-h-11 (44px, aligns with the project's touch-target convention instead of 36px). - W2/W3: autoComplete="name" + spellCheck={false} on the name Input. Kept variant="ghost" (W1 — consistent with UserMenu, className overrides cover it). Co-Authored-By: Claude Opus 4.8 --- src/app/components/ProfilePage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/components/ProfilePage.tsx b/src/app/components/ProfilePage.tsx index 1517691b..84a99b67 100644 --- a/src/app/components/ProfilePage.tsx +++ b/src/app/components/ProfilePage.tsx @@ -192,7 +192,10 @@ function ProfilePageContent() { maxLength={50} autoFocus disabled={saving} + autoComplete="name" + spellCheck={false} aria-invalid={error ? true : undefined} + aria-describedby={error ? 'profile-display-name-error' : undefined} className="text-base" onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); @@ -200,7 +203,7 @@ function ProfilePageContent() { }} /> {error && ( -

+

)} @@ -210,7 +213,7 @@ function ProfilePageContent() { size="link-inline" onClick={handleSave} disabled={saving} - className="gap-1.5 px-3 min-h-9 rounded-md text-xs" + className="gap-1.5 px-3 min-h-11 rounded-md text-xs" >