From b854b5a648bdaf15ae76dc4e4d343de4a5f716e9 Mon Sep 17 00:00:00 2001 From: veekshitha Nelluru Date: Sun, 12 Jul 2026 17:35:19 +0530 Subject: [PATCH] =?UTF-8?q?feat(admin):=20guarded=20/admin=20shell=20?= =?UTF-8?q?=E2=80=94=20sidebar,=20AdminGuard,=20dashboard=20(#1254,=20M2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin Portal v1, Milestone 2 (the /admin shell, guarded, empty) — frontend. /admin renders an admin-only sidebar shell for platform admins; non-admins never see it and are bounced. Dashboard shows real total-orgs / total-users. - components/client-layout.tsx: new /admin pathname branch -> {children}. AdminGuard sits inside AuthGuard so children only evaluate once authenticated. - components/admin/AdminGuard.tsx: client access gate. Reads is_platform_admin straight from the /currentuserv2 SWR data (shared cache key, no extra request, no store-hydration race). While that data resolves it shows a loading state — never a flash of the shell before redirecting a non-admin to '/'. - components/admin/AdminLayout.tsx: admin sidebar. Home is live; Organizations is disabled ONLY until M3 (TODO(M3) in-code); Notifications + Feature Flags are deferred features (plan.md "Later"). - components/admin/ComingSoonBadge.tsx: one shared "coming soon" placeholder (badge + dim) used by BOTH the sidebar and the dashboard cards, so the two surfaces read as one deliberate pattern rather than two half-finished features. - app/admin/page.tsx: dashboard. Total Orgs + Total Users are real (from /api/v1/admin/stats); Notifications Sent + Feature Flags ON are placeholders. - components/main-layout.tsx: "Admin Portal" nav item gated hide:!isPlatformAdmin (via useUserPermissions); getNavItems exported for unit testing. - hooks/api/useAdminPortal.ts: useAdminStats() SWR hook -> /api/v1/admin/stats. Tests (Jest + Playwright): - components/admin/__tests__/AdminGuard.test.tsx: loading -> no redirect/spinner; non-admin -> router.replace('/'); admin -> renders shell. (3) - components/__tests__/getNavItems.test.ts: Admin Portal link hidden when not a platform admin, shown when one, hidden by default. (3) - e2e/admin-portal.spec.ts: non-admin bounced from /admin (Playwright; skips without E2E_NONADMIN creds + a running server, per login.spec.ts convention). - Jest: 6 passed. tsc: no new errors. prettier: clean. Stacked on feature/admin-portal-m1-platform-admin-gate (needs M1's is_platform_admin). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/admin/page.tsx | 70 ++++++++++++++ components/__tests__/getNavItems.test.ts | 34 +++++++ components/admin/AdminGuard.tsx | 57 +++++++++++ components/admin/AdminLayout.tsx | 94 +++++++++++++++++++ components/admin/ComingSoonBadge.tsx | 20 ++++ .../admin/__tests__/AdminGuard.test.tsx | 69 ++++++++++++++ components/client-layout.tsx | 19 ++++ components/main-layout.tsx | 23 ++++- e2e/admin-portal.spec.ts | 34 +++++++ hooks/api/useAdminPortal.ts | 25 +++++ 10 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 app/admin/page.tsx create mode 100644 components/__tests__/getNavItems.test.ts create mode 100644 components/admin/AdminGuard.tsx create mode 100644 components/admin/AdminLayout.tsx create mode 100644 components/admin/ComingSoonBadge.tsx create mode 100644 components/admin/__tests__/AdminGuard.test.tsx create mode 100644 e2e/admin-portal.spec.ts create mode 100644 hooks/api/useAdminPortal.ts diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 00000000..27255377 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { Building2, Users, Bell, Flag } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAdminStats } from '@/hooks/api/useAdminPortal'; +import { ComingSoonBadge, ADMIN_PLACEHOLDER_DIM } from '@/components/admin/ComingSoonBadge'; + +function StatCard({ + title, + icon: Icon, + value, + isLoading, + comingSoon, +}: { + title: string; + icon: React.ComponentType<{ className?: string }>; + value?: number; + isLoading?: boolean; + comingSoon?: boolean; +}) { + return ( + + + {title} + + + + {comingSoon ? ( + + ) : isLoading ? ( + + ) : ( +
{value ?? 0}
+ )} +
+
+ ); +} + +export default function AdminDashboardPage() { + const { stats, isLoading } = useAdminStats(); + + return ( +
+
+

Dashboard

+

Platform overview

+
+ +
+ + + {/* Deferred features (plan.md §8 #3) — placeholders, not functional, not hidden */} + + +
+
+ ); +} diff --git a/components/__tests__/getNavItems.test.ts b/components/__tests__/getNavItems.test.ts new file mode 100644 index 00000000..33df7670 --- /dev/null +++ b/components/__tests__/getNavItems.test.ts @@ -0,0 +1,34 @@ +/** + * getNavItems tests — the "Admin Portal" nav link is gated on isPlatformAdmin. + * + * The sidebar renders items with `.filter((item) => !item.hide)`, so asserting the + * `hide` flag is the load-bearing check (mirrors the existing feature-flag pattern). + */ + +import { getNavItems } from '@/components/main-layout'; + +// getNavItems takes an isFeatureFlagEnabled fn; no flags matter for this suite. +const noFlags = () => false; + +const adminItem = (isPlatformAdmin?: boolean) => + getNavItems('/impact', false, noFlags, undefined, isPlatformAdmin).find( + (item) => item.title === 'Admin Portal' + ); + +describe('getNavItems - Admin Portal link', () => { + it('hides the Admin Portal link when the user is not a platform admin', () => { + const item = adminItem(false); + expect(item).toBeDefined(); + expect(item?.hide).toBe(true); + }); + + it('shows the Admin Portal link when the user is a platform admin', () => { + const item = adminItem(true); + expect(item).toBeDefined(); + expect(item?.hide).toBe(false); + }); + + it('defaults to hidden when isPlatformAdmin is omitted', () => { + expect(adminItem()?.hide).toBe(true); + }); +}); diff --git a/components/admin/AdminGuard.tsx b/components/admin/AdminGuard.tsx new file mode 100644 index 00000000..9661e184 --- /dev/null +++ b/components/admin/AdminGuard.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import useSWR from 'swr'; + +function AdminGuardLoading() { + return ( +
+
+
+

