From c9c12a4c2c9fb1fc4c0d9f8aaa71ff72c34a6054 Mon Sep 17 00:00:00 2001 From: kitadmin Date: Thu, 30 Apr 2026 02:23:15 +0000 Subject: [PATCH] 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 --- client/src/App.tsx | 15 +-- client/src/pages/AdminPage.tsx | 161 +++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/AdminPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index b5019d1..78a6cc7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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('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 && ( { logout(); setRoute('login') }} /> )} - {route === 'admin' && ( -
-

Admin

-

Admin panel coming in Task 12.

-
- )} + {route === 'admin' && } ) } diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx new file mode 100644 index 0000000..86aeb17 --- /dev/null +++ b/client/src/pages/AdminPage.tsx @@ -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(null) + const [stats, setStats] = useState(null) + const [error, setError] = useState(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 ( +
+

Admin

+
+ + setPassword(e.target.value)} + placeholder="Enter admin password" + style={{ marginBottom: '1rem' }} + /> + {error &&

{error}

} + +
+
+ ) + } + + return ( +
+

Admin Panel

+ + {/* Stats summary */} + {stats && ( +
+
{stats.totalUsers}
Total users
+
{stats.avgScore}
Avg score
+
+
Most missed
+
+ {stats.mostMissed.slice(0, 5).map(m => `${m.letter}(${m.misses})`).join(' ')} +
+
+
+ )} + + {/* User table */} + {users ? ( +
+ + + + {['Email', 'Name', 'Level', 'Score', 'Correct/Attempts', 'Joined', 'Last seen'].map(h => ( + + ))} + + + + {users.map((u, i) => ( + + + + + + + + + + ))} + +
{h}
{u.email}{u.display_name ?? '—'}{u.level ?? '—'}{u.score ?? '—'} + {u.total_correct ?? 0} / {u.total_attempts ?? 0} + {new Date(u.created_at).toLocaleDateString()}{new Date(u.last_seen).toLocaleDateString()}
+ {users.length === 0 &&

No users yet.

} +
+ ) : ( +

Loading users...

+ )} + +
+ +
+
+ ) +}