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:
+9
-6
@@ -3,6 +3,7 @@ import { useAuth } from './hooks/useAuth'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { OnboardingPage } from './pages/OnboardingPage'
|
||||
import { GamePage } from './pages/GamePage'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
import './index.css'
|
||||
|
||||
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
||||
@@ -12,6 +13,13 @@ export default function App() {
|
||||
const [route, setRoute] = useState<AppRoute>('login')
|
||||
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
|
||||
|
||||
// Check for /admin path
|
||||
useEffect(() => {
|
||||
if (window.location.pathname === '/admin') {
|
||||
setRoute('admin')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// After auth resolves, check onboarding status
|
||||
useEffect(() => {
|
||||
if (!profile || !token) return
|
||||
@@ -55,12 +63,7 @@ export default function App() {
|
||||
{route === 'game' && token && (
|
||||
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
|
||||
)}
|
||||
{route === 'admin' && (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-header)' }}>Admin</h1>
|
||||
<p>Admin panel coming in Task 12.</p>
|
||||
</div>
|
||||
)}
|
||||
{route === 'admin' && <AdminPage />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user