Checking access...

+
+
+ ); +} + +/** + * Client-side gate for the /admin section. + * + * This is UX only — the real enforcement is the backend @platform_admin_required + * guard on every /api/v1/admin/* route. Its job here is to keep a non-admin from + * ever *seeing* the admin shell. + * + * is_platform_admin is read straight from the /currentuserv2 SWR data (shared cache + * key with AuthGuard, so no extra request) rather than the auth store, to avoid a + * store-hydration race. While that data is still resolving we show a loading state — + * we must never flash the admin sidebar to someone who may not be an admin before + * redirecting them (access-control edge case, not cosmetic). + */ +export function AdminGuard({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { data, isLoading } = useSWR('/api/currentuserv2'); + + const resolving = isLoading || data === undefined; + const isPlatformAdmin = + Array.isArray(data) && data.length > 0 ? Boolean(data[0].is_platform_admin) : false; + + useEffect(() => { + if (!resolving && !isPlatformAdmin) { + router.replace('/'); + } + }, [resolving, isPlatformAdmin, router]); + + // Still resolving who the user is → loading, never a flash of the sidebar. + if (resolving) { + return ; + } + + // Resolved and not an admin → keep showing loading while the redirect runs; + // children (the admin shell) must never render. + if (!isPlatformAdmin) { + return ; + } + + return <>{children}; +} diff --git a/components/admin/AdminLayout.tsx b/components/admin/AdminLayout.tsx new file mode 100644 index 00000000..af182e19 --- /dev/null +++ b/components/admin/AdminLayout.tsx @@ -0,0 +1,94 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import { Home, Building2, Bell, Flag, ArrowLeft, Shield } from 'lucide-react'; +import { ComingSoonBadge, ADMIN_PLACEHOLDER_DIM } from '@/components/admin/ComingSoonBadge'; + +interface AdminNavItem { + title: string; + href: string; + icon: React.ComponentType<{ className?: string }>; + /** disabled items render dimmed with a "Coming soon" tag and don't navigate */ + disabled?: boolean; +} + +// Home is live in M2. Two kinds of disabled items below, deliberately distinct: +// - Organizations is TEMPORARY: it gets a real page in M3 (see TODO below). +// - Notifications + Feature Flags are deferred features (plan.md "Later") with no +// scheduled milestone yet. +const ADMIN_NAV_ITEMS: AdminNavItem[] = [ + { title: 'Home', href: '/admin', icon: Home }, + // TODO(M3): enable once the org list page (app/admin/organizations) exists — this is + // only disabled because that route isn't built yet, NOT a permanently-deferred feature. + { title: 'Organizations', href: '/admin/organizations', icon: Building2, disabled: true }, + { title: 'Notifications', href: '/admin/notifications', icon: Bell, disabled: true }, + { title: 'Feature Flags', href: '/admin/feature-flags', icon: Flag, disabled: true }, +]; + +export function AdminLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + + return ( +
+ + +
{children}
+
+ ); +} diff --git a/components/admin/ComingSoonBadge.tsx b/components/admin/ComingSoonBadge.tsx new file mode 100644 index 00000000..5a952b0e --- /dev/null +++ b/components/admin/ComingSoonBadge.tsx @@ -0,0 +1,20 @@ +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; + +/** + * Single source of truth for the admin portal's "coming soon" placeholder treatment, + * used by BOTH the sidebar (AdminLayout) and the dashboard stat cards (app/admin/page). + * Keeping one component means the two surfaces always look like the same deliberate + * pattern, not two unrelated half-finished features. + */ + +/** dimming applied to a placeholder container (sidebar row or dashboard card) */ +export const ADMIN_PLACEHOLDER_DIM = 'opacity-60'; + +export function ComingSoonBadge({ className }: { className?: string }) { + return ( + + Coming soon + + ); +} diff --git a/components/admin/__tests__/AdminGuard.test.tsx b/components/admin/__tests__/AdminGuard.test.tsx new file mode 100644 index 00000000..54630882 --- /dev/null +++ b/components/admin/__tests__/AdminGuard.test.tsx @@ -0,0 +1,69 @@ +/** + * AdminGuard tests — the client-side access-control gate for /admin. + * + * Covers the loading edge case explicitly (decision #2): while /currentuserv2 is + * still resolving, show loading and do NOT redirect or flash the shell. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AdminGuard } from '@/components/admin/AdminGuard'; +import useSWR from 'swr'; + +const mockReplace = jest.fn(); +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mockReplace }), +})); + +jest.mock('swr', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const mockedUseSWR = useSWR as unknown as jest.Mock; + +describe('AdminGuard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows loading and does not redirect while /currentuserv2 is resolving', () => { + mockedUseSWR.mockReturnValue({ data: undefined, isLoading: true }); + + render( + +
admin shell
+
+ ); + + expect(screen.queryByText('admin shell')).not.toBeInTheDocument(); + expect(screen.getByText('Checking access...')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it('redirects a non-admin to / and never renders the shell', () => { + mockedUseSWR.mockReturnValue({ data: [{ is_platform_admin: false }], isLoading: false }); + + render( + +
admin shell
+
+ ); + + expect(mockReplace).toHaveBeenCalledWith('/'); + expect(screen.queryByText('admin shell')).not.toBeInTheDocument(); + }); + + it('renders the shell for a platform admin', () => { + mockedUseSWR.mockReturnValue({ data: [{ is_platform_admin: true }], isLoading: false }); + + render( + +
admin shell
+
+ ); + + expect(screen.getByText('admin shell')).toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/components/client-layout.tsx b/components/client-layout.tsx index f5e27f5f..721185f7 100644 --- a/components/client-layout.tsx +++ b/components/client-layout.tsx @@ -3,6 +3,8 @@ import { usePathname } from 'next/navigation'; import { AuthGuard } from '@/components/auth-guard'; import { MainLayout } from '@/components/main-layout'; +import { AdminGuard } from '@/components/admin/AdminGuard'; +import { AdminLayout } from '@/components/admin/AdminLayout'; import { NavigationTitleHandler } from '@/components/navigation-title-handler'; import { Toaster } from 'sonner'; import { usePostHogIdentify } from '@/hooks/usePostHogIdentify'; @@ -50,6 +52,23 @@ export function ClientLayout({ children }: ClientLayoutProps) { ); } + // Admin portal - authenticated + platform-admin gated, its own sidebar shell. + // AdminGuard sits inside AuthGuard so children only evaluate once the user is + // authenticated; AdminGuard then keeps non-admins from ever seeing the shell. + if (pathname.startsWith('/admin')) { + return ( +
+ + + + {children} + + + +
+ ); + } + // Protected routes - require authentication and include main layout return (
diff --git a/components/main-layout.tsx b/components/main-layout.tsx index 6d225203..0c010e8b 100644 --- a/components/main-layout.tsx +++ b/components/main-layout.tsx @@ -25,6 +25,7 @@ import { CreditCard, Users, Target, + Shield, } from 'lucide-react'; import IngestIcon from '@/assets/icons/ingest'; import TransformIcon from '@/assets/icons/transform'; @@ -35,6 +36,7 @@ import OrchestrateIcon from '@/assets/icons/orchestrate'; import { Header } from './header'; import { useAuthStore } from '@/stores/authStore'; import { useFeatureFlags, FeatureFlagKeys } from '@/hooks/api/useFeatureFlags'; +import { useUserPermissions } from '@/hooks/api/usePermissions'; import { TransformTypeEnum as TransformType, useTransformType } from '@/hooks/api/useTransform'; import Image from 'next/image'; @@ -88,11 +90,12 @@ const filterMenuItemsForProduction = (items: NavItemType[]): NavItemType[] => { }; // Define the navigation items with their routes and icons -const getNavItems = ( +export const getNavItems = ( currentPath: string, hasSupersetSetup: boolean = false, isFeatureFlagEnabled: (flag: FeatureFlagKeys) => boolean, - transformType?: string + transformType?: string, + isPlatformAdmin: boolean = false ): NavItemType[] => { // Build dashboard children based on feature flags AND Superset setup const dashboardChildren: NavItemType[] = []; @@ -224,6 +227,13 @@ const getNavItems = ( : []), ], }, + { + title: 'Admin Portal', + href: '/admin', + icon: Shield, + isActive: currentPath.startsWith('/admin'), + hide: !isPlatformAdmin, + }, ]; // Filter menu items for production environment @@ -482,8 +492,15 @@ export function MainLayout({ children }: { children: React.ReactNode }) { const { currentOrg } = useAuthStore(); const { isFeatureFlagEnabled } = useFeatureFlags(); const { transformType } = useTransformType(); + const { isPlatformAdmin } = useUserPermissions(); const hasSupersetSetup = Boolean(currentOrg?.viz_url); - const navItems = getNavItems(pathname, hasSupersetSetup, isFeatureFlagEnabled, transformType); + const navItems = getNavItems( + pathname, + hasSupersetSetup, + isFeatureFlagEnabled, + transformType, + isPlatformAdmin + ); // Auto-open a parent's submenu when the current path enters its subtree. Never auto-closes. useEffect(() => { diff --git a/e2e/admin-portal.spec.ts b/e2e/admin-portal.spec.ts new file mode 100644 index 00000000..77581b3e --- /dev/null +++ b/e2e/admin-portal.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from '@playwright/test'; + +// A non-platform-admin user's credentials. Skipped when not provided, mirroring +// e2e/login.spec.ts — this test needs a real logged-in non-admin session. +const NONADMIN_EMAIL = process.env.E2E_NONADMIN_EMAIL; +const NONADMIN_PASSWORD = process.env.E2E_NONADMIN_PASSWORD; + +test.describe('Admin Portal access control', () => { + test('a non-platform-admin is bounced from /admin', async ({ page }) => { + test.skip( + !NONADMIN_EMAIL || !NONADMIN_PASSWORD, + 'Missing E2E_NONADMIN_EMAIL or E2E_NONADMIN_PASSWORD' + ); + + // Log in as a non-admin user. + await page.goto('/login'); + await page.getByLabel('Business Email*').fill(NONADMIN_EMAIL!); + await page.getByLabel('Password*').fill(NONADMIN_PASSWORD!); + await Promise.all([ + page.waitForURL('/impact', { timeout: 15000 }), + page.getByRole('button', { name: 'Sign In' }).click(), + ]); + + // The Admin Portal nav link must not be visible to a non-admin. + await expect(page.getByRole('link', { name: 'Admin Portal' })).toHaveCount(0); + + // Try to reach the admin portal directly by URL — AdminGuard must bounce us. + await page.goto('/admin'); + await expect(page).not.toHaveURL(/\/admin/, { timeout: 15000 }); + + // The admin shell (its "Admin Portal" sidebar heading) must never render. + await expect(page.getByText('Admin Portal')).toHaveCount(0); + }); +}); diff --git a/hooks/api/useAdminPortal.ts b/hooks/api/useAdminPortal.ts new file mode 100644 index 00000000..91e37460 --- /dev/null +++ b/hooks/api/useAdminPortal.ts @@ -0,0 +1,25 @@ +import useSWR from 'swr'; +import { apiGet } from '@/lib/api'; + +export interface AdminStats { + total_orgs: number; + total_users: number; +} + +/** + * Fetch platform-wide counts for the admin dashboard. + * + * Calls the cross-org admin endpoint directly (org id is not in the header for + * admin routes — the platform-admin guard authorizes it). See + * features/admin-portal/v1/plan.md §4.5. + */ +export function useAdminStats() { + const { data, error, isLoading, mutate } = useSWR('/api/v1/admin/stats', apiGet); + + return { + stats: data, + isLoading, + error, + mutate, + }; +}