diff --git a/src/app/components/ProfilePage.tsx b/src/app/components/ProfilePage.tsx index ac7ed50..774f58b 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,47 @@ 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; + } + // Resolve the client + guard BEFORE setSaving(true) so the spinner only + // starts when the network call will actually happen (code-reviewer: a guard + // inside the try after setSaving is correct today but fragile to refactor). + const { supabase } = await import('../../lib/supabase'); + if (!supabase) { + setError('Service indisponible.'); + return; + } + setSaving(true); + setError(null); + try { + // 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 +172,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 +183,76 @@ function ProfilePageContent() {
-

{displayName}

-

{user.email}

+ {isEditing ? ( +
+ {/* shadcn Input ships light-mode tokens (bg-input-background / border-input) + but the app theme is GitHub-dark via CSS vars (no `dark` class), so we + override the colors to match the other fields (cf. SupportTicketModal). + twMerge keeps these over the defaults. Contrast fix — @thierry review. */} + + setNameValue(e.target.value)} + 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 bg-[var(--github-bg-secondary)] text-[var(--github-text-primary)] border-[var(--github-border-primary)] placeholder:text-[var(--github-text-secondary)] focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:border-emerald-500/40" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') setIsEditing(false); + }} + /> + {error && ( + + )} +
+ + +
+
+ ) : ( +
+

{displayName}

+ +
+ )} +

{user.email}

@@ -137,15 +265,20 @@ function ProfilePageContent() {

Environnement actif

-
-