brackt/app/routes/admin.users.tsx

275 lines
9.5 KiB
TypeScript
Raw 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 } 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, hasMore } = await findUsersPaginated({
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
search: search || undefined,
});
const totalPages = Math.ceil(total / PAGE_SIZE);
return { users, page, totalPages, total, hasMore, 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("");
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 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Users</h1>
<p className="text-muted-foreground text-sm mt-1">{total} total users</p>
</div>
</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"
onClick={() => setEditingId(null)}
>
<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>
);
}