From f2daea6c39c9d0f7f0aaf3df8a160653e9c85492 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 18:26:30 +0000 Subject: [PATCH] Add admin users management page with pagination, search, and inline username editing https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs --- app/models/user.ts | 31 ++++- app/routes.ts | 1 + app/routes/admin.tsx | 7 + app/routes/admin.users.tsx | 275 +++++++++++++++++++++++++++++++++++++ 4 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 app/routes/admin.users.tsx diff --git a/app/models/user.ts b/app/models/user.ts index 9c219b6..f44b85f 100644 --- a/app/models/user.ts +++ b/app/models/user.ts @@ -1,4 +1,4 @@ -import { eq, inArray, and } from "drizzle-orm"; +import { eq, inArray, and, or, ilike, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { generateFlagConfig } from "~/lib/flag-generator"; @@ -107,6 +107,35 @@ export async function findAllUsers(): Promise { }); } +export async function findUsersPaginated(options: { + limit: number; + offset: number; + search?: string; +}): Promise<{ users: User[]; total: number; hasMore: boolean }> { + const db = database(); + const searchFilter = options.search + ? or( + ilike(schema.users.email, `%${options.search}%`), + ilike(schema.users.username, `%${options.search}%`), + ilike(schema.users.displayName, `%${options.search}%`), + ) + : undefined; + + const [users, [{ count }]] = await Promise.all([ + db.query.users.findMany({ + where: searchFilter, + orderBy: (users, { asc }) => [asc(users.displayName)], + limit: options.limit, + offset: options.offset, + }), + db + .select({ count: sql`count(*)::int` }) + .from(schema.users) + .where(searchFilter), + ]); + return { users, total: count, hasMore: options.offset + users.length < count }; +} + /** * Returns true if the user currently owns a team in a draft-status season. * Used to prevent timezone changes mid-draft. diff --git a/app/routes.ts b/app/routes.ts index e8a0b73..9577079 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -148,6 +148,7 @@ export default [ route("templates/:id", "routes/admin.templates.$id.tsx"), route("data-sync", "routes/admin.data-sync.tsx"), route("standings-snapshots", "routes/admin.standings-snapshots.tsx"), + route("users", "routes/admin.users.tsx"), ]), route( "api/admin/export-sports-data", diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index c233ae3..1fb88f5 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -12,6 +12,7 @@ import { RefreshCw, Award, Activity, + Users, } from "lucide-react"; export function meta(): Route.MetaDescriptors { @@ -79,6 +80,12 @@ export default function AdminLayout() { Season Templates +
+ {search && ( + + )} + + + + + + + Email + Username + Display Name + Admin + Joined + + + + {users.length === 0 && ( + + + No users found. + + + )} + {users.map((user) => { + const isEditing = editingId === user.id; + const hasError = actionData && "error" in actionData && actionData.userId === user.id; + return ( + + {user.email} + + {isEditing ? ( +
+
+ + + setEditValue(e.target.value)} + className="h-7 w-40 text-sm" + autoFocus + placeholder="leave blank to clear" + /> + + + + {hasError && ( +

{actionData.error}

+ )} +
+ ) : ( +
+ {user.username ?? "—"} + +
+ )} +
+ + {user.displayName ?? "—"} + + + {user.isAdmin && Admin} + + + {new Date(user.createdAt).toLocaleDateString()} + +
+ ); + })} +
+
+ + {totalPages > 1 && ( +
+ + Page {page + 1} of {totalPages} + +
+ {page > 0 && ( + + )} + {page < totalPages - 1 && ( + + )} +
+
+ )} +
+ +
+ ); +}