{
  "id": "add-an-admin-table",
  "type": "playbooks",
  "category": "playbooks",
  "locale": "zh",
  "url": "/zh/playbooks/add-an-admin-table",
  "title": "Prompt-to-PR：添加管理数据表",
  "description": "用于在Next.js中使用TanStack Table和PostgreSQL查询添加服务器端分页、可排序、可搜索的管理数据表的SOP——无ORM魔术。",
  "tools": [
    "Cursor",
    "Claude Code",
    "Codex",
    "Windsurf"
  ],
  "stack": [
    "Next.js",
    "TypeScript",
    "PostgreSQL",
    "Tailwind"
  ],
  "tags": [
    "nextjs",
    "postgres",
    "typescript",
    "tailwind",
    "auth"
  ],
  "difficulty": "medium",
  "updated": "2026-06-08",
  "markdown": "管理表是常见的AI编码任务，但经常产生不安全的、完全客户端实现。本攻略将分页和排序保持在服务器端，并显式保护路由。\n\n## 1. 需求\n\n添加一个仅对拥有`role = \"admin\"`的用户可访问的`/admin/users`页面。该表为服务器端分页（每页25行）、可按列排序、可按电子邮件搜索。数据通过参数化查询从PostgreSQL的`users`表中获取——不使用未参数化的SQL。\n\n## 2. 首次提示\n\n```txt title=\"First Prompt\"\nAdd an admin users table to this Next.js 15 App Router project.\n\nRequirements:\n1. Create `src/app/admin/users/page.tsx` — an async Server Component.\n   - Protect it: check the session (using the existing auth helper at\n     src/lib/auth.ts). If no session or session.user.role !== \"admin\",\n     call notFound() or redirect to /.\n   - Read `page` (default 1), `sort` (default \"createdAt\"), `order`\n     (\"asc\"|\"desc\", default \"desc\"), and `q` (search string) from\n     `searchParams`.\n   - Fetch data by calling a function `getUsers({ page, sort, order, q })`\n     from `src/lib/queries/users.ts`.\n   - Pass results to `<UsersTable>` client component.\n2. Create `src/lib/queries/users.ts` exporting `getUsers`.\n   - Use parameterized SQL via the existing db client (Drizzle or postgres.js,\n     whichever is in this project).\n   - Allowed sort columns whitelist: [\"email\",\"name\",\"createdAt\",\"role\"].\n     Reject any other sort value by defaulting to \"createdAt\".\n   - Return `{ rows: User[], total: number }`.\n   - Page size: 25.\n3. Create `src/components/admin/UsersTable.tsx` — a Client Component using\n   TanStack Table v8.\n   - Columns: email, name, role, createdAt (formatted), actions (Edit link).\n   - Column headers are clickable to sort — update URL search params via\n     useRouter/useSearchParams (do not manage sort state locally).\n   - Render a search input that debounces 300 ms before updating the URL.\n   - Render a pagination row: Previous / Next and \"Page N of M\".\n4. Install `@tanstack/react-table` if not already present.\n5. Do not implement the Edit action — leave it as a placeholder link.\n```\n\n## 3. 预期文件变更\n\n```txt\npackage.json                                   (@tanstack/react-table if missing)\nsrc/app/admin/users/page.tsx                   (new — protected Server Component)\nsrc/lib/queries/users.ts                       (new — parameterized query)\nsrc/components/admin/UsersTable.tsx            (new — TanStack Table client component)\n```\n\n## 4. 审查清单\n\n- 页面在服务器端检查`session.user.role === \"admin\"`——无仅客户端保护。\n- `getUsers`在插入SQL之前白名单允许的排序列——列名中无原始用户输入。\n- 所有WHERE/LIMIT/OFFSET值使用参数化占位符，而非字符串拼接。\n- 搜索(`q`)使用带参数的`ILIKE '%' || $1 || '%'`，而非字符串插值。\n- 仅在`\"use client\"`组件中使用TanStack Table——服务器组件中无`@tanstack/react-table`导入。\n- URL搜索参数驱动排序/页面/搜索状态——浏览器返回按钮正常工作。\n- `total`计数来自同一查询函数中的`SELECT COUNT(*)`——并非与分页查询分离的全表扫描。\n\n## 5. 测试命令\n\n```bash\nbun dev\n\n# Hit the page as an unauthenticated user\ncurl -I http://localhost:3000/admin/users\n# Expect: 404 or redirect, not 200\n\n# Log in as an admin user in the browser, visit /admin/users\n# Confirm: 25 rows, sortable columns, search, pagination\n\n# SQL audit — confirm no dynamic column names slip through\ngrep -n \"sort\\|order\\|column\" src/lib/queries/users.ts\n\nbun tsc --noEmit\nbun test\n```\n\n## 6. 常见失败\n\n- **通过排序列的SQL注入**——代理直接插值`sort`：`` `ORDER BY ${sort}` ``。必须使用白名单查找至安全的列名字符串。\n- **`searchParams`在Next.js 15+中为异步**——必须使用`await`：`const { page } = await searchParams`。\n- **TanStack Table在服务器组件中渲染**——因使用`useState`导致构建错误。将其移至`\"use client\"`组件。\n- **`total`计数差一页**——若`total`为0，`Math.ceil(total / 25)`给出错误页数。保护：`Math.max(1, Math.ceil(total / 25))`。\n- **缺少角色检查**——代理仅检查会话，而非管理员角色。任何登录用户均可访问该表。\n\n## 7. 修复提示\n\n```txt title=\"Fix Prompt\"\nThe sort column in getUsers is interpolated directly into SQL:\n  `ORDER BY ${sort} ${order}`\nThis is a SQL injection risk.\n\nFix: create an allowlist object:\n  const ALLOWED_SORT: Record<string, string> = {\n    email: \"users.email\",\n    name: \"users.name\",\n    createdAt: \"users.created_at\",\n    role: \"users.role\",\n  };\n  const safeSort = ALLOWED_SORT[sort] ?? \"users.created_at\";\n\nThen use `safeSort` in the query. The order direction must also be\nvalidated: only accept \"asc\" or \"desc\", default to \"desc\".\n```\n\n## 8. PR描述\n\n```md title=\"PR description\"\n## Feature: Admin users table at /admin/users\n\n- Server-side pagination (25 rows/page), sorting, and email search\n- Role guard: redirects non-admins server-side (no client-only check)\n- Parameterized SQL with sort-column whitelist — no injection surface\n- TanStack Table v8 client component; sort/page/search state lives in URL\n- `getUsers` returns `{ rows, total }` from a single query function\n```"
}