feat: admin panel with user table and stats (Task 12)

- AdminPage: password gate (sessionStorage), user table, stats banner
- Auto-login using stored sessionStorage password on mount
- App.tsx: route to admin when pathname === '/admin'
- Fix: remove redundant setPassword in auto-login effect (useState already seeds from sessionStorage)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 02:23:15 +00:00
parent 10afc2bbb0
commit c9c12a4c2c
2 changed files with 170 additions and 6 deletions
+9 -6
View File
@@ -3,6 +3,7 @@ import { useAuth } from './hooks/useAuth'
import { LoginPage } from './pages/LoginPage' import { LoginPage } from './pages/LoginPage'
import { OnboardingPage } from './pages/OnboardingPage' import { OnboardingPage } from './pages/OnboardingPage'
import { GamePage } from './pages/GamePage' import { GamePage } from './pages/GamePage'
import { AdminPage } from './pages/AdminPage'
import './index.css' import './index.css'
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin' type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
@@ -12,6 +13,13 @@ export default function App() {
const [route, setRoute] = useState<AppRoute>('login') const [route, setRoute] = useState<AppRoute>('login')
const [checkingOnboarding, setCheckingOnboarding] = useState(false) const [checkingOnboarding, setCheckingOnboarding] = useState(false)
// Check for /admin path
useEffect(() => {
if (window.location.pathname === '/admin') {
setRoute('admin')
}
}, [])
// After auth resolves, check onboarding status // After auth resolves, check onboarding status
useEffect(() => { useEffect(() => {
if (!profile || !token) return if (!profile || !token) return
@@ -55,12 +63,7 @@ export default function App() {
{route === 'game' && token && ( {route === 'game' && token && (
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} /> <GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
)} )}
{route === 'admin' && ( {route === 'admin' && <AdminPage />}
<div>
<h1 style={{ fontFamily: 'var(--font-header)' }}>Admin</h1>
<p>Admin panel coming in Task 12.</p>
</div>
)}
</div> </div>
) )
} }
+161
View File
@@ -0,0 +1,161 @@
import { useState, useEffect } from 'react'
type AdminUser = {
id: number
email: string
display_name: string | null
level: number | null
score: number | null
total_correct: number | null
total_attempts: number | null
created_at: number
last_seen: number
}
type AdminStats = {
totalUsers: number
avgScore: number
mostMissed: Array<{ letter: string; misses: number }>
}
export function AdminPage() {
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_pwd') ?? '')
const [authed, setAuthed] = useState(false)
const [users, setUsers] = useState<AdminUser[] | null>(null)
const [stats, setStats] = useState<AdminStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function login(e: React.FormEvent) {
e.preventDefault()
if (!password) return
setLoading(true)
setError(null)
try {
const r = await fetch('/api/admin/users', {
headers: { Authorization: `Bearer ${password}` },
})
if (!r.ok) throw new Error('Invalid password')
sessionStorage.setItem('admin_pwd', password)
setAuthed(true)
} catch {
setError('Invalid admin password')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!authed || !password) return
const headers = { Authorization: `Bearer ${password}` }
Promise.all([
fetch('/api/admin/users', { headers }).then(r => r.json()),
fetch('/api/admin/stats', { headers }).then(r => r.json()),
]).then(([u, s]) => {
setUsers(u)
setStats(s)
}).catch(() => setError('Failed to load data'))
}, [authed, password])
// Try auto-login if stored password exists (password state already seeded by useState initializer)
useEffect(() => {
const stored = sessionStorage.getItem('admin_pwd')
if (stored) {
fetch('/api/admin/users', { headers: { Authorization: `Bearer ${stored}` } })
.then(r => { if (r.ok) setAuthed(true) })
.catch(() => {})
}
}, [])
if (!authed) {
return (
<div style={{ maxWidth: 380, margin: '4rem auto 0' }}>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '1.5rem' }}>Admin</h1>
<form onSubmit={login}>
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
Admin password
</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="Enter admin password"
style={{ marginBottom: '1rem' }}
/>
{error && <p style={{ color: 'crimson', marginBottom: '0.75rem' }}>{error}</p>}
<button type="submit" className="btn" disabled={loading || !password} style={{ width: '100%' }}>
{loading ? 'Checking...' : 'Enter'}
</button>
</form>
</div>
)
}
return (
<div>
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '1.5rem' }}>Admin Panel</h1>
{/* Stats summary */}
{stats && (
<div style={{
display: 'flex', gap: '2rem', flexWrap: 'wrap',
padding: '1rem', background: 'var(--color-forest)',
color: 'var(--color-parchment)', borderRadius: 4, marginBottom: '1.5rem',
}}>
<div><div style={{ fontSize: '1.6rem', fontWeight: 700, color: 'var(--color-gold)' }}>{stats.totalUsers}</div><div>Total users</div></div>
<div><div style={{ fontSize: '1.6rem', fontWeight: 700, color: 'var(--color-gold)' }}>{stats.avgScore}</div><div>Avg score</div></div>
<div>
<div style={{ fontSize: '0.85rem', color: 'var(--color-gold)', marginBottom: '0.25rem' }}>Most missed</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '1rem' }}>
{stats.mostMissed.slice(0, 5).map(m => `${m.letter}(${m.misses})`).join(' ')}
</div>
</div>
</div>
)}
{/* User table */}
{users ? (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
<thead>
<tr style={{ background: 'var(--color-forest)', color: 'var(--color-parchment)' }}>
{['Email', 'Name', 'Level', 'Score', 'Correct/Attempts', 'Joined', 'Last seen'].map(h => (
<th key={h} style={{ padding: '0.5rem 0.75rem', textAlign: 'left', whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((u, i) => (
<tr key={u.id} style={{ background: i % 2 === 0 ? 'white' : 'var(--color-parchment)' }}>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.email}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.display_name ?? '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.level ?? '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{u.score ?? '—'}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>
{u.total_correct ?? 0} / {u.total_attempts ?? 0}
</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{new Date(u.created_at).toLocaleDateString()}</td>
<td style={{ padding: '0.5rem 0.75rem' }}>{new Date(u.last_seen).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
{users.length === 0 && <p style={{ padding: '1rem', textAlign: 'center' }}>No users yet.</p>}
</div>
) : (
<p>Loading users...</p>
)}
<div style={{ marginTop: '2rem' }}>
<button className="btn btn-ghost" onClick={() => {
sessionStorage.removeItem('admin_pwd')
setAuthed(false)
setUsers(null)
setStats(null)
}}>
Log out
</button>
</div>
</div>
)
}