Admin user lookup
A platform-admin back-office panel to search users as you type and impersonate one, wired to the admin router. Degrades to an explanatory notice for non-admins.
A self-contained platform back-office panel over the admin router. It searches
every user on the platform as you type (the input is debounced before a
request fires), lets you pick the email or name field, pages through results, and
impersonates a user — the server swaps the session cookie to the target, so
on success you leave for the product app as that user.
The admin.* procedures are platform-admin-gated (adminProcedure). A non-admin
viewer's search fails with FORBIDDEN; rather than crash or leak a raw error, the
block hides the search, table, and impersonate affordances and shows an
explanatory notice.
Requires: admin
Preview
| Name | Role | Actions | |
|---|---|---|---|
| Ada Lovelace | ada@example.com | admin | |
| Grace Hopper | grace@example.com | user | |
| Alan Turing | alan@example.com | user |
"use client";import { useRouter } from "next/navigation";import { useEffect, useState } from "react";import { toast } from "sonner";import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from "@/components/ui/card";import { Button } from "@/components/ui/button";import { Input } from "@/components/ui/input";import { Label } from "@/components/ui/label";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table";import { trpc } from "@/lib/trpc";/** * A self-contained platform back-office panel over the `admin` router * (`searchUsers` / `impersonate`). It searches users **as you type** (the draft * is debounced before a request fires, so a keystroke doesn't spawn a query), * offers email/name field selection with offset pagination, and impersonates a * user — the server swaps the session cookie to the target (D-064), so on * success we leave for the product app **as that user**. * * The `admin.*` procedures are platform-admin-gated server-side (`adminProcedure`). * Per PD-4/D-096 (role-gated blocks degrade, never assume), a non-admin viewer's * `searchUsers` query fails with `FORBIDDEN`; the block hides the search, table, * and impersonate affordances entirely and shows an explanatory notice instead * of crashing or leaking a raw error. *//** Newest-first page size — mirrors the router's `DEFAULT_ADMIN_PAGE_SIZE`. */const PAGE_SIZE = 25;/** How long to wait after the last keystroke before committing the search. */const SEARCH_DEBOUNCE_MS = 300;/** The two fields `admin.searchUsers` can match on. */type SearchField = "email" | "name";/** * One row of the user search. Declared inline (the `AdminUsersTable`/`ApiKeyView` * precedent) rather than derived from `RouterOutputs` — the render decouples from * the generated query output; the router's `AdminUserView` matches by construction. */type UserRow = { id: string; name: string; email: string; role: string | null; banned: boolean; createdAt: string | Date;};export function AdminUserLookup() { const router = useRouter(); // The draft lives in the input; a debounced copy is what actually drives the // request, so search-as-you-type doesn't fire a query per keystroke. const [draft, setDraft] = useState(""); const [field, setField] = useState<SearchField>("email"); const [query, setQuery] = useState(""); const [offset, setOffset] = useState(0); // Debounce the draft into the committed query; resetting the page each time the // search changes so results always start from the first page. useEffect(() => { const id = setTimeout(() => { setQuery(draft.trim()); setOffset(0); }, SEARCH_DEBOUNCE_MS); return () => clearTimeout(id); }, [draft]); const users = trpc.admin.searchUsers.useQuery({ ...(query ? { query, field } : {}), limit: PAGE_SIZE, offset, }); const impersonate = trpc.admin.impersonate.useMutation({ onSuccess: () => { // The server re-issued the session cookie to the target (D-064); enter the // product app as them. `refresh()` re-reads the now-impersonated session. router.push("/dashboard"); router.refresh(); }, onError: (error) => toast.error(error.message || "Could not impersonate that user."), }); // PD-4/D-096 degrade: the search query is admin-gated, so a non-admin sees // FORBIDDEN. Hide the whole lookup surface and explain, rather than render a // broken search and table. if (users.error?.data?.code === "FORBIDDEN") { return ( <Card> <CardHeader> <CardTitle>User lookup</CardTitle> <CardDescription> You need a platform-admin role to look up and impersonate users. </CardDescription> </CardHeader> </Card> ); } const rows = (users.data?.items ?? []) as UserRow[]; const total = users.data?.total ?? 0; const hasPrev = offset > 0; const hasNext = offset + PAGE_SIZE < total; return ( <Card> <CardHeader> <CardTitle>User lookup</CardTitle> <CardDescription> Search every user on the platform and impersonate one for support. </CardDescription> </CardHeader> <CardContent className="flex flex-col gap-4 text-sm"> <div className="flex flex-wrap items-end gap-3" aria-label="Search users"> <div className="flex flex-col gap-1.5"> <Label htmlFor="admin-user-field">Field</Label> <Select value={field} onValueChange={(value) => setField(value as SearchField)} > <SelectTrigger id="admin-user-field" aria-label="Field"> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="email">Email</SelectItem> <SelectItem value="name">Name</SelectItem> </SelectContent> </Select> </div> <div className="flex flex-1 flex-col gap-1.5"> <Label htmlFor="admin-user-search">Search</Label> <Input id="admin-user-search" placeholder="Search users…" autoComplete="off" value={draft} onChange={(event) => setDraft(event.target.value)} /> </div> </div> {users.isPending && ( <p className="text-muted-foreground">Loading users…</p> )} {users.isError && ( <p role="alert" className="text-destructive"> Could not load users: {users.error.message} </p> )} {users.data && rows.length === 0 && ( <p className="text-muted-foreground">No users match this search.</p> )} {rows.length > 0 && ( <Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Email</TableHead> <TableHead>Role</TableHead> <TableHead className="text-right">Actions</TableHead> </TableRow> </TableHeader> <TableBody> {rows.map((user) => ( <TableRow key={user.id}> <TableCell className="font-medium"> {user.name || "—"} </TableCell> <TableCell>{user.email}</TableCell> <TableCell>{user.role ?? "user"}</TableCell> <TableCell className="text-right"> <Button variant="secondary" size="sm" disabled={impersonate.isPending} onClick={() => impersonate.mutate({ userId: user.id })} > Impersonate </Button> </TableCell> </TableRow> ))} </TableBody> </Table> )} {(hasPrev || hasNext) && ( <div className="flex items-center gap-3"> <Button variant="secondary" size="sm" disabled={!hasPrev} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))} > Previous </Button> <Button variant="secondary" size="sm" disabled={!hasNext} onClick={() => setOffset(offset + PAGE_SIZE)} > Next </Button> <span className="text-xs text-muted-foreground"> {total} user{total === 1 ? "" : "s"} </span> </div> )} </CardContent> </Card> );}Offline preview
The docs site has no backend, so the preview seeds one representative page of
users to show the populated admin table. In an app the data comes from
admin.searchUsers; a non-admin instead sees the "you need a platform-admin
role" notice, and impersonation fires only on the Impersonate action.
Install
npx create-saas-starter add admin-user-lookupRun it from your app's root. add only writes new files — wiring is up to you:
import { AdminUserLookup } from "@/components/blocks/admin-user-lookup/admin-user-lookup";File dropzone
A drag-and-drop upload zone driving the presigned files flow (createUpload → PUT → confirmUpload), with a recent-files list. Shows a storage-limit hint when flags is present.
Plan comparison grid
A plan-comparison matrix from PLANS with the current plan highlighted and checkout/portal CTAs, wired to the billing router. Degrades to the plain grid for non-owners.