brackt/app/routes/admin.users.tsx
Chris Parsons 6f70a19b73
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>
2026-05-15 12:07:51 -07:00

279 lines
9.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 330 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>
);
}