Skip to content

Commit 0cdc27f

Browse files
Give Users a directory page and rebuild the admin lists around the table
The admin dashboard was carrying the user directory as a panel beneath the stats, so the one table people actually work in was the hardest to reach and the smallest thing on screen. Users now has its own page alongside Organizations and Projects, and all three are built as list views rather than tables dropped onto a page. - New /admin/users route; the dashboard keeps only stats and analytics. - AdminListPage: title, row count and filters in one header bar, rows scrolling beneath a pinned table header, paging pinned at the bottom. The document no longer scrolls - only the rows do. - AdminDataTable gains a 'page' variant and column meta for width and alignment, so short columns stop giving their slack to Email. - Projects surfaces the creator it was already fetching and never rendering. - Table primitive takes containerClassName; its internal overflow-x wrapper is the sticky ancestor, so the scroll area has to be set there. Claude-Session: https://claude.ai/code/session_01LqxkXwhjRDsJ1N9cBYpU1n
1 parent 491a14f commit 0cdc27f

14 files changed

Lines changed: 399 additions & 250 deletions

File tree

packages/web/e2e/admin-flow.spec.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* Admin flow e2e test
33
*
4-
* Exercises the admin dashboard, user detail pages (loader pilot with
5-
* useSuspenseQuery), navigation between detail pages, and non-admin
6-
* access denial -- all in a single workflow.
4+
* Exercises the users directory, user detail pages (loader pilot with
5+
* useSuspenseQuery), navigation between detail pages, the dashboard
6+
* snapshot, and non-admin access denial -- all in a single workflow.
77
*
88
* Requires:
99
* - Dev server running: pnpm --filter web dev (localhost:3010, DEV_MODE=true)
@@ -56,7 +56,7 @@ async function loginAndGoto(
5656
await page.goto(path);
5757
}
5858

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

90-
// ── Back link navigates to dashboard ──
91-
await page.getByRole('link', { name: /Back to Admin Dashboard/ }).click();
92-
await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 });
93-
94-
// ── Dashboard stats and user table ──
95-
await expect(page.getByText('Total Users')).toBeVisible();
96-
await expect(page.getByText('Active Sessions')).toBeVisible();
97-
await expect(page.getByText('New This Week')).toBeVisible();
90+
// ── Back link navigates to the users directory ──
91+
await page.getByRole('link', { name: /Back to Users/ }).click();
92+
await expect(page.getByRole('heading', { name: 'Users', exact: true })).toBeVisible({
93+
timeout: 10_000,
94+
});
9895

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

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

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

132+
// ── Dashboard is the stats snapshot, reached from the sidebar ──
133+
await page.goto('/admin');
134+
await expect(page.getByText('Admin Dashboard')).toBeVisible({ timeout: 10_000 });
135+
await expect(page.getByText('Total Users')).toBeVisible();
136+
await expect(page.getByText('Active Sessions')).toBeVisible();
137+
await expect(page.getByText('New This Week')).toBeVisible();
138+
133139
// ── Non-admin access control ──
134140
await context.clearCookies();
135141
await loginAndGoto(page, context, scenario.regularCookies, '/admin');

packages/web/src/components/admin/UserTable.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,17 @@ interface UserTableProps {
3636
users: UserRow[];
3737
loading?: boolean;
3838
refreshing?: boolean;
39-
fillRows?: boolean;
4039
skeletonRows?: number;
40+
variant?: 'panel' | 'page';
4141
emptyState?: React.ReactNode;
4242
}
4343

