Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 143 additions & 10 deletions src/app/components/ProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string | null>(null);

if (!user) return null; // type-narrowing for TS — never reached at runtime

Expand All @@ -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 (
<main className="flex-1 px-6 py-8 max-w-4xl mx-auto w-full">
<Helmet>
Expand All @@ -112,7 +172,7 @@ function ProfilePageContent() {

<h1 className="text-2xl font-semibold text-[var(--github-text-primary)] mb-2">Mon profil</h1>
<p className="text-sm text-[var(--github-text-secondary)] mb-8">
Informations de compte, environnement actif et paramètres. <span className="text-[var(--github-text-secondary)]/70">Édition de profil et danger zone disponibles bientôt.</span>
Informations de compte, environnement actif et paramètres. <span className="text-[var(--github-text-secondary)]/70">Export de données et suppression de compte bientôt disponibles.</span>
</p>

{/* ── Identité ──────────────────────────────────────────────────── */}
Expand All @@ -123,8 +183,76 @@ function ProfilePageContent() {
<div className="px-5 py-5 rounded-lg bg-[var(--github-border-secondary)] border border-[var(--github-border-primary)] flex items-center gap-5">
<UserAvatar avatarUrl={avatarUrl} initials={initials} size="lg" />
<div className="min-w-0 flex-1">
<p className="text-lg text-[var(--github-text-primary)] font-medium truncate">{displayName}</p>
<p className="text-sm text-[var(--github-text-secondary)] truncate font-mono">{user.email}</p>
{isEditing ? (
<div>
{/* 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. */}
<label htmlFor="profile-display-name" className="sr-only">
Nom affiché
</label>
<Input
id="profile-display-name"
value={nameValue}
onChange={(e) => 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 && (
<p id="profile-display-name-error" role="alert" className="mt-1 text-xs text-[var(--github-red)] font-mono">
{error}
</p>
)}
<div className="mt-2 flex items-center gap-2">
<Button
variant="emerald-soft"
size="link-inline"
onClick={handleSave}
disabled={saving}
className="gap-1.5 px-3 min-h-11 rounded-md text-xs"
>
<Check size={13} aria-hidden="true" />
{saving ? 'Enregistrement…' : 'Enregistrer'}
</Button>
<Button
variant="ghost"
size="link-inline"
onClick={() => setIsEditing(false)}
disabled={saving}
className="gap-1.5 px-3 min-h-11 rounded-md text-xs"
>
<X size={13} aria-hidden="true" />
Annuler
</Button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<p className="text-lg text-[var(--github-text-primary)] font-medium truncate">{displayName}</p>
<Button
variant="ghost"
size="link-inline"
onClick={startEdit}
aria-label="Modifier le nom affiché"
className="shrink-0 gap-1 px-2 min-h-11 rounded-md text-xs text-[var(--github-text-secondary)] hover:text-emerald-300"
>
<Pencil size={12} aria-hidden="true" />
Modifier
</Button>
</div>
)}
<p className="text-sm text-[var(--github-text-secondary)] truncate font-mono mt-0.5">{user.email}</p>
<div className="mt-2">
<ProviderBadge provider={typeof provider === 'string' ? provider : undefined} />
</div>
Expand All @@ -137,15 +265,20 @@ function ProfilePageContent() {
<h2 id="profile-env-heading" className="text-sm font-medium text-[var(--github-text-secondary)] font-mono uppercase tracking-wider mb-3">
Environnement actif
</h2>
<div className="px-5 py-4 rounded-lg bg-[var(--github-border-secondary)] border border-[var(--github-border-primary)] flex items-center gap-4">
<Monitor size={20} className={envMeta.color} aria-hidden="true" />
<div className="px-5 py-4 rounded-lg bg-[var(--github-border-secondary)] border border-[var(--github-border-primary)] flex items-start gap-4">
<Monitor size={20} className={`${envMeta.color} shrink-0 mt-0.5`} aria-hidden="true" />
<div className="flex-1 min-w-0">
<p className="text-sm text-[var(--github-text-primary)] font-medium">{envMeta.label}</p>
<p className="text-xs text-[var(--github-text-secondary)] font-mono">{envMeta.shell} · {envMeta.promptPreview}</p>
{/* break-words so the shell + prompt preview wrap cleanly on mobile
instead of overflowing / colliding with the hint (was a right-aligned
shrink-0 <p> that overlapped the wrapped text at 390px). */}
<p className="text-xs text-[var(--github-text-secondary)] font-mono break-words">
{envMeta.shell} · {envMeta.promptPreview}
</p>
<p className="text-xs text-[var(--github-text-secondary)]/70 font-mono mt-1">
Modifier dans la barre latérale
</p>
</div>
<p className="text-xs text-[var(--github-text-secondary)]/70 font-mono shrink-0">
Modifier dans la sidebar
</p>
</div>
</section>

Expand Down
81 changes: 79 additions & 2 deletions src/test/profilePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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();
});
});
Loading