diff --git a/packages/web/e2e/admin-flow.spec.ts b/packages/web/e2e/admin-flow.spec.ts index f0970e97..97900da1 100644 --- a/packages/web/e2e/admin-flow.spec.ts +++ b/packages/web/e2e/admin-flow.spec.ts @@ -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) @@ -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, }) => { @@ -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...'); @@ -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); @@ -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'); diff --git a/packages/web/src/components/admin/UserTable.tsx b/packages/web/src/components/admin/UserTable.tsx index 29eec2a5..47bbd586 100644 --- a/packages/web/src/components/admin/UserTable.tsx +++ b/packages/web/src/components/admin/UserTable.tsx @@ -36,8 +36,8 @@ interface UserTableProps { users: UserRow[]; loading?: boolean; refreshing?: boolean; - fillRows?: boolean; skeletonRows?: number; + variant?: 'panel' | 'page'; emptyState?: React.ReactNode; } @@ -45,8 +45,8 @@ export function UserTable({ users, loading, refreshing, - fillRows, skeletonRows, + variant, emptyState, }: UserTableProps) { const navigate = useNavigate(); @@ -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) { @@ -140,6 +141,7 @@ export function UserTable({ { accessorKey: 'banned', header: 'Status', + meta: { className: 'w-24' }, cell: info => info.row.original.banned ? Banned @@ -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 ? @@ -158,6 +161,7 @@ export function UserTable({ { accessorKey: 'createdAt', header: 'Joined', + meta: { className: 'w-28' }, cell: info => ( {formatDate(info.getValue() as string | number | null | undefined)} @@ -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) => diff --git a/packages/web/src/components/admin/ui/AdminDataTable.tsx b/packages/web/src/components/admin/ui/AdminDataTable.tsx index 36d5e683..27997207 100644 --- a/packages/web/src/components/admin/ui/AdminDataTable.tsx +++ b/packages/web/src/components/admin/ui/AdminDataTable.tsx @@ -33,6 +33,13 @@ const features = tableFeatures({ export type AdminColumnDef = ColumnDef; +/** 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 { columns: AdminColumnDef[]; data: T[]; @@ -47,8 +54,10 @@ interface AdminDataTableProps { 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({ @@ -61,6 +70,7 @@ export function AdminDataTable({ skeletonRows = 8, refreshing, fillRows, + variant = 'panel', }: AdminDataTableProps) { const [sorting, setSorting] = useState([]); @@ -75,16 +85,23 @@ export function AdminDataTable({ 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.getHeaderGroups().map(headerGroup => ( {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 ( ({ } 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} > -
+
{header.isPlaceholder ? null : ( flexRender(header.column.columnDef.header, header.getContext()) )} @@ -129,7 +153,10 @@ export function AdminDataTable({ Array.from({ length: skeletonRows }, (_, i) => ( {columns.map((_, j) => ( - + ))} @@ -141,7 +168,12 @@ export function AdminDataTable({ {emptyState} @@ -155,11 +187,22 @@ export function AdminDataTable({ className={cn('border-border', onRowClick && 'cursor-pointer')} onClick={() => onRowClick?.(row.original)} > - {row.getAllCells().map(cell => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} + {row.getAllCells().map(cell => { + const meta = cell.column.columnDef.meta as AdminColumnMeta | undefined; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} ))} @@ -169,7 +212,10 @@ export function AdminDataTable({ rows.length > 0 && Array.from({ length: fillerCount }, (_, i) => ( - + ))} diff --git a/packages/web/src/components/admin/ui/AdminListPage.tsx b/packages/web/src/components/admin/ui/AdminListPage.tsx new file mode 100644 index 00000000..4ef3ded7 --- /dev/null +++ b/packages/web/src/components/admin/ui/AdminListPage.tsx @@ -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 ( +
+
+

{title}

+ {count !== undefined && ( + {count} + )} + {filters &&
{filters}
} +
+ +
{children}
+ + {footer && ( +
+ {footer} +
+ )} +
+ ); +} diff --git a/packages/web/src/components/admin/ui/index.ts b/packages/web/src/components/admin/ui/index.ts index e5cda92e..bc55ac05 100644 --- a/packages/web/src/components/admin/ui/index.ts +++ b/packages/web/src/components/admin/ui/index.ts @@ -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'; diff --git a/packages/web/src/components/layout/sidebar/AdminSidebar.tsx b/packages/web/src/components/layout/sidebar/AdminSidebar.tsx index d7652b26..e7eaccf6 100644 --- a/packages/web/src/components/layout/sidebar/AdminSidebar.tsx +++ b/packages/web/src/components/layout/sidebar/AdminSidebar.tsx @@ -6,6 +6,7 @@ import { Link, useLocation } from '@tanstack/react-router'; import { LayoutDashboardIcon, + UsersIcon, BuildingIcon, FolderIcon, HardDriveIcon, @@ -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' }, ], @@ -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}/`); } diff --git a/packages/web/src/components/ui/table.tsx b/packages/web/src/components/ui/table.tsx index b5a90f72..df198d9f 100644 --- a/packages/web/src/components/ui/table.tsx +++ b/packages/web/src/components/ui/table.tsx @@ -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 ( -
+
) {
) { return ( ); diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 936d94bd..cd355160 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -76,6 +76,7 @@ import { Route as AppProtectedAdminOrgsIndexRouteImport } from './routes/_app/_p import { Route as AppProtectedAdminOrgsOrgIdRouteImport } from './routes/_app/_protected/admin/orgs.$orgId' import { Route as AppProtectedAdminProjectsIndexRouteImport } from './routes/_app/_protected/admin/projects.index' import { Route as AppProtectedAdminProjectsProjectIdRouteImport } from './routes/_app/_protected/admin/projects.$projectId' +import { Route as AppProtectedAdminUsersIndexRouteImport } from './routes/_app/_protected/admin/users.index' import { Route as AppProtectedAdminUsersUserIdRouteImport } from './routes/_app/_protected/admin/users.$userId' import { Route as AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRouteImport } from './routes/_app/_protected/projects.$projectId/studies.$studyId.checklists.$checklistId' import { Route as ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteImport } from './routes/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' @@ -433,6 +434,12 @@ const AppProtectedAdminProjectsProjectIdRoute = path: '/projects/$projectId', getParentRoute: () => AppProtectedAdminRoute, } as any) +const AppProtectedAdminUsersIndexRoute = + AppProtectedAdminUsersIndexRouteImport.update({ + id: '/users/', + path: '/users/', + getParentRoute: () => AppProtectedAdminRoute, + } as any) const AppProtectedAdminUsersUserIdRoute = AppProtectedAdminUsersUserIdRouteImport.update({ id: '/users/$userId', @@ -534,6 +541,7 @@ export interface FileRoutesByFullPath { '/admin/users/$userId': typeof AppProtectedAdminUsersUserIdRoute '/admin/orgs/': typeof AppProtectedAdminOrgsIndexRoute '/admin/projects/': typeof AppProtectedAdminProjectsIndexRoute + '/admin/users/': typeof AppProtectedAdminUsersIndexRoute '/projects/$projectId/studies/$studyId/checklists/$checklistId': typeof AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRoute '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs': typeof ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteWithChildren '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id': typeof AppProtectedProjectsProjectIdStudiesStudyIdReconcileChecklist1IdChecklist2IdRoute @@ -602,6 +610,7 @@ export interface FileRoutesByTo { '/admin/users/$userId': typeof AppProtectedAdminUsersUserIdRoute '/admin/orgs': typeof AppProtectedAdminOrgsIndexRoute '/admin/projects': typeof AppProtectedAdminProjectsIndexRoute + '/admin/users': typeof AppProtectedAdminUsersIndexRoute '/projects/$projectId/studies/$studyId/checklists/$checklistId': typeof AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRoute '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs': typeof ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteWithChildren '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id': typeof AppProtectedProjectsProjectIdStudiesStudyIdReconcileChecklist1IdChecklist2IdRoute @@ -677,6 +686,7 @@ export interface FileRoutesById { '/_app/_protected/admin/users/$userId': typeof AppProtectedAdminUsersUserIdRoute '/_app/_protected/admin/orgs/': typeof AppProtectedAdminOrgsIndexRoute '/_app/_protected/admin/projects/': typeof AppProtectedAdminProjectsIndexRoute + '/_app/_protected/admin/users/': typeof AppProtectedAdminUsersIndexRoute '/_app/_protected/projects/$projectId/studies/$studyId/checklists/$checklistId': typeof AppProtectedProjectsProjectIdStudiesStudyIdChecklistsChecklistIdRoute '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs': typeof ApiOrgsOrgIdProjectsProjectIdStudiesStudyIdPdfsRouteWithChildren '/_app/_protected/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id': typeof AppProtectedProjectsProjectIdStudiesStudyIdReconcileChecklist1IdChecklist2IdRoute @@ -750,6 +760,7 @@ export interface FileRouteTypes { | '/admin/users/$userId' | '/admin/orgs/' | '/admin/projects/' + | '/admin/users/' | '/projects/$projectId/studies/$studyId/checklists/$checklistId' | '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' | '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id' @@ -818,6 +829,7 @@ export interface FileRouteTypes { | '/admin/users/$userId' | '/admin/orgs' | '/admin/projects' + | '/admin/users' | '/projects/$projectId/studies/$studyId/checklists/$checklistId' | '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' | '/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id' @@ -892,6 +904,7 @@ export interface FileRouteTypes { | '/_app/_protected/admin/users/$userId' | '/_app/_protected/admin/orgs/' | '/_app/_protected/admin/projects/' + | '/_app/_protected/admin/users/' | '/_app/_protected/projects/$projectId/studies/$studyId/checklists/$checklistId' | '/api/orgs/$orgId/projects/$projectId/studies/$studyId/pdfs' | '/_app/_protected/projects/$projectId/studies/$studyId/reconcile/$checklist1Id/$checklist2Id' @@ -1408,6 +1421,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppProtectedAdminProjectsProjectIdRouteImport parentRoute: typeof AppProtectedAdminRoute } + '/_app/_protected/admin/users/': { + id: '/_app/_protected/admin/users/' + path: '/users' + fullPath: '/admin/users/' + preLoaderRoute: typeof AppProtectedAdminUsersIndexRouteImport + parentRoute: typeof AppProtectedAdminRoute + } '/_app/_protected/admin/users/$userId': { id: '/_app/_protected/admin/users/$userId' path: '/users/$userId' @@ -1458,6 +1478,7 @@ interface AppProtectedAdminRouteChildren { AppProtectedAdminUsersUserIdRoute: typeof AppProtectedAdminUsersUserIdRoute AppProtectedAdminOrgsIndexRoute: typeof AppProtectedAdminOrgsIndexRoute AppProtectedAdminProjectsIndexRoute: typeof AppProtectedAdminProjectsIndexRoute + AppProtectedAdminUsersIndexRoute: typeof AppProtectedAdminUsersIndexRoute } const AppProtectedAdminRouteChildren: AppProtectedAdminRouteChildren = { @@ -1475,6 +1496,7 @@ const AppProtectedAdminRouteChildren: AppProtectedAdminRouteChildren = { AppProtectedAdminUsersUserIdRoute: AppProtectedAdminUsersUserIdRoute, AppProtectedAdminOrgsIndexRoute: AppProtectedAdminOrgsIndexRoute, AppProtectedAdminProjectsIndexRoute: AppProtectedAdminProjectsIndexRoute, + AppProtectedAdminUsersIndexRoute: AppProtectedAdminUsersIndexRoute, } const AppProtectedAdminRouteWithChildren = diff --git a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx index da79b728..10297b56 100644 --- a/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx +++ b/packages/web/src/routes/_app/_protected/admin/billing.ledger.tsx @@ -379,7 +379,7 @@ function AdminBillingLedgerPage() { data={entries} loading={ledgerQuery.isLoading} refreshing={ledgerQuery.isFetching} - fillRows + fillRows={10} skeletonRows={10} emptyState={ | undefined; - const usersDataQuery = useAdminUsers({ - page, - limit: PAGE_SIZE, - search: debouncedSearch, - }); - const usersData = usersDataQuery.data as - | { - users: Array<{ id: string; [key: string]: unknown }>; - pagination: { limit: number; total: number; totalPages: number }; - } - | undefined; - - const handleSearchChange = (value: string) => { - setSearch(value); - setPage(1); - }; - - const pagination = usersData?.pagination; - return ( - + @@ -66,43 +29,6 @@ function AdminDashboard() { - - - } - footer={ - - } - > - - } - /> - ); } diff --git a/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx b/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx index b3a65a3d..6d33fd0f 100644 --- a/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/orgs.index.tsx @@ -7,8 +7,7 @@ import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { AdminDataTable, AdminEmpty, - AdminPage, - AdminPanel, + AdminListPage, AdminSearch, ServerPagination, type AdminColumnDef, @@ -22,11 +21,10 @@ interface OrgRow { memberCount?: number; projectCount?: number; }; - plan?: string; createdAt?: string | number; } -const PAGE_SIZE = 10; +const PAGE_SIZE = 25; export const Route = createFileRoute('/_app/_protected/admin/orgs/')({ component: AdminOrgList, @@ -74,6 +72,7 @@ function AdminOrgList() { { accessorKey: 'stats.memberCount', header: 'Members', + meta: { className: 'w-24', align: 'right' }, cell: info => ( {info.row.original.stats?.memberCount ?? 0} @@ -83,6 +82,7 @@ function AdminOrgList() { { accessorKey: 'stats.projectCount', header: 'Projects', + meta: { className: 'w-24', align: 'right' }, cell: info => ( {info.row.original.stats?.projectCount ?? 0} @@ -92,6 +92,7 @@ function AdminOrgList() { { accessorKey: 'createdAt', header: 'Created', + meta: { className: 'w-32' }, cell: info => ( {formatDate(info.getValue() as string | number | null | undefined)} @@ -105,51 +106,50 @@ function AdminOrgList() { const pagination = orgsData?.pagination; return ( - - + } + footer={ + + } + > + } - footer={ - + enableSorting + onRowClick={(row: OrgRow) => + navigate({ + to: '/admin/orgs/$orgId' as string, + params: { orgId: row.id } as Record, + }) } - > - - } - enableSorting - onRowClick={(row: OrgRow) => - navigate({ - to: '/admin/orgs/$orgId' as string, - params: { orgId: row.id } as Record, - }) - } - /> - - + /> + ); } diff --git a/packages/web/src/routes/_app/_protected/admin/projects.index.tsx b/packages/web/src/routes/_app/_protected/admin/projects.index.tsx index 8f8e85ce..6f8b4022 100644 --- a/packages/web/src/routes/_app/_protected/admin/projects.index.tsx +++ b/packages/web/src/routes/_app/_protected/admin/projects.index.tsx @@ -8,8 +8,8 @@ import { AdminDataTable, AdminEmpty, AdminError, + AdminListPage, AdminPage, - AdminPanel, AdminSearch, ServerPagination, type AdminColumnDef, @@ -43,7 +43,7 @@ interface OrgOption { name: string; } -const PAGE_SIZE = 10; +const PAGE_SIZE = 25; const ALL_ORGS_VALUE = 'all'; export const Route = createFileRoute('/_app/_protected/admin/projects/')({ @@ -111,9 +111,31 @@ function AdminProjectList() { ); }, }, + { + accessorKey: 'creatorDisplayName', + header: 'Created by', + cell: info => { + const project = info.row.original; + const name = project.creatorDisplayName || project.creatorName; + if (!name && !project.creatorEmail) { + return -; + } + return ( + } + className='text-muted-foreground hover:text-primary transition-colors' + onClick={(e: React.MouseEvent) => e.stopPropagation()} + > + {name || project.creatorEmail} + + ); + }, + }, { accessorKey: 'memberCount', header: 'Members', + meta: { className: 'w-24', align: 'right' }, cell: info => ( {info.getValue() as number} ), @@ -121,6 +143,7 @@ function AdminProjectList() { { accessorKey: 'fileCount', header: 'Files', + meta: { className: 'w-20', align: 'right' }, cell: info => ( {info.getValue() as number} ), @@ -128,6 +151,7 @@ function AdminProjectList() { { accessorKey: 'createdAt', header: 'Created', + meta: { className: 'w-32' }, cell: info => ( {formatDate(info.getValue() as string | number | null | undefined)} @@ -147,87 +171,86 @@ function AdminProjectList() { } return ( - - - - - - } - footer={ - + - } - > - { - setSearch(''); - setSelectedOrgId(''); - setPage(1); - }} - > - Clear filters - - ) - } - /> - } - enableSorting - onRowClick={(row: ProjectRow) => - navigate({ - to: '/admin/projects/$projectId' as string, - params: { projectId: row.id } as Record, - }) - } + + + } + footer={ + - - + } + > + { + setSearch(''); + setSelectedOrgId(''); + setPage(1); + }} + > + Clear filters + + ) + } + /> + } + enableSorting + onRowClick={(row: ProjectRow) => + navigate({ + to: '/admin/projects/$projectId' as string, + params: { projectId: row.id } as Record, + }) + } + /> + ); } diff --git a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx index 051ac07b..bea3002f 100644 --- a/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx +++ b/packages/web/src/routes/_app/_protected/admin/users.$userId.tsx @@ -32,7 +32,7 @@ import { RevokeAllSessionsDialog, } from '@/components/admin/users/UserDialogs'; -const BACK_TO_DASHBOARD = { to: '/admin', label: 'Back to Admin Dashboard' }; +const BACK_TO_USERS = { to: '/admin/users', label: 'Back to Users' }; export const Route = createFileRoute('/_app/_protected/admin/users/$userId')({ loader: async ({ params: { userId } }) => { @@ -45,7 +45,7 @@ export const Route = createFileRoute('/_app/_protected/admin/users/$userId')({ function UserDetailError() { const router = useRouter(); return ( - + router.invalidate()} /> ); @@ -54,7 +54,7 @@ function UserDetailError() { /** Holds the page's shape while the suspense query resolves. */ function UserDetailSkeleton() { return ( - + @@ -160,7 +160,7 @@ function UserDetailContent() { await deleteUser(userId); showToast.success('Success', 'User deleted successfully'); setConfirmDialog(null); - navigate({ to: '/admin' as string }); + navigate({ to: '/admin/users' as string }); } catch (error) { await handleError(error, { showToast: true }); setLoading(false); @@ -169,7 +169,7 @@ function UserDetailContent() { return ( diff --git a/packages/web/src/routes/_app/_protected/admin/users.index.tsx b/packages/web/src/routes/_app/_protected/admin/users.index.tsx new file mode 100644 index 00000000..f426cfcd --- /dev/null +++ b/packages/web/src/routes/_app/_protected/admin/users.index.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { createFileRoute } from '@tanstack/react-router'; +import { UsersIcon } from 'lucide-react'; +import { useAdminUsers } from '@/hooks/useAdminQueries'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { UserTable } from '@/components/admin/UserTable'; +import { AdminEmpty, AdminListPage, AdminSearch, ServerPagination } from '@/components/admin/ui'; + +const PAGE_SIZE = 25; + +export const Route = createFileRoute('/_app/_protected/admin/users/')({ + component: AdminUserList, +}); + +function AdminUserList() { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const debouncedSearch = useDebouncedValue(search, 300); + + const usersDataQuery = useAdminUsers({ + page, + limit: PAGE_SIZE, + search: debouncedSearch, + }); + const usersData = usersDataQuery.data as + | { + users: Array<{ id: string; [key: string]: unknown }>; + pagination: { limit: number; total: number; totalPages: number }; + } + | undefined; + + const handleSearchChange = (value: string) => { + setSearch(value); + setPage(1); + }; + + const pagination = usersData?.pagination; + + return ( + + } + footer={ + + } + > + + } + /> + + ); +}