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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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