4444
export function UserTable({
4545
users,
4646
loading,
4747
refreshing,
48-
fillRows,
4948
skeletonRows,
49+
variant,
5050
emptyState,
5151
}: UserTableProps) {
5252
const navigate = useNavigate();
@@ -107,6 +107,7 @@ export function UserTable({
107107
{
108108
accessorKey: 'providers',
109109
header: 'Providers',
110+
meta: { className: 'w-28' },
110111
cell: info => {
111112
const providers = info.row.original.providers || [];
112113
if (providers.length === 0) {
@@ -140,6 +141,7 @@ export function UserTable({
140141
{
141142
accessorKey: 'banned',
142143
header: 'Status',
144+
meta: { className: 'w-24' },
143145
cell: info =>
144146
info.row.original.banned ?
145147
<Badge variant='destructive'>Banned</Badge>
@@ -148,6 +150,7 @@ export function UserTable({
148150
{
149151
accessorKey: 'stripeCustomerId',
150152
header: 'Stripe customer',
153+
meta: { className: 'w-44' },
151154
cell: info => {
152155
const value = info.getValue() as string | undefined;
153156
return value ?
@@ -158,6 +161,7 @@ export function UserTable({
158161
{
159162
accessorKey: 'createdAt',
160163
header: 'Joined',
164+
meta: { className: 'w-28' },
161165
cell: info => (
162166
<span className='text-muted-foreground tabular-nums'>
163167
{formatDate(info.getValue() as string | number | null | undefined)}
@@ -174,8 +178,8 @@ export function UserTable({
174178
data={users || []}
175179
loading={loading}
176180
refreshing={refreshing}
177-
fillRows={fillRows}
178181
skeletonRows={skeletonRows}
182+
variant={variant}
179183
emptyState={emptyState ?? 'No users found'}
180184
enableSorting
181185
onRowClick={(row: UserRow) =>

packages/web/src/components/admin/ui/AdminDataTable.tsx

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ const features = tableFeatures({
3333

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

36+
/** Set on a column's `meta`. */
37+
export interface AdminColumnMeta {
38+
/** Applied to the header and body cell alike, so widths stay in step. */
39+
className?: string;
40+
align?: 'left' | 'right';
41+
}
42+
3643
interface AdminDataTableProps<T extends RowData> {
3744
columns: AdminColumnDef<T>[];
3845
data: T[];
@@ -47,8 +54,10 @@ interface AdminDataTableProps<T extends RowData> {
4754
skeletonRows?: number;
4855
/** Dims the rows in place while a new page or search result is in flight. */
4956
refreshing?: boolean;
50-
/** Pads short result sets to `skeletonRows` so the panel keeps one height. */
51-
fillRows?: boolean;
57+
/** Pads the body out to this many rows so the panel keeps one height. */
58+
fillRows?: number;
59+
/** 'page' fills the shell and scrolls under a pinned header; 'panel' sits in a card. */
60+
variant?: 'panel' | 'page';
5261
}
5362

5463
export function AdminDataTable<T extends RowData>({
@@ -61,6 +70,7 @@ export function AdminDataTable<T extends RowData>({
6170
skeletonRows = 8,
6271
refreshing,
6372
fillRows,
73+
variant = 'panel',
6474
}: AdminDataTableProps<T>) {
6575
const [sorting, setSorting] = useState<SortingState>([]);
6676

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

7686
const rows = table.getRowModel().rows;
7787

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

8096
return (
81-
<Table>
82-
<TableHeader className='bg-muted/40'>
97+
<Table className={edgeInset} containerClassName={cn(isPage && 'min-h-0 flex-1')}>
98+
<TableHeader className={cn('bg-muted/40', isPage && 'bg-background sticky top-0 z-10')}>
8399
{table.getHeaderGroups().map(headerGroup => (
84100
<TableRow key={headerGroup.id} className='border-border hover:bg-transparent'>
85101
{headerGroup.headers.map(header => {
86102
const sortable = enableSorting && header.column.getCanSort();
87103
const sorted = header.column.getIsSorted();
104+
const meta = header.column.columnDef.meta as AdminColumnMeta | undefined;
88105
return (
89106
<TableHead
90107
key={header.id}
@@ -96,12 +113,19 @@ export function AdminDataTable<T extends RowData>({
96113
}
97114
className={cn(
98115
'text-muted-foreground h-9 px-3 text-xs font-medium',
116+
isPage && 'bg-muted/40',
99117
sortable &&
100118
'hover:text-foreground cursor-pointer transition-colors select-none',
119+
meta?.className,
101120
)}
102121
onClick={sortable ? header.column.getToggleSortingHandler() : undefined}
103122
>
104-
<div className='flex items-center gap-1'>
123+
<div
124+
className={cn(
125+
'flex items-center gap-1',
126+
meta?.align === 'right' && 'justify-end',
127+
)}
128+
>
105129
{header.isPlaceholder ? null : (
106130
flexRender(header.column.columnDef.header, header.getContext())
107131
)}
@@ -129,7 +153,10 @@ export function AdminDataTable<T extends RowData>({
129153
Array.from({ length: skeletonRows }, (_, i) => (
130154
<TableRow key={`skeleton-${i}`} className='border-border hover:bg-transparent'>
131155
{columns.map((_, j) => (
132-
<TableCell key={`skeleton-cell-${j}`} className='h-12 px-3'>
156+
<TableCell
157+
key={`skeleton-cell-${j}`}
158+
className={cn('px-3', isPage ? 'h-10' : 'h-11')}
159+
>
133160
<Skeleton className='h-3.5' style={{ width: `${45 + ((j * 17) % 40)}%` }} />
134161
</TableCell>
135162
))}
@@ -141,7 +168,12 @@ export function AdminDataTable<T extends RowData>({
141168
<TableCell
142169
colSpan={columns.length || 1}
143170
className='text-muted-foreground px-3 text-center whitespace-normal'
144-
style={fillRows ? { height: skeletonRows * 48 } : { height: 160 }}
171+
style={
172+
isPage ? { height: '40vh' }
173+
: fillRows ?
174+
{ height: fillRows * 44 }
175+
: { height: 160 }
176+
}
145177
>
146178
{emptyState}
147179
</TableCell>
@@ -155,11 +187,22 @@ export function AdminDataTable<T extends RowData>({
155187
className={cn('border-border', onRowClick && 'cursor-pointer')}
156188
onClick={() => onRowClick?.(row.original)}
157189
>
158-
{row.getAllCells().map(cell => (
159-
<TableCell key={cell.id} className='text-foreground h-12 px-3 text-[13px]'>
160-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
161-
</TableCell>
162-
))}
190+
{row.getAllCells().map(cell => {
191+
const meta = cell.column.columnDef.meta as AdminColumnMeta | undefined;
192+
return (
193+
<TableCell
194+
key={cell.id}
195+
className={cn(
196+
'text-foreground px-3 text-[13px]',
197+
isPage ? 'h-10' : 'h-11',
198+
meta?.align === 'right' && 'text-right',
199+
meta?.className,
200+
)}
201+
>
202+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
203+
</TableCell>
204+
);
205+
})}
163206
</TableRow>
164207
))}
165208

@@ -169,7 +212,10 @@ export function AdminDataTable<T extends RowData>({
169212
rows.length > 0 &&
170213
Array.from({ length: fillerCount }, (_, i) => (
171214
<TableRow key={`filler-${i}`} className='border-border hover:bg-transparent'>
172-
<TableCell colSpan={columns.length || 1} className='h-12 px-3' />
215+
<TableCell
216+
colSpan={columns.length || 1}
217+
className={cn('px-3', isPage ? 'h-10' : 'h-11')}
218+
/>
173219
</TableRow>
174220
))}
175221
</TableBody>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Directory views where the table is the page: only the rows scroll, between a
2+
// header bar and pinned paging.
3+
4+
import type { ReactNode } from 'react';
5+
6+
interface AdminListPageProps {
7+
title: string;
8+
/** Total matching rows, not the number on this page. */
9+
count?: number;
10+
filters?: ReactNode;
11+
footer?: ReactNode;
12+
children: ReactNode;
13+
}
14+
15+
export function AdminListPage({ title, count, filters, footer, children }: AdminListPageProps) {
16+
return (
17+
<div className='flex min-h-0 flex-1 flex-col'>
18+
<header className='border-border flex h-13 shrink-0 items-center gap-3 border-b px-6'>
19+
<h1 className='text-foreground text-[15px] font-semibold'>{title}</h1>
20+
{count !== undefined && (
21+
<span className='text-muted-foreground text-[13px] tabular-nums'>{count}</span>
22+
)}
23+
{filters && <div className='flex min-w-0 flex-1 items-center gap-2'>{filters}</div>}
24+
</header>
25+
26+
<div className='flex min-h-0 flex-1 flex-col'>{children}</div>
27+
28+
{footer && (
29+
<div className='border-border text-muted-foreground flex h-11 shrink-0 items-center justify-between gap-3 border-t px-6 text-[13px]'>
30+
{footer}
31+
</div>
32+
)}
33+
</div>
34+
);
35+
}

packages/web/src/components/admin/ui/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
export { AdminDataTable, type AdminColumnDef } from './AdminDataTable';
1+
export { AdminDataTable, type AdminColumnDef, type AdminColumnMeta } from './AdminDataTable';
22
export { AdminEmpty, AdminError } from './AdminEmpty';
33
export { AdminField, AdminFieldGrid } from './AdminField';
4+
export { AdminListPage } from './AdminListPage';
45
export { AdminPage } from './AdminPage';
56
export { AdminPanel } from './AdminPanel';
67
export { AdminSearch } from './AdminSearch';

packages/web/src/components/layout/sidebar/AdminSidebar.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { Link, useLocation } from '@tanstack/react-router';
77
import {
88
LayoutDashboardIcon,
9+
UsersIcon,
910
BuildingIcon,
1011
FolderIcon,
1112
HardDriveIcon,
@@ -34,6 +35,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
3435
{
3536
label: 'Directory',
3637
items: [
38+
{ label: 'Users', icon: UsersIcon, path: '/admin/users' },
3739
{ label: 'Organizations', icon: BuildingIcon, path: '/admin/orgs' },
3840
{ label: 'Projects', icon: FolderIcon, path: '/admin/projects' },
3941
],
@@ -55,10 +57,9 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
5557
},
5658
];
5759

58-
// User detail pages are reached from the dashboard, so they keep it highlighted.
5960
function isItemActive(pathname: string, path: string): boolean {
6061
if (path === '/admin') {
61-
return pathname === '/admin' || pathname === '/admin/' || pathname.startsWith('/admin/users');
62+
return pathname === '/admin' || pathname === '/admin/';
6263
}
6364
return pathname === path || pathname.startsWith(`${path}/`);
6465
}

packages/web/src/components/ui/table.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import * as React from 'react';
22

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

5-
function Table({ className, ...props }: React.ComponentProps<'table'>) {
5+
function Table({
6+
className,
7+
containerClassName,
8+
...props
9+
}: React.ComponentProps<'table'> & { containerClassName?: string }) {
610
return (
7-
<div data-slot='table-container' className='relative w-full overflow-x-auto'>
11+
<div
12+
data-slot='table-container'
13+
className={cn('relative w-full overflow-x-auto', containerClassName)}
14+
>
815
<table
916
data-slot='table'
1017
className={cn('w-full caption-bottom text-sm', className)}
@@ -56,7 +63,7 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
5663
<th
5764
data-slot='table-head'
5865
className={cn(
59-
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0',
66+
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap has-[[role=checkbox]]:pr-0',
6067
className,
6168
)}
6269
{...props}
@@ -68,7 +75,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
6875
return (
6976
<td
7077
data-slot='table-cell'
71-
className={cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', className)}
78+
className={cn('p-2 align-middle whitespace-nowrap has-[[role=checkbox]]:pr-0', className)}
7279
{...props}
7380
/>
7481
);

0 commit comments

Comments
 (0)