brackt/app/routes/admin.users.tsx

280 lines
9.5 KiB
TypeScript
Raw Normal View History

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