Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/app/golf/actions/admin-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/app/golf/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading