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 <noreply@anthropic.com>
This commit is contained in:
parent
215b0cf6b5
commit
6f70a19b73
7 changed files with 341 additions and 11 deletions
|
|
@ -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 { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { generateFlagConfig } from "~/lib/flag-generator";
|
import { generateFlagConfig } from "~/lib/flag-generator";
|
||||||
|
|
@ -43,7 +43,7 @@ export async function findUsersByIds(ids: string[]): Promise<User[]> {
|
||||||
export async function findUserByUsername(username: string): Promise<User | undefined> {
|
export async function findUserByUsername(username: string): Promise<User | undefined> {
|
||||||
const db = database();
|
const db = database();
|
||||||
return await db.query.users.findFirst({
|
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<User[]> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<number>`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.
|
* Returns true if the user currently owns a team in a draft-status season.
|
||||||
* Used to prevent timezone changes mid-draft.
|
* Used to prevent timezone changes mid-draft.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import logoUrl from "../public/logo.svg?url";
|
||||||
|
|
||||||
// Paths that should never trigger the onboarding redirect.
|
// Paths that should never trigger the onboarding redirect.
|
||||||
// Trailing-slash entries are matched as prefixes; others require exact match or sub-path.
|
// 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 {
|
export function isOnboardingExempt(pathname: string): boolean {
|
||||||
return ONBOARDING_EXEMPT.some((p) =>
|
return ONBOARDING_EXEMPT.some((p) =>
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,7 @@ export default [
|
||||||
route("templates/:id", "routes/admin.templates.$id.tsx"),
|
route("templates/:id", "routes/admin.templates.$id.tsx"),
|
||||||
route("data-sync", "routes/admin.data-sync.tsx"),
|
route("data-sync", "routes/admin.data-sync.tsx"),
|
||||||
route("standings-snapshots", "routes/admin.standings-snapshots.tsx"),
|
route("standings-snapshots", "routes/admin.standings-snapshots.tsx"),
|
||||||
|
route("users", "routes/admin.users.tsx"),
|
||||||
]),
|
]),
|
||||||
route(
|
route(
|
||||||
"api/admin/export-sports-data",
|
"api/admin/export-sports-data",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ describe("isOnboardingExempt", () => {
|
||||||
expect(isOnboardingExempt("/loginpage")).toBe(false);
|
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", () => {
|
it("does not exempt normal app routes", () => {
|
||||||
expect(isOnboardingExempt("/")).toBe(false);
|
expect(isOnboardingExempt("/")).toBe(false);
|
||||||
expect(isOnboardingExempt("/leagues/abc")).toBe(false);
|
expect(isOnboardingExempt("/leagues/abc")).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Award,
|
Award,
|
||||||
Activity,
|
Activity,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export function meta(): Route.MetaDescriptors {
|
export function meta(): Route.MetaDescriptors {
|
||||||
|
|
@ -79,6 +80,12 @@ export default function AdminLayout() {
|
||||||
Season Templates
|
Season Templates
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
|
<Link to="/admin/users">
|
||||||
|
<Users className="mr-2 h-4 w-4" />
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<div className="border-t my-2" />
|
<div className="border-t my-2" />
|
||||||
<Button variant="ghost" className="w-full justify-start" asChild>
|
<Button variant="ghost" className="w-full justify-start" asChild>
|
||||||
<Link to="/admin/data-sync">
|
<Link to="/admin/data-sync">
|
||||||
|
|
|
||||||
279
app/routes/admin.users.tsx
Normal file
279
app/routes/admin.users.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Form, redirect, useSearchParams } from "react-router";
|
||||||
|
import type { Route } from "./+types/admin.users";
|
||||||
|
|
||||||
|
import { auth } from "~/lib/auth.server";
|
||||||
|
import {
|
||||||
|
isUserAdmin,
|
||||||
|
findUsersPaginated,
|
||||||
|
findUserByUsername,
|
||||||
|
updateUser,
|
||||||
|
USERNAME_RE,
|
||||||
|
} from "~/models/user";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Input } from "~/components/ui/input";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "~/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "~/components/ui/table";
|
||||||
|
import { Pencil, Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
export function meta(): Route.MetaDescriptors {
|
||||||
|
return [{ title: "Users — Brackt Admin" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(args: Route.LoaderArgs) {
|
||||||
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
if (!userId) throw redirect("/");
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
if (!isAdmin) throw redirect("/");
|
||||||
|
|
||||||
|
const searchParams = new URL(args.request.url).searchParams;
|
||||||
|
const page = Math.max(0, parseInt(searchParams.get("page") ?? "0", 10) || 0);
|
||||||
|
const search = searchParams.get("search") ?? "";
|
||||||
|
|
||||||
|
const { users, total } = await findUsersPaginated({
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: page * PAGE_SIZE,
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
return { users, page, totalPages, total, search };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action(args: Route.ActionArgs) {
|
||||||
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
|
const userId = session?.user.id ?? null;
|
||||||
|
if (!userId) throw redirect("/");
|
||||||
|
|
||||||
|
const isAdmin = await isUserAdmin(userId);
|
||||||
|
if (!isAdmin) throw redirect("/");
|
||||||
|
|
||||||
|
const formData = await args.request.formData();
|
||||||
|
const intent = formData.get("intent");
|
||||||
|
|
||||||
|
if (intent === "update-username") {
|
||||||
|
const targetUserId = formData.get("userId");
|
||||||
|
const username = formData.get("username");
|
||||||
|
|
||||||
|
if (typeof targetUserId !== "string" || typeof username !== "string") {
|
||||||
|
return { error: "Invalid request" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = username.trim();
|
||||||
|
|
||||||
|
if (trimmed !== "" && !USERNAME_RE.test(trimmed)) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Username must be 3–30 characters and contain only letters, numbers, underscores, or hyphens.",
|
||||||
|
userId: targetUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed !== "") {
|
||||||
|
const existing = await findUserByUsername(trimmed);
|
||||||
|
if (existing && existing.id !== targetUserId) {
|
||||||
|
return { error: "That username is already taken.", userId: targetUserId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(targetUserId, { username: trimmed || null });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminUsers({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { users, page, totalPages, total, search } = loaderData;
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (actionData && "success" in actionData && actionData.success) {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditValue("");
|
||||||
|
}
|
||||||
|
}, [actionData]);
|
||||||
|
|
||||||
|
function startEdit(userId: string, currentUsername: string | null) {
|
||||||
|
setEditingId(userId);
|
||||||
|
setEditValue(currentUsername ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
setEditValue("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageUrl(newPage: number) {
|
||||||
|
const p = new URLSearchParams(searchParams);
|
||||||
|
p.set("page", String(newPage));
|
||||||
|
return `?${p.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold">Users</h1>
|
||||||
|
<p className="text-muted-foreground text-sm mt-1">{total} total users</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Users</CardTitle>
|
||||||
|
<CardDescription>Search and manage user accounts.</CardDescription>
|
||||||
|
<Form method="get" className="mt-2 flex gap-2">
|
||||||
|
<Input
|
||||||
|
name="search"
|
||||||
|
placeholder="Search by email, username, or display name…"
|
||||||
|
defaultValue={search}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="page" value="0" />
|
||||||
|
<Button type="submit" variant="secondary">
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
{search && (
|
||||||
|
<Button variant="ghost" asChild>
|
||||||
|
<a href="?page=0">Clear</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Username</TableHead>
|
||||||
|
<TableHead>Display Name</TableHead>
|
||||||
|
<TableHead>Admin</TableHead>
|
||||||
|
<TableHead>Joined</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||||
|
No users found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{users.map((user) => {
|
||||||
|
const isEditing = editingId === user.id;
|
||||||
|
const hasError = actionData && "error" in actionData && actionData.userId === user.id;
|
||||||
|
return (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-mono text-sm">{user.email}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Form method="post" className="flex items-center gap-1">
|
||||||
|
<input type="hidden" name="intent" value="update-username" />
|
||||||
|
<input type="hidden" name="userId" value={user.id} />
|
||||||
|
<Input
|
||||||
|
name="username"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
className="h-7 w-40 text-sm"
|
||||||
|
autoFocus
|
||||||
|
placeholder="leave blank to clear"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 w-7"
|
||||||
|
title="Cancel"
|
||||||
|
onClick={cancelEdit}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
{hasError && (
|
||||||
|
<p className="text-destructive text-xs">{actionData.error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm">{user.username ?? "—"}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 opacity-50 hover:opacity-100"
|
||||||
|
title="Edit username"
|
||||||
|
onClick={() => startEdit(user.id, user.username)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{user.displayName ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.isAdmin && <Badge variant="secondary">Admin</Badge>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{page > 0 && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={buildPageUrl(page - 1)}>Previous</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{page < totalPages - 1 && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={buildPageUrl(page + 1)}>Next</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -123,17 +123,21 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
|
||||||
const timezone = formData.get("timezone") as string | null;
|
const timezone = formData.get("timezone") as string | null;
|
||||||
const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
|
const timezoneValue = timezone && VALID_TIMEZONES.has(timezone) ? timezone : undefined;
|
||||||
|
|
||||||
if (!USERNAME_RE.test(username)) {
|
if (username !== "") {
|
||||||
return { intent: "update-profile", error: "Username must be 3–30 characters and contain only letters, numbers, underscores, or hyphens." };
|
if (!USERNAME_RE.test(username)) {
|
||||||
}
|
return { intent: "update-profile", error: "Username must be 3–30 characters and contain only letters, numbers, underscores, or hyphens." };
|
||||||
|
}
|
||||||
const existing = await findUserByUsername(username);
|
const existing = await findUserByUsername(username);
|
||||||
if (existing && existing.id !== session.user.id) {
|
if (existing && existing.id !== session.user.id) {
|
||||||
return { intent: "update-profile", error: "That username is already taken." };
|
return { intent: "update-profile", error: "That username is already taken." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateUser(session.user.id, { username, timezone: timezoneValue });
|
await updateUser(session.user.id, {
|
||||||
|
...(username !== "" ? { username } : {}),
|
||||||
|
timezone: timezoneValue,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return { intent: "update-profile", error: "That username is already taken." };
|
return { intent: "update-profile", error: "That username is already taken." };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue