From 6f70a19b7330ddd8d5caf32b1bf1f69874fac572 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 15 May 2026 12:07:51 -0700 Subject: [PATCH] Add admin user management interface with search and username editing (#431) * Add admin users management page with pagination, search, and inline username editing https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs * Fix five issues from admin users page code review - admin.users: use useEffect to close inline edit on success so validation errors are not silently discarded when the save button fires onClick - root: add /admin to ONBOARDING_EXEMPT so admin users without a username are not redirect-looped away from admin pages - settings: skip username validation when the username field is empty so updating timezone alone does not break for accounts without a username - models/user: exclude soft-deleted users from findUsersPaginated - admin.users: remove unused hasMore from loader return; simplify header div https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs * Fix lint: rename shadowed variables in user model - findUserByUsername: use imported sql directly instead of destructuring it from the callback (shadowed the top-level import) - findUsersPaginated: rename orderBy callback param from users to t (shadowed the outer users result variable) https://claude.ai/code/session_01E4UUQqQedhFP2F5YcBRArs --------- Co-authored-by: Claude --- app/models/user.ts | 37 ++- app/root.tsx | 2 +- app/routes.ts | 1 + .../__tests__/root.onboarding-exempt.test.ts | 6 + app/routes/admin.tsx | 7 + app/routes/admin.users.tsx | 279 ++++++++++++++++++ app/routes/settings.tsx | 20 +- 7 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 app/routes/admin.users.tsx diff --git a/app/models/user.ts b/app/models/user.ts index 9c219b6..2980d88 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, isNull, sql } from "drizzle-orm"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { generateFlagConfig } from "~/lib/flag-generator"; @@ -43,7 +43,7 @@ export async function findUsersByIds(ids: string[]): Promise { export async function findUserByUsername(username: string): Promise { const db = database(); return await db.query.users.findFirst({ - where: (users, { sql }) => sql`lower(${users.username}) = lower(${username})`, + where: (u) => sql`lower(${u.username}) = lower(${username})`, }); } @@ -107,6 +107,39 @@ 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 notDeleted = isNull(schema.users.deletedAt); + const filter = options.search + ? and( + notDeleted, + or( + ilike(schema.users.email, `%${options.search}%`), + ilike(schema.users.username, `%${options.search}%`), + ilike(schema.users.displayName, `%${options.search}%`), + ), + ) + : notDeleted; + + const [users, [{ count }]] = await Promise.all([ + db.query.users.findMany({ + where: filter, + orderBy: (t, { asc }) => [asc(t.displayName)], + limit: options.limit, + offset: options.offset, + }), + db + .select({ count: sql`count(*)::int` }) + .from(schema.users) + .where(filter), + ]); + 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/root.tsx b/app/root.tsx index 7fd76c0..d9dc04d 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -23,7 +23,7 @@ import logoUrl from "../public/logo.svg?url"; // Paths that should never trigger the onboarding redirect. // Trailing-slash entries are matched as prefixes; others require exact match or sub-path. -const ONBOARDING_EXEMPT = ["/onboarding", "/api/", "/login", "/register", "/check-email", "/forgot-password", "/reset-password"]; +const ONBOARDING_EXEMPT = ["/onboarding", "/api/", "/admin", "/login", "/register", "/check-email", "/forgot-password", "/reset-password"]; export function isOnboardingExempt(pathname: string): boolean { return ONBOARDING_EXEMPT.some((p) => 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/__tests__/root.onboarding-exempt.test.ts b/app/routes/__tests__/root.onboarding-exempt.test.ts index b0768a8..8df54f4 100644 --- a/app/routes/__tests__/root.onboarding-exempt.test.ts +++ b/app/routes/__tests__/root.onboarding-exempt.test.ts @@ -33,6 +33,12 @@ describe("isOnboardingExempt", () => { expect(isOnboardingExempt("/loginpage")).toBe(false); }); + it("exempts /admin and all sub-paths", () => { + expect(isOnboardingExempt("/admin")).toBe(true); + expect(isOnboardingExempt("/admin/users")).toBe(true); + expect(isOnboardingExempt("/admin/sports-seasons/123")).toBe(true); + }); + it("does not exempt normal app routes", () => { expect(isOnboardingExempt("/")).toBe(false); expect(isOnboardingExempt("/leagues/abc")).toBe(false); 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 && ( + + )} +
+
+ )} +
+ +
+ ); +} diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 35c600d..4824c02 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -123,17 +123,21 @@ export async function action(args: Route.ActionArgs): Promise