Skip to content
Merged
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
34 changes: 20 additions & 14 deletions packages/web/e2e/admin-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Admin flow e2e test
*
* Exercises the admin dashboard, user detail pages (loader pilot with
* useSuspenseQuery), navigation between detail pages, and non-admin
* access denial -- all in a single workflow.
* Exercises the users directory, user detail pages (loader pilot with
* useSuspenseQuery), navigation between detail pages, the dashboard
* snapshot, and non-admin access denial -- all in a single workflow.
*
* Requires:
* - Dev server running: pnpm --filter web dev (localhost:3010, DEV_MODE=true)
Expand Down Expand Up @@ -56,7 +56,7 @@ async function loginAndGoto(
await page.goto(path);
}

test('Admin dashboard, user detail (loader pilot), and access control', async ({
test('Users directory, user detail (loader pilot), dashboard, and access control', async ({
page,
context,
}) => {
Expand Down Expand Up @@ -87,14 +87,11 @@ test('Admin dashboard, user detail (loader pilot), and access control', async ({
// Email-verified badge renders for seeded user
await expect(page.getByText('Verified')).toBeVisible();

// ── Back link navigates to dashboard ──
await page.getByRole('link', { name: /Back to Admin Dashboard/ }).click();
await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 });

// ── Dashboard stats and user table ──
await expect(page.getByText('Total Users')).toBeVisible();
await expect(page.getByText('Active Sessions')).toBeVisible();
await expect(page.getByText('New This Week')).toBeVisible();
// ── Back link navigates to the users directory ──
await page.getByRole('link', { name: /Back to Users/ }).click();
await expect(page.getByRole('heading', { name: 'Users', exact: true })).toBeVisible({
timeout: 10_000,
});

// ── Search for the regular user and navigate via click (client-side loader) ──
const searchInput = page.getByPlaceholder('Search by name or email...');
Expand All @@ -112,8 +109,10 @@ test('Admin dashboard, user detail (loader pilot), and access control', async ({
await expect(page.getByText('Profile Information')).toBeVisible({ timeout: 10_000 });

// ── Navigate to admin's own profile ──
await page.getByRole('link', { name: /Back to Admin Dashboard/ }).click();
await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 });
await page.getByRole('link', { name: /Back to Users/ }).click();
await expect(page.getByRole('heading', { name: 'Users', exact: true })).toBeVisible({
timeout: 10_000,
});

await searchInput.fill(scenario.admin.email);
await waitForSearchToSettle(page);
Expand All @@ -130,6 +129,13 @@ test('Admin dashboard, user detail (loader pilot), and access control', async ({
// Admin user shows the Admin badge in the profile area
await expect(page.getByRole('main').getByText('Admin', { exact: true })).toBeVisible();

// ── Dashboard is the stats snapshot, reached from the sidebar ──
await page.goto('/admin');
await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Total Users')).toBeVisible();
await expect(page.getByText('Active Sessions')).toBeVisible();
await expect(page.getByText('New This Week')).toBeVisible();

// ── Non-admin access control ──
await context.clearCookies();
await loginAndGoto(page, context, scenario.regularCookies, '/admin');
Expand Down
10 changes: 7 additions & 3 deletions packages/web/src/components/admin/UserTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,17 @@ interface UserTableProps {
users: UserRow[];
loading?: boolean;
refreshing?: boolean;
fillRows?: boolean;
skeletonRows?: number;
variant?: 'panel' | 'page';
emptyState?: React.ReactNode;
}

export function UserTable({
users,
loading,
refreshing,
fillRows,
skeletonRows,
variant,
emptyState,
}: UserTableProps) {
const navigate = useNavigate();
Expand Down Expand Up @@ -107,6 +107,7 @@ export function UserTable({
{
accessorKey: 'providers',
header: 'Providers',
meta: { className: 'w-28' },
cell: info => {
const providers = info.row.original.providers || [];
if (providers.length === 0) {
Expand Down Expand Up @@ -140,6 +141,7 @@ export function UserTable({
{
accessorKey: 'banned',
header: 'Status',
meta: { className: 'w-24' },
cell: info =>
info.row.original.banned ?
<Badge variant='destructive'>Banned</Badge>
Expand All @@ -148,6 +150,7 @@ export function UserTable({
{
accessorKey: 'stripeCustomerId',
header: 'Stripe customer',
meta: { className: 'w-44' },
cell: info => {
const value = info.getValue() as string | undefined;
return value ?
Expand All @@ -158,6 +161,7 @@ export function UserTable({
{
accessorKey: 'createdAt',
header: 'Joined',
meta: { className: 'w-28' },
cell: info => (
<span className='text-muted-foreground tabular-nums'>
{formatDate(info.getValue() as string | number | null | undefined)}
Expand All @@ -174,8 +178,8 @@ export function UserTable({
data={users || []}
loading={loading}
refreshing={refreshing}
fillRows={fillRows}
skeletonRows={skeletonRows}
variant={variant}
emptyState={emptyState ?? 'No users found'}
enableSorting
onRowClick={(row: UserRow) =>
Expand Down
74 changes: 60 additions & 14 deletions packages/web/src/components/admin/ui/AdminDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ const features = tableFeatures({

export type AdminColumnDef<T extends RowData> = ColumnDef<typeof features, T, unknown>;

/** Set on a column's `meta`. */
export interface AdminColumnMeta {
/** Applied to the header and body cell alike, so widths stay in step. */
className?: string;
align?: 'left' | 'right';
}

interface AdminDataTableProps<T extends RowData> {
columns: AdminColumnDef<T>[];
data: T[];
Expand All @@ -47,8 +54,10 @@ interface AdminDataTableProps<T extends RowData> {
skeletonRows?: number;
/** Dims the rows in place while a new page or search result is in flight. */
refreshing?: boolean;
/** Pads short result sets to `skeletonRows` so the panel keeps one height. */
fillRows?: boolean;
/** Pads the body out to this many rows so the panel keeps one height. */
fillRows?: number;
/** 'page' fills the shell and scrolls under a pinned header; 'panel' sits in a card. */
variant?: 'panel' | 'page';
}

export function AdminDataTable<T extends RowData>({
Expand All @@ -61,6 +70,7 @@ export function AdminDataTable<T extends RowData>({
skeletonRows = 8,
refreshing,
fillRows,
variant = 'panel',
}: AdminDataTableProps<T>) {
const [sorting, setSorting] = useState<SortingState>([]);

Expand All @@ -75,16 +85,23 @@ export function AdminDataTable<T extends RowData>({

const rows = table.getRowModel().rows;

const fillerCount = fillRows ? Math.max(0, skeletonRows - rows.length) : 0;
const fillerCount = fillRows ? Math.max(0, fillRows - rows.length) : 0;
const isPage = variant === 'page';
// Rows span the full width, so the edge cells carry the header bar's inset.
const edgeInset =
isPage ?
'[&_td:first-child]:pl-6 [&_th:first-child]:pl-6 [&_td:last-child]:pr-6 [&_th:last-child]:pr-6'
: '';

return (
<Table>
<TableHeader className='bg-muted/40'>
<Table className={edgeInset} containerClassName={cn(isPage && 'min-h-0 flex-1')}>
<TableHeader className={cn('bg-muted/40', isPage && 'bg-background sticky top-0 z-10')}>
{table.getHeaderGroups().map(headerGroup => (
<TableRow key={headerGroup.id} className='border-border hover:bg-transparent'>
{headerGroup.headers.map(header => {
const sortable = enableSorting && header.column.getCanSort();
const sorted = header.column.getIsSorted();
const meta = header.column.columnDef.meta as AdminColumnMeta | undefined;
return (
<TableHead
key={header.id}
Expand All @@ -96,12 +113,19 @@ export function AdminDataTable<T extends RowData>({
}
className={cn(
'text-muted-foreground h-9 px-3 text-xs font-medium',
isPage && 'bg-muted/40',
sortable &&
'hover:text-foreground cursor-pointer transition-colors select-none',
meta?.className,
)}
onClick={sortable ? header.column.getToggleSortingHandler() : undefined}
>
<div className='flex items-center gap-1'>
<div
className={cn(
'flex items-center gap-1',
meta?.align === 'right' && 'justify-end',
)}
>
{header.isPlaceholder ? null : (
flexRender(header.column.columnDef.header, header.getContext())
)}
Expand Down Expand Up @@ -129,7 +153,10 @@ export function AdminDataTable<T extends RowData>({
Array.from({ length: skeletonRows }, (_, i) => (
<TableRow key={`skeleton-${i}`} className='border-border hover:bg-transparent'>
{columns.map((_, j) => (
<TableCell key={`skeleton-cell-${j}`} className='h-12 px-3'>
<TableCell
key={`skeleton-cell-${j}`}
className={cn('px-3', isPage ? 'h-10' : 'h-11')}
>
<Skeleton className='h-3.5' style={{ width: `${45 + ((j * 17) % 40)}%` }} />
</TableCell>
))}
Expand All @@ -141,7 +168,12 @@ export function AdminDataTable<T extends RowData>({
<TableCell
colSpan={columns.length || 1}
className='text-muted-foreground px-3 text-center whitespace-normal'
style={fillRows ? { height: skeletonRows * 48 } : { height: 160 }}
style={
isPage ? { height: '40vh' }
: fillRows ?
{ height: fillRows * 44 }
: { height: 160 }
}
>
{emptyState}
</TableCell>
Expand All @@ -155,11 +187,22 @@ export function AdminDataTable<T extends RowData>({
className={cn('border-border', onRowClick && 'cursor-pointer')}
onClick={() => onRowClick?.(row.original)}
>
{row.getAllCells().map(cell => (
<TableCell key={cell.id} className='text-foreground h-12 px-3 text-[13px]'>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
{row.getAllCells().map(cell => {
const meta = cell.column.columnDef.meta as AdminColumnMeta | undefined;
return (
<TableCell
key={cell.id}
className={cn(
'text-foreground px-3 text-[13px]',
isPage ? 'h-10' : 'h-11',
meta?.align === 'right' && 'text-right',
meta?.className,
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))}

Expand All @@ -169,7 +212,10 @@ export function AdminDataTable<T extends RowData>({
rows.length > 0 &&
Array.from({ length: fillerCount }, (_, i) => (
<TableRow key={`filler-${i}`} className='border-border hover:bg-transparent'>
<TableCell colSpan={columns.length || 1} className='h-12 px-3' />
<TableCell
colSpan={columns.length || 1}
className={cn('px-3', isPage ? 'h-10' : 'h-11')}
/>
</TableRow>
))}
</TableBody>
Expand Down
35 changes: 35 additions & 0 deletions packages/web/src/components/admin/ui/AdminListPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Directory views where the table is the page: only the rows scroll, between a
// header bar and pinned paging.

import type { ReactNode } from 'react';

interface AdminListPageProps {
title: string;
/** Total matching rows, not the number on this page. */
count?: number;
filters?: ReactNode;
footer?: ReactNode;
children: ReactNode;
}

export function AdminListPage({ title, count, filters, footer, children }: AdminListPageProps) {
return (
<div className='flex min-h-0 flex-1 flex-col'>
<header className='border-border flex h-13 shrink-0 items-center gap-3 border-b px-6'>
<h1 className='text-foreground text-[15px] font-semibold'>{title}</h1>
{count !== undefined && (
<span className='text-muted-foreground text-[13px] tabular-nums'>{count}</span>
)}
{filters && <div className='flex min-w-0 flex-1 items-center gap-2'>{filters}</div>}
</header>

<div className='flex min-h-0 flex-1 flex-col'>{children}</div>

{footer && (
<div className='border-border text-muted-foreground flex h-11 shrink-0 items-center justify-between gap-3 border-t px-6 text-[13px]'>
{footer}
</div>
)}
</div>
);
}
3 changes: 2 additions & 1 deletion packages/web/src/components/admin/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { AdminDataTable, type AdminColumnDef } from './AdminDataTable';
export { AdminDataTable, type AdminColumnDef, type AdminColumnMeta } from './AdminDataTable';
export { AdminEmpty, AdminError } from './AdminEmpty';
export { AdminField, AdminFieldGrid } from './AdminField';
export { AdminListPage } from './AdminListPage';
export { AdminPage } from './AdminPage';
export { AdminPanel } from './AdminPanel';
export { AdminSearch } from './AdminSearch';
Expand Down
5 changes: 3 additions & 2 deletions packages/web/src/components/layout/sidebar/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { Link, useLocation } from '@tanstack/react-router';
import {
LayoutDashboardIcon,
UsersIcon,
BuildingIcon,
FolderIcon,
HardDriveIcon,
Expand Down Expand Up @@ -34,6 +35,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
{
label: 'Directory',
items: [
{ label: 'Users', icon: UsersIcon, path: '/admin/users' },
{ label: 'Organizations', icon: BuildingIcon, path: '/admin/orgs' },
{ label: 'Projects', icon: FolderIcon, path: '/admin/projects' },
],
Expand All @@ -55,10 +57,9 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
},
];

// User detail pages are reached from the dashboard, so they keep it highlighted.
function isItemActive(pathname: string, path: string): boolean {
if (path === '/admin') {
return pathname === '/admin' || pathname === '/admin/' || pathname.startsWith('/admin/users');
return pathname === '/admin' || pathname === '/admin/';
}
return pathname === path || pathname.startsWith(`${path}/`);
}
Expand Down
15 changes: 11 additions & 4 deletions packages/web/src/components/ui/table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@ import * as React from 'react';

import { cn } from '@/lib/utils';

function Table({ className, ...props }: React.ComponentProps<'table'>) {
function Table({
className,
containerClassName,
...props
}: React.ComponentProps<'table'> & { containerClassName?: string }) {
return (
<div data-slot='table-container' className='relative w-full overflow-x-auto'>
<div
data-slot='table-container'
className={cn('relative w-full overflow-x-auto', containerClassName)}
>
<table
data-slot='table'
className={cn('w-full caption-bottom text-sm', className)}
Expand Down Expand Up @@ -56,7 +63,7 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
<th
data-slot='table-head'
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap has-[[role=checkbox]]:pr-0',
className,
)}
{...props}
Expand All @@ -68,7 +75,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot='table-cell'
className={cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className)}
className={cn('p-2 align-middle whitespace-nowrap has-[[role=checkbox]]:pr-0', className)}
{...props}
/>
);
Expand Down
Loading
Loading