diff --git a/src/app/golf/actions/admin-data.ts b/src/app/golf/actions/admin-data.ts index 1d4ebbb9d..86c118af3 100644 --- a/src/app/golf/actions/admin-data.ts +++ b/src/app/golf/actions/admin-data.ts @@ -82,6 +82,43 @@ export interface AdminDashboardRollup { signup_trend_30d: { date: string; count: number }[]; } +/** + * Non-throwing admin access probe. + * + * Returns the caller's authority to load admin data WITHOUT 500ing on the + * unauth path. The page polls every 5 min while open; if the layout's SSR + * guard ever lets a non-admin tab through (stale role row, session + * downgraded mid-tab, etc.) the throw-based actions flood prod runtime + * logs at ~576 errors/day. The dashboard gates on this check first and + * stops polling cleanly when access drops — no 500. + */ +export async function checkAdminAccess(): Promise<{ + allowed: boolean; + reason?: 'unauthenticated' | 'forbidden'; +}> { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return { allowed: false, reason: 'unauthenticated' }; + + const { data: userRow, error: userErr } = await supabase + .from('users') + .select('role') + .eq('id', user.id) + .single(); + if (userErr) { + // A transient DB error is not an auth denial — re-throw so the caller + // surfaces it as a retriable error rather than a permanent session + // expiry. Collapsing it into `forbidden` would trip the client's + // /\bForbidden\b/ guard and tear the polling timer down for a real + // admin who just hit a Supabase hiccup. + throw userErr instanceof Error ? userErr : new Error(String(userErr)); + } + if (userRow?.role !== 'admin') { + return { allowed: false, reason: 'forbidden' }; + } + return { allowed: true }; +} + /** Server-side entrypoint: admin check, then one RPC round-trip via the * user-scoped client so the SECURITY DEFINER `auth.uid()` gate inside * `get_admin_dashboard_rollup` resolves to the invoking admin (and not diff --git a/src/app/golf/admin/page.tsx b/src/app/golf/admin/page.tsx index f6a12af15..f05a20e2b 100644 --- a/src/app/golf/admin/page.tsx +++ b/src/app/golf/admin/page.tsx @@ -8,6 +8,7 @@ import Link from 'next/link'; import { createClient } from '@/lib/supabase/client'; import { clearActiveTeam } from '@/app/golf/actions/team-switcher'; import { + checkAdminAccess, getAdminDashboardData, getAdminDashboardRollup, } from '@/app/golf/actions/admin-data'; @@ -266,6 +267,16 @@ function AdminDashboardContent() { if (!silent) setLoading(true); else setIsRefreshing(true); try { + // Non-throwing access probe first. The SSR layout guards a hard + // navigation, but a tab kept open through a role/session change can + // still reach this code path — and the data actions 500 on auth + // failure, flooding runtime logs (576+ entries/day). Stop here on + // any non-allowed result and let the catch block freeze polling. + const access = await checkAdminAccess(); + if (!access.allowed) { + throw new Error(access.reason === 'unauthenticated' ? 'Unauthorized' : 'Forbidden'); + } + // Fetch main data, rollup, and CRM data in parallel. // The rollup is 1 RPC round-trip, cached + tag-invalidated; the full // legacy fetch is ~95 queries and stays until every tab is migrated. @@ -308,8 +319,11 @@ function AdminDashboardContent() { // If the error is auth-related, stop polling and show session expired UI. // Flipping sessionExpired=true passes null to useVisibilityAwareInterval - // below, which tears the timer down. - if (message === 'Unauthorized' || message === 'Forbidden') { + // below, which tears the timer down. Match wrapped messages too — the + // inner rollup helpers re-throw with a `rollupA failed: Forbidden` + // prefix, which the prior exact-equality check missed and let polling + // run forever after a real-admin RLS denial. + if (/\b(Unauthorized|Forbidden)\b/.test(message)) { setSessionExpired(true); if (!silent) { setError(message);