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
70 changes: 70 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card className={comingSoon ? ADMIN_PLACEHOLDER_DIM : undefined}>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{comingSoon ? (
<ComingSoonBadge />
) : isLoading ? (
<Skeleton className="h-9 w-16" />
) : (
<div className="text-3xl font-semibold">{value ?? 0}</div>
)}
</CardContent>
</Card>
);
}

export default function AdminDashboardPage() {
const { stats, isLoading } = useAdminStats();

return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-semibold">Dashboard</h1>
<p className="text-sm text-muted-foreground">Platform overview</p>
</div>

<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Total Orgs"
icon={Building2}
value={stats?.total_orgs}
isLoading={isLoading}
/>
<StatCard
title="Total Users"
icon={Users}
value={stats?.total_users}
isLoading={isLoading}
/>
{/* Deferred features (plan.md §8 #3) — placeholders, not functional, not hidden */}
<StatCard title="Notifications Sent" icon={Bell} comingSoon />
<StatCard title="Feature Flags ON" icon={Flag} comingSoon />
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions components/__tests__/getNavItems.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
57 changes: 57 additions & 0 deletions components/admin/AdminGuard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import useSWR from 'swr';

function AdminGuardLoading() {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-black">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4" />
<p className="text-lg font-medium">Checking access...</p>
</div>
</div>
);
}

/**
* 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 <AdminGuardLoading />;
}

// Resolved and not an admin → keep showing loading while the redirect runs;
// children (the admin shell) must never render.
if (!isPlatformAdmin) {
return <AdminGuardLoading />;
}

return <>{children}</>;
}
94 changes: 94 additions & 0 deletions components/admin/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex min-h-screen bg-gray-50 dark:bg-black">
<aside className="flex w-64 shrink-0 flex-col border-r bg-white dark:bg-neutral-950">
<div className="flex items-center gap-2 px-6 py-5 border-b">
<Shield className="h-5 w-5 text-primary" />
<span className="font-semibold">Admin Portal</span>
</div>

<nav className="flex-1 space-y-1 px-3 py-4">
{ADMIN_NAV_ITEMS.map((item) => {
const isActive =
item.href === '/admin' ? pathname === '/admin' : pathname.startsWith(item.href);
const Icon = item.icon;

if (item.disabled) {
return (
<div
key={item.title}
aria-disabled="true"
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm cursor-not-allowed',
ADMIN_PLACEHOLDER_DIM
)}
>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
<ComingSoonBadge className="ml-auto" />
</div>
);
}

return (
<Link
key={item.title}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-muted'
)}
>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
);
})}
</nav>

<div className="border-t px-3 py-4">
<Link
href="/"
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-muted"
>
<ArrowLeft className="h-4 w-4" />
<span>Back to Dalgo</span>
</Link>
</div>
</aside>

<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
20 changes: 20 additions & 0 deletions components/admin/ComingSoonBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Badge variant="secondary" className={cn('font-normal', className)}>
Coming soon
</Badge>
);
}
69 changes: 69 additions & 0 deletions components/admin/__tests__/AdminGuard.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AdminGuard>
<div>admin shell</div>
</AdminGuard>
);

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(
<AdminGuard>
<div>admin shell</div>
</AdminGuard>
);

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(
<AdminGuard>
<div>admin shell</div>
</AdminGuard>
);

expect(screen.getByText('admin shell')).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
});
19 changes: 19 additions & 0 deletions components/client-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div id="client-layout-admin-route">
<NavigationTitleHandler />
<AuthGuard>
<AdminGuard>
<AdminLayout>{children}</AdminLayout>
</AdminGuard>
<Toaster richColors position="top-center" />
</AuthGuard>
</div>
);
}

// Protected routes - require authentication and include main layout
return (
<div id="client-layout-protected-route">
Expand Down
Loading