{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={
+
+ }
+ >
+
+ }
+ />
+
+ );
+}