zazzy
Blocks

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

User lookup
Search every user on the platform and impersonate one for support.
NameEmailRoleActions
Ada Lovelaceada@example.comadmin
Grace Hoppergrace@example.comuser
Alan Turingalan@example.comuser
"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-lookup

Run 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";

On this page