Files
nuzlocke-tracker/frontend/src/components/Layout.tsx
Julian Tabel 7a828d7215
All checks were successful
CI / backend-tests (pull_request) Successful in 26s
CI / frontend-tests (pull_request) Successful in 28s
feat: add optional TOTP MFA for email/password accounts
- Add MFA enrollment UI in new Settings page with QR code and backup secret
- Add TOTP challenge step to login flow for enrolled users
- Check AAL after login and show TOTP input when aal2 required
- Add disable MFA option with TOTP re-verification
- Only show MFA options for email/password users (not OAuth)
- Add Settings link to user dropdown menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:56:48 +01:00

270 lines
8.6 KiB
TypeScript

import { useState, useMemo } from 'react'
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useTheme } from '../hooks/useTheme'
import { useAuth } from '../contexts/AuthContext'
function NavLink({
to,
active,
children,
onClick,
className = '',
}: {
to: string
active: boolean
children: React.ReactNode
onClick?: () => void
className?: string
}) {
return (
<Link
to={to}
onClick={onClick}
className={`${className} px-3 py-2 rounded-md text-sm font-medium transition-colors ${
active
? 'bg-accent-600/20 text-accent-300'
: 'text-text-secondary hover:text-text-primary hover:bg-surface-3'
}`}
>
{children}
</Link>
)
}
function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<button
type="button"
onClick={toggle}
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
{theme === 'dark' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
)}
</button>
)
}
function UserMenu({ onAction }: { onAction?: () => void }) {
const { user, loading, signOut } = useAuth()
const [open, setOpen] = useState(false)
const navigate = useNavigate()
if (loading) {
return <div className="w-8 h-8 rounded-full bg-surface-3 animate-pulse" />
}
if (!user) {
return (
<Link
to="/login"
onClick={onAction}
className="px-3 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
>
Sign in
</Link>
)
}
const email = user.email ?? ''
const initials = email.charAt(0).toUpperCase()
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 p-1 rounded-full hover:bg-surface-3 transition-colors"
>
<div className="w-8 h-8 rounded-full bg-accent-600 flex items-center justify-center text-white text-sm font-medium">
{initials}
</div>
</button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 mt-2 w-48 bg-surface-2 border border-border-default rounded-lg shadow-lg z-50">
<div className="px-4 py-3 border-b border-border-default">
<p className="text-sm text-text-primary truncate">{email}</p>
</div>
<div className="py-1">
<button
type="button"
onClick={() => {
setOpen(false)
onAction?.()
navigate('/settings')
}}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
>
Settings
</button>
<button
type="button"
onClick={async () => {
setOpen(false)
onAction?.()
await signOut()
}}
className="w-full text-left px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
>
Sign out
</button>
</div>
</div>
</>
)}
</div>
)
}
export function Layout() {
const [menuOpen, setMenuOpen] = useState(false)
const location = useLocation()
const { user, isAdmin } = useAuth()
const navLinks = useMemo(() => {
if (!user) {
// Logged out: Home, Runs, Genlockes, Stats
return [
{ to: '/', label: 'Home' },
{ to: '/runs', label: 'Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
]
}
// Logged in: New Run, My Runs, Genlockes, Stats
const links = [
{ to: '/runs/new', label: 'New Run' },
{ to: '/runs', label: 'My Runs' },
{ to: '/genlockes', label: 'Genlockes' },
{ to: '/stats', label: 'Stats' },
]
// Admin gets Admin link
if (isAdmin) {
links.push({ to: '/admin', label: 'Admin' })
}
return links
}, [user, isAdmin])
function isActive(to: string) {
if (to === '/' || to === '/runs/new') return location.pathname === to
return location.pathname.startsWith(to)
}
return (
<div className="min-h-screen flex flex-col bg-surface-0 text-text-primary">
<nav className="sticky top-0 z-40 bg-surface-1/80 backdrop-blur-lg border-b border-border-default">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-14">
<div className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2 group">
<img
src="/favicon.svg"
alt=""
className="w-7 h-7 transition-transform group-hover:scale-110"
/>
<span className="text-lg font-bold tracking-tight text-text-primary">ANT</span>
</Link>
</div>
{/* Desktop nav */}
<div className="hidden sm:flex items-center gap-1">
{navLinks.map((link) => (
<NavLink key={link.to} to={link.to} active={isActive(link.to)}>
{link.label}
</NavLink>
))}
<ThemeToggle />
<UserMenu />
</div>
{/* Mobile hamburger */}
<div className="flex items-center gap-1 sm:hidden">
<ThemeToggle />
<button
type="button"
onClick={() => setMenuOpen(!menuOpen)}
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
aria-label="Toggle menu"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{menuOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
</div>
</div>
</div>
{/* Mobile dropdown */}
{menuOpen && (
<div className="sm:hidden border-t border-border-default">
<div className="px-2 pt-2 pb-3 space-y-1">
{navLinks.map((link) => (
<NavLink
key={link.to}
to={link.to}
active={isActive(link.to)}
onClick={() => setMenuOpen(false)}
className="block"
>
{link.label}
</NavLink>
))}
<div className="pt-2 border-t border-border-default mt-2">
<UserMenu onAction={() => setMenuOpen(false)} />
</div>
</div>
</div>
)}
</nav>
<main className="flex-1">
<Outlet />
</main>
<footer className="border-t border-border-default bg-surface-1/50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 text-center text-xs text-text-tertiary">
Encounter data from{' '}
<a
href="https://pokedb.org"
className="underline hover:text-text-secondary transition-colors"
target="_blank"
rel="noopener noreferrer"
>
PokeDB.org
</a>
</div>
</footer>
</div>
)
}