diff --git a/src/app/components/Sidebar.tsx b/src/app/components/Sidebar.tsx index 8d695da..e3f12db 100644 --- a/src/app/components/Sidebar.tsx +++ b/src/app/components/Sidebar.tsx @@ -4,9 +4,9 @@ import { useFocusTrap } from '../../lib/hooks/useFocusTrap'; import { useUserRole } from '../../lib/hooks/useUserRole'; import { Terminal, LayoutDashboard, BookOpen, Settings, - ChevronDown, ChevronRight, CheckCircle2, Circle, X, Menu, Home, Lock, School, ShieldCheck, ShieldUser, Briefcase, LifeBuoy, Inbox, + ChevronDown, ChevronRight, CheckCircle2, Circle, X, Menu, Home, Lock, School, ShieldCheck, ShieldUser, LifeBuoy, Inbox, } from 'lucide-react'; -import { UserMenu } from './auth/UserMenu'; +import { UserMenu, type UserMenuSecondaryItem } from './auth/UserMenu'; import { useAuth } from '../context/AuthContext'; import { SupportTicketModal } from './support/SupportTicketModal'; import { curriculum } from '../data/curriculum'; @@ -75,31 +75,18 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { const canSeeInstitutionEntry = role === 'institution_admin' || role === 'super_admin'; const canSeeAdminEntry = role === 'super_admin'; - // THI-240 — "Mes outils" collapsible section. - // Sprint 2.B.3 (27 mai 2026) — refactor sidebar saturation pour super_admin - // qui voit 3 entries role-gated (Mes classes + Mon institution + Administration). - // Seuil empirique : ≥ 2 entries → collapsible (gain ~132px modules space). - // ≤ 1 entry → flat (pas de friction inutile click-to-expand pour 1 lien). - // Persist en localStorage pour préserver le state utilisateur (default = closed - // pour maximiser l'espace modules immédiat — l'utilisateur peut expand au besoin). - const roleGatedCount = - (canSeeTeacherEntry ? 1 : 0) + - (canSeeInstitutionEntry ? 1 : 0) + - (canSeeAdminEntry ? 1 : 0); - const useMesOutilsCollapsible = roleGatedCount >= 2; - const [mesOutilsOpen, setMesOutilsOpen] = useState(() => { - if (typeof window === 'undefined') return false; - return localStorage.getItem('tl-sidebar-mes-outils-open') === 'true'; - }); - const toggleMesOutils = () => { - setMesOutilsOpen((prev) => { - const next = !prev; - if (typeof window !== 'undefined') { - localStorage.setItem('tl-sidebar-mes-outils-open', String(next)); - } - return next; - }); - }; + // THI-341 — items secondaires affichés dans le menu avatar (UserMenu, bas de + // sidebar). Le role-gating reste ICI (RBAC en un seul endroit) ; UserMenu ne fait + // que rendre la liste fournie. « Aide & feedback » ouvre le modal support (onClick, + // pas une route). Désencombre la nav → les modules/leçons gagnent l'espace. + const secondaryItems: UserMenuSecondaryItem[] = [ + ...(canSeeTeacherEntry ? [{ label: 'Mes classes', to: '/app/teacher', icon: }] : []), + ...(canSeeInstitutionEntry ? [{ label: 'Mon institution', to: '/app/institution', icon: }] : []), + ...(canSeeAdminEntry ? [{ label: 'Administration', to: '/app/admin', icon: }] : []), + { label: 'Paramètres IA', to: '/app/settings', icon: }, + ...(user ? [{ label: 'Aide & feedback', onClick: () => setSupportOpen(true), icon: }] : []), + ...(user ? [{ label: 'Mes signalements', to: '/app/support', icon: }] : []), + ]; // Module actif déduit de la route leçon (/app/learn/:moduleId/:lessonId) via le // matching natif du routeur (useMatch) plutôt qu'une regex sur pathname : reste // aligné sur la définition de route et ne casse pas silencieusement si la route @@ -246,190 +233,11 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { Référence - {/* THI-240 — "Mes outils" collapsible role-gated section. - Sprint 2.B.3 (27 mai 2026, PR #309 — audit ui-auditor Sonnet upgrade). - ≥ 2 entries role-gated → collapsible (gain ~132px modules space pour - super_admin avec 3 entries). 1 entry → flat (teacher / institution_admin - voient 1 lien sans friction expand). */} - {useMesOutilsCollapsible ? ( -
- {/* ui-auditor C1 fix : réutilise Button variant=tl-sidebar-row + size=tl-sidebar-row - au lieu de raw - {mesOutilsOpen && ( - - )} -
- ) : ( - <> - {/* ≤ 1 entry role-gated → flat (pas de friction expand inutile) */} - {canSeeTeacherEntry && ( - - `flex items-center gap-2.5 min-h-11 px-3 py-2 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${ - isActive - ? 'bg-[#21262d] text-[var(--github-text-primary)]' - : 'text-[var(--github-text-secondary)] hover:bg-[var(--github-border-secondary)] hover:text-[var(--github-text-primary)]' - }` - } - > - - Mes classes - - )} - {canSeeInstitutionEntry && ( - - `flex items-center gap-2.5 min-h-11 px-3 py-2 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${ - isActive - ? 'bg-[#21262d] text-[var(--github-text-primary)]' - : 'text-[var(--github-text-secondary)] hover:bg-[var(--github-border-secondary)] hover:text-[var(--github-text-primary)]' - }` - } - > - - Mon institution - - )} - {canSeeAdminEntry && ( - - `flex items-center gap-2.5 min-h-11 px-3 py-2 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${ - isActive - ? 'bg-[#21262d] text-[var(--github-text-primary)]' - : 'text-[var(--github-text-secondary)] hover:bg-[var(--github-border-secondary)] hover:text-[var(--github-text-primary)]' - }` - } - > - - Administration - - )} - - )} - Compte & aide - - `flex items-center gap-2.5 min-h-11 px-3 py-2 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${ - isActive - ? 'bg-[#21262d] text-[var(--github-text-primary)]' - : 'text-[var(--github-text-secondary)] hover:bg-[var(--github-border-secondary)] hover:text-[var(--github-text-primary)]' - }` - } - > - - Paramètres IA - - {/* Sprint 2.C Étape 2 — support ticket trigger. Button (not NavLink) : - opens a modal, not a route. Auth-gated : the ticket RLS needs - auth.uid(), so anonymous visitors never see it. */} - {user && ( - - )} - {user && ( - - `flex items-center gap-2.5 min-h-11 px-3 py-2 rounded-lg text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 ${ - isActive - ? 'bg-[#21262d] text-[var(--github-text-primary)]' - : 'text-[var(--github-text-secondary)] hover:bg-[var(--github-border-secondary)] hover:text-[var(--github-text-primary)]' - }` - } - > - - Mes signalements - - )} + {/* THI-341 — TOUT le secondaire (Mes outils role-gated + Paramètres IA + + Aide & feedback + Mes signalements) part dans le menu avatar (UserMenu, + bas). Sidebar épurée centrée sur les modules/leçons (lisibilité apprenants). + Role-gating ICI (RBAC, un seul endroit) via `secondaryItems`. Accès invité + à Paramètres IA préservé dans le bloc « Mode invité » du UserMenu. */} {/* Modules */} @@ -550,6 +358,7 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { {/* Profile card avec actions intégrées */} ). */ + to?: string; + /** Action instead of a link (e.g. open the support modal). */ + onClick?: () => void; +} + interface UserMenuProps { syncStatus: 'local' | 'synced' | 'syncing' | 'error'; /** - * card — pleine largeur dans la sidebar (défaut) - * compact — avatar circulaire dans un header/navbar + * card — sidebar : trigger (avatar+nom+sync) → dropdown VERS LE HAUT + * compact — header/navbar : avatar rond → dropdown vers le bas */ variant?: 'card' | 'compact'; - /** Actions supplémentaires affichées à droite du header de la card (ex. Home, Install) */ - extraActions?: React.ReactNode; + /** Actions supplémentaires (ex. Home) affichées à droite du trigger card. */ + extraActions?: ReactNode; + /** Liens secondaires (déjà role-gated par l'appelant) dans le dropdown. */ + secondaryItems?: UserMenuSecondaryItem[]; } const SYNC_CONFIG: Record = { - local: { label: 'Local', dot: 'bg-[var(--github-text-secondary)]', text: 'text-[var(--github-text-secondary)]' }, - syncing: { label: 'Sync…', dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400' }, - synced: { label: 'Synchronisé', dot: 'bg-emerald-400', text: 'text-emerald-400' }, - error: { label: 'Erreur de sync', dot: 'bg-[var(--github-red)]', text: 'text-[var(--github-red)]' }, + local: { label: 'Local', dot: 'bg-[var(--github-text-secondary)]', text: 'text-[var(--github-text-secondary)]' }, + syncing: { label: 'Sync…', dot: 'bg-yellow-400 animate-pulse', text: 'text-yellow-400' }, + synced: { label: 'Synchronisé', dot: 'bg-emerald-400', text: 'text-emerald-400' }, + error: { label: 'Erreur de sync', dot: 'bg-[var(--github-red)]', text: 'text-[var(--github-red)]' }, }; // UserAvatar extracted to ./UserAvatar.tsx (THI-42 PR #1) so ProfilePage can // reuse the same OAuth avatar rendering with identical sizing semantics. -export function UserMenu({ syncStatus, variant = 'card', extraActions }: UserMenuProps) { +export function UserMenu({ syncStatus, variant = 'card', extraActions, secondaryItems = [] }: UserMenuProps) { const { user, signOut } = useAuth(); const navigate = useNavigate(); const [open, setOpen] = useState(false); @@ -35,12 +52,11 @@ export function UserMenu({ syncStatus, variant = 'card', extraActions }: UserMen const menuRef = useRef(null); const popoverRef = useRef(null); - // Trap focus inside the dropdown popover (compact variant only) - useFocusTrap(open && variant === 'compact', popoverRef); + // Both variants are now dropdown menus → trap focus + dismiss for both. + useFocusTrap(open, popoverRef); - // Fermeture Escape / clic extérieur (compact uniquement) useEffect(() => { - if (!open || variant !== 'compact') return; + if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; const onPointer = (e: PointerEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false); @@ -51,7 +67,7 @@ export function UserMenu({ syncStatus, variant = 'card', extraActions }: UserMen document.removeEventListener('keydown', onKey); document.removeEventListener('pointerdown', onPointer); }; - }, [open, variant]); + }, [open]); const handleSignOut = async () => { setOpen(false); @@ -78,6 +94,16 @@ export function UserMenu({ syncStatus, variant = 'card', extraActions }: UserMen {extraActions &&
{extraActions}
} + {/* THI-341 — accès invité à Paramètres IA (config clé IA BYOK, + anonymous-friendly) : le menu avatar n'existe pas pour un invité, donc + on expose le lien ici pour ne pas perdre l'accès en épurant la sidebar. */} + +