337 lines
15 KiB
TypeScript
337 lines
15 KiB
TypeScript
'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;
|
|
};
|
|
provider_quotas?: Array<{
|
|
provider: string;
|
|
name?: string;
|
|
used: number;
|
|
limit: number;
|
|
remaining: number;
|
|
pct: number;
|
|
period: string;
|
|
quotaType: string;
|
|
allowed: boolean;
|
|
degraded?: boolean;
|
|
}>;
|
|
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>
|
|
|
|
{/* Session 20 — Provider Quotas. Pulled from
|
|
/api/internal/quota; rendered as a per-provider table with
|
|
a usage bar + status indicator. When the array is empty,
|
|
the section auto-hides (likely VYNDR_INTERNAL_KEY unset
|
|
on the Next.js side — surfaced in the notes section). */}
|
|
{!!stats?.provider_quotas?.length && (
|
|
<section style={{ ...cellStyle, marginTop: 24 }}>
|
|
<h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 14, letterSpacing: '-0.01em' }}>Provider quotas</h2>
|
|
<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' }}>Provider</th>
|
|
<th style={{ padding: '6px 0' }}>Used</th>
|
|
<th style={{ padding: '6px 0' }}>Type</th>
|
|
<th style={{ padding: '6px 0', textAlign: 'right' }}>Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{stats.provider_quotas.map((p) => {
|
|
const pctNum = Math.round((p.pct || 0) * 100);
|
|
const color = !p.allowed
|
|
? 'var(--grade-d, #FF6B6B)'
|
|
: pctNum >= 80
|
|
? 'var(--grade-c, #FFD93D)'
|
|
: 'var(--grade-a, #00D4A0)';
|
|
const indicator = !p.allowed
|
|
? `❌ BLOCKED ${pctNum}%`
|
|
: pctNum >= 80
|
|
? `⚠️ ${pctNum}%`
|
|
: `✅ ${pctNum}%`;
|
|
return (
|
|
<tr key={p.provider} style={{ borderBottom: '1px solid var(--border, #1A1A24)' }}>
|
|
<td style={{ padding: '8px 0', color: 'var(--text-0, #F0F0F5)' }}>
|
|
<div style={{ fontWeight: 600 }}>{p.name || p.provider}</div>
|
|
<div className="mono" style={{ fontSize: 10, color: 'var(--text-tertiary, #6B6B7B)', marginTop: 2 }}>{p.period}</div>
|
|
</td>
|
|
<td className="mono" style={{ padding: '8px 0', color: 'var(--text-0, #F0F0F5)', fontVariantNumeric: 'tabular-nums' }}>
|
|
<div>{p.used}/{p.limit}</div>
|
|
<div style={{ marginTop: 4, background: 'var(--bg-2, #15151F)', height: 4, borderRadius: 2, overflow: 'hidden', width: 90 }}>
|
|
<div style={{ width: `${Math.min(100, Math.max(2, pctNum))}%`, height: '100%', background: color }} />
|
|
</div>
|
|
</td>
|
|
<td className="mono" style={{ padding: '8px 0', color: 'var(--text-secondary, #8A8A9A)', fontSize: 11, textTransform: 'uppercase' }}>
|
|
{p.quotaType}
|
|
</td>
|
|
<td className="mono" style={{ padding: '8px 0', textAlign: 'right', color, fontSize: 12 }}>
|
|
{indicator}{p.degraded ? ' (degraded)' : ''}
|
|
</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>
|
|
);
|
|
}
|