From 5d2da48a2c785bf167f01523095457e15fd2066e Mon Sep 17 00:00:00 2001 From: Guido X Jansen Date: Mon, 2 Mar 2026 23:24:42 +0100 Subject: [PATCH] fix(admin): auth guard and API client bugs on admin pages (#118) * fix(admin): pass auth token to getCommunitySettings and fix reorder body getCommunitySettings() was calling GET /api/admin/settings (which requires admin auth) without sending an Authorization header, causing 401 on /admin/settings and /admin/content-ratings. Added accessToken parameter and updated both call sites. Also fixed reorderOnboardingFields() sending { fields: [...] } when the backend expects a bare array. * fix(admin): add auth guard layout to prevent race condition All admin pages fired API calls on mount before AuthProvider finished its silent refresh, sending requests with null tokens and getting 401s. Added app/admin/layout.tsx that gates all /admin/* pages on auth state: shows loading skeleton while refreshing, redirects to /login if not authenticated, redirects to / if not admin. Child pages only mount once the access token is available. Updated content-ratings test to mock useAuth after the auth token fix. --- src/app/admin/content-ratings/page.test.tsx | 19 ++++++++ src/app/admin/content-ratings/page.tsx | 9 +++- src/app/admin/layout.tsx | 48 +++++++++++++++++++++ src/app/admin/settings/page.tsx | 2 +- src/lib/api/client.ts | 9 ++-- 5 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 src/app/admin/layout.tsx diff --git a/src/app/admin/content-ratings/page.test.tsx b/src/app/admin/content-ratings/page.test.tsx index 2419c5e..1acf896 100644 --- a/src/app/admin/content-ratings/page.test.tsx +++ b/src/app/admin/content-ratings/page.test.tsx @@ -31,6 +31,25 @@ vi.mock('next/image', () => ({ }, })) +vi.mock('@/hooks/use-auth', () => { + const mockAuth = { + user: { + did: 'did:plc:user-jay-001', + handle: 'jay.bsky.team', + displayName: 'Jay', + avatarUrl: null, + }, + isAuthenticated: true, + isLoading: false, + getAccessToken: () => 'mock-access-token', + login: vi.fn(), + logout: vi.fn(), + setSessionFromCallback: vi.fn(), + authFetch: vi.fn(), + } + return { useAuth: () => mockAuth } +}) + describe('AdminContentRatingsPage', () => { it('renders content ratings heading', () => { render() diff --git a/src/app/admin/content-ratings/page.tsx b/src/app/admin/content-ratings/page.tsx index a7fbdca..a41e425 100644 --- a/src/app/admin/content-ratings/page.tsx +++ b/src/app/admin/content-ratings/page.tsx @@ -13,6 +13,7 @@ import { ErrorAlert } from '@/components/error-alert' import { getCategories, getCommunitySettings } from '@/lib/api/client' import { cn } from '@/lib/utils' import type { CategoryTreeNode, CommunitySettings, MaturityRating } from '@/lib/api/types' +import { useAuth } from '@/hooks/use-auth' const MATURITY_DESCRIPTIONS: Record = { safe: 'Suitable for all audiences. No explicit or mature content.', @@ -52,6 +53,7 @@ function CategoryRatingRow({ category, depth }: { category: CategoryTreeNode; de } export default function AdminContentRatingsPage() { + const { getAccessToken } = useAuth() const [categories, setCategories] = useState([]) const [communitySettings, setCommunitySettings] = useState(null) const [loading, setLoading] = useState(true) @@ -60,7 +62,10 @@ export default function AdminContentRatingsPage() { const fetchData = useCallback(async () => { setError(null) try { - const [catRes, settingsRes] = await Promise.all([getCategories(), getCommunitySettings()]) + const [catRes, settingsRes] = await Promise.all([ + getCategories(), + getCommunitySettings(getAccessToken() ?? ''), + ]) setCategories(catRes.categories) setCommunitySettings(settingsRes) } catch { @@ -68,7 +73,7 @@ export default function AdminContentRatingsPage() { } finally { setLoading(false) } - }, []) + }, [getAccessToken]) useEffect(() => { void fetchData() diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..0ceb9c8 --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,48 @@ +/** + * Admin route layout. + * Gates all /admin/* pages on authentication + admin role. + * Shows loading skeleton while auth state initializes, + * redirects non-admin users to the homepage. + * @see specs/prd-web.md Section M11 + */ + +'use client' + +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useAuth } from '@/hooks/use-auth' + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + const { user, isAuthenticated, isLoading } = useAuth() + const router = useRouter() + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace(`/login?returnTo=${encodeURIComponent('/admin')}`) + } + }, [isLoading, isAuthenticated, router]) + + useEffect(() => { + if (!isLoading && isAuthenticated && user?.role !== 'admin') { + router.replace('/') + } + }, [isLoading, isAuthenticated, user?.role, router]) + + if (isLoading) { + return ( +
+
+
+
+
+ Loading admin panel +
+ ) + } + + if (!isAuthenticated || user?.role !== 'admin') { + return null + } + + return <>{children} +} diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index e4473fc..a2d501e 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -35,7 +35,7 @@ export default function AdminSettingsPage() { setLoadError(null) try { const [settingsData, pdsData] = await Promise.all([ - getCommunitySettings(), + getCommunitySettings(getAccessToken() ?? ''), getPdsTrustFactors(getAccessToken() ?? ''), ]) setSettings(settingsData) diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 53e821e..6bb3046 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -317,10 +317,13 @@ export function markNotificationsRead( // --- Community endpoints --- -export function getCommunitySettings(options?: FetchOptions): Promise { +export function getCommunitySettings( + accessToken: string, + options?: FetchOptions +): Promise { return apiFetch('/api/admin/settings', { ...options, - headers: { ...options?.headers }, + headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, }) } @@ -787,7 +790,7 @@ export function reorderOnboardingFields( ...options, method: 'PUT', headers: { ...options?.headers, Authorization: `Bearer ${accessToken}` }, - body: { fields }, + body: fields, }) } -- 2.51.2