Session 18: Admin dashboard + Tank01 prefetch endpoint (1443 tests)

This commit is contained in:
Kev
2026-06-11 22:29:38 -04:00
parent beaf8b2a61
commit 0e3839a90a
9 changed files with 813 additions and 2 deletions
+268
View File
@@ -0,0 +1,268 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { isAdmin } from '@/lib/isAdmin';
/**
* /admin — internal dashboard (Session 18).
*
* Not linked from nav. Operator bookmarks the URL.
*
* Two-layer access control:
* 1. Client-side `isAdmin(user.email)` redirect — UX-only. Prevents
* a confused non-admin from seeing a 403 screen. Anyone with
* devtools can bypass this.
* 2. Server-side check in `/api/admin/stats/route.ts` — the real
* security boundary. Non-admin tokens hit a 403 before any data
* leaves Supabase.
*
* Data shape: see AdminStats interface in the API route.
*/
interface AdminStats {
generated_at: string;
users: {
total: number;
by_tier: Record<string, number>;
recent_24h: Array<{ email_masked: string; tier: string; created_at: string }>;
};
grades: {
total: number;
today: number;
};
health: {
sports: Array<{ sport: string; status: 'ok' | 'error' | 'empty'; quota?: number | null; props?: number; error?: string }>;
odds_quota_remaining: number | null;
};
notes: string[];
}
function timeAgo(iso: string): string {
if (!iso) return '';
const then = Date.parse(iso);
if (!Number.isFinite(then)) return '';
const diffSec = Math.floor((Date.now() - then) / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
function pct(part: number, whole: number): string {
if (!whole) return '0%';
return `${Math.round((part / whole) * 1000) / 10}%`;
}
const cellStyle: React.CSSProperties = {
padding: 16,
border: '1px solid var(--border, #1A1A24)',
background: 'var(--bg-surface, #12121A)',
borderRadius: 8,
};
const labelStyle: React.CSSProperties = {
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: '0.12em',
color: 'var(--text-tertiary, #6B6B7B)',
marginBottom: 6,
};
const numberStyle: React.CSSProperties = {
fontSize: 32,
fontWeight: 800,
color: 'var(--text-0, #F0F0F5)',
letterSpacing: '-0.02em',
fontVariantNumeric: 'tabular-nums',
};
export default function AdminPage() {
const router = useRouter();
const { user, session, loading: authLoading } = useAuth();
const [stats, setStats] = useState<AdminStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [fetching, setFetching] = useState(true);
// Client-side guard — UX only. Server enforces the real boundary.
useEffect(() => {
if (authLoading) return;
if (!user) {
router.replace('/login?next=/admin');
return;
}
if (!isAdmin(user.email)) {
router.replace('/dashboard');
}
}, [authLoading, user, router]);
// Stats fetch. Triggered once `session` is available; the server
// 403s if the token isn't an admin's, which we surface inline.
useEffect(() => {
if (!session || !isAdmin(user?.email ?? null)) return;
let alive = true;
(async () => {
try {
const res = await fetch('/api/admin/stats', {
method: 'GET',
headers: { Authorization: `Bearer ${session.access_token}`, Accept: 'application/json' },
cache: 'no-store',
});
const body = (await res.json().catch(() => null)) as AdminStats | { error?: string } | null;
if (!alive) return;
if (!res.ok) {
setError((body as { error?: string })?.error || `HTTP ${res.status}`);
setStats(null);
} else {
setStats(body as AdminStats);
}
} catch (err) {
if (!alive) return;
setError(err instanceof Error ? err.message : 'Fetch failed');
} finally {
if (alive) setFetching(false);
}
})();
return () => { alive = false; };
}, [session, user?.email]);
if (authLoading || !user || !isAdmin(user.email)) {
return (
<main style={{ minHeight: '60vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="mono" style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>Resolving</p>
</main>
);
}
const totalUsers = stats?.users.total ?? 0;
const byTier = stats?.users.by_tier ?? { free: 0, africa: 0, analyst: 0, desk: 0 };
const payingUsers = (byTier.africa ?? 0) + (byTier.analyst ?? 0) + (byTier.desk ?? 0);
const freeUsers = byTier.free ?? 0;
return (
<main style={{ padding: '24px 16px 80px', maxWidth: 1100, margin: '0 auto' }}>
<header style={{ marginBottom: 24 }}>
<h1 style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em' }}>Admin</h1>
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 4, letterSpacing: '0.06em', textTransform: 'uppercase' }}>
{stats?.generated_at ? `Generated ${timeAgo(stats.generated_at)}` : fetching ? 'Loading…' : 'No data'}
</p>
</header>
{error && (
<div role="alert" style={{ ...cellStyle, borderColor: 'var(--grade-d, #FF6B6B)', color: 'var(--grade-d, #FF6B6B)', marginBottom: 24, fontSize: 13 }}>
{error}
</div>
)}
{/* Section 3.1 — Key metrics */}
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12, marginBottom: 24 }}>
<div style={cellStyle}>
<div style={labelStyle}>Total Users</div>
<div style={numberStyle}>{totalUsers}</div>
</div>
<div style={cellStyle}>
<div style={labelStyle}>Paying Users</div>
<div style={{ ...numberStyle, color: 'var(--grade-a, #00D4A0)' }}>{payingUsers}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 4 }}>{pct(payingUsers, totalUsers)} of total</div>
</div>
<div style={cellStyle}>
<div style={labelStyle}>Free Users</div>
<div style={numberStyle}>{freeUsers}</div>
</div>
<div style={cellStyle}>
<div style={labelStyle}>Grades Today</div>
<div style={numberStyle}>{stats?.grades.today ?? 0}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 4 }}>{stats?.grades.total ?? 0} all-time</div>
</div>
</section>
{/* Section 3.2 — Tier breakdown */}
<section style={{ ...cellStyle, marginBottom: 24 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>Tier breakdown</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr auto', columnGap: 14, rowGap: 8, fontSize: 13 }}>
{(['free', 'africa', 'analyst', 'desk'] as const).map((tier) => {
const count = byTier[tier] ?? 0;
const p = totalUsers > 0 ? (count / totalUsers) * 100 : 0;
return (
<div key={tier} style={{ display: 'contents' }}>
<div className="mono" style={{ textTransform: 'uppercase', color: 'var(--text-secondary, #8A8A9A)' }}>{tier}</div>
<div style={{ background: 'var(--bg-2, #15151F)', height: 8, borderRadius: 4, overflow: 'hidden', alignSelf: 'center' }}>
<div style={{ width: `${Math.max(2, p)}%`, height: '100%', background: tier === 'free' ? 'var(--text-tertiary, #6B6B7B)' : 'var(--grade-a, #00D4A0)' }} />
</div>
<div className="mono" style={{ color: 'var(--text-0, #F0F0F5)', fontVariantNumeric: 'tabular-nums' }}>
{count} <span style={{ color: 'var(--text-tertiary, #6B6B7B)' }}>({pct(count, totalUsers)})</span>
</div>
</div>
);
})}
</div>
</section>
{/* Section 3.3 — Recent signups */}
<section style={{ ...cellStyle, marginBottom: 24 }}>
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>Recent signups (24h)</h2>
{!stats?.users.recent_24h?.length ? (
<p style={{ fontSize: 13, color: 'var(--text-tertiary, #6B6B7B)' }}>No signups in the last 24 hours.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--text-tertiary, #6B6B7B)', fontSize: 10, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
<th style={{ padding: '6px 0' }}>Email</th>
<th style={{ padding: '6px 0' }}>Tier</th>
<th style={{ padding: '6px 0', textAlign: 'right' }}>Signed up</th>
</tr>
</thead>
<tbody>
{stats.users.recent_24h.map((row, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--border, #1A1A24)' }}>
<td style={{ padding: '8px 0', fontFamily: 'monospace' }}>{row.email_masked}</td>
<td style={{ padding: '8px 0', color: 'var(--text-secondary, #8A8A9A)' }}>{row.tier}</td>
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color: 'var(--text-tertiary, #6B6B7B)' }}>{timeAgo(row.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{/* Section 3.4 — System health */}
<section style={cellStyle}>
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>System health</h2>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<tbody>
<tr style={{ borderBottom: '1px solid var(--border, #1A1A24)' }}>
<td style={{ padding: '8px 0', color: 'var(--text-secondary, #8A8A9A)' }}>Odds API quota remaining</td>
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color: 'var(--text-0, #F0F0F5)' }}>
{stats?.health.odds_quota_remaining ?? '—'}
</td>
</tr>
{stats?.health.sports.map((s) => {
const indicator = s.status === 'ok' ? '✅ Live'
: s.status === 'empty' ? '⚪ No props'
: `${s.error || 'error'}`;
const color = s.status === 'ok' ? 'var(--grade-a, #00D4A0)'
: s.status === 'empty' ? 'var(--text-tertiary, #6B6B7B)'
: 'var(--grade-d, #FF6B6B)';
return (
<tr key={s.sport} style={{ borderBottom: '1px solid var(--border, #1A1A24)' }}>
<td style={{ padding: '8px 0', textTransform: 'uppercase', fontSize: 11, color: 'var(--text-secondary, #8A8A9A)' }}>{s.sport}</td>
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color, fontSize: 12 }}>{indicator}{typeof s.props === 'number' ? ` · ${s.props} props` : ''}</td>
</tr>
);
})}
</tbody>
</table>
</section>
{!!stats?.notes?.length && (
<section style={{ ...cellStyle, marginTop: 24, fontSize: 12, color: 'var(--grade-c, #FFD93D)' }}>
<h2 style={{ fontSize: 12, fontWeight: 700, marginBottom: 8, letterSpacing: '-0.01em' }}>Query notes</h2>
<ul style={{ paddingLeft: 18, display: 'grid', gap: 4 }}>
{stats.notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
</section>
)}
</main>
);
}