Session 18: Admin dashboard + Tank01 prefetch endpoint (1443 tests)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user