From c926a6a8748bc8f13bda0abab43a2ffbc4f5e2eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B7=AB=E5=87=A4=E8=90=8D?= Date: Fri, 7 Aug 2026 11:06:20 +0800 Subject: [PATCH] Add user authentication and role management --- .dev.vars.example | 3 +- README.md | 68 +- app/admin-app.tsx | 47 +- app/api/action/route.ts | 2 +- app/api/auth/login/route.ts | 47 ++ app/api/auth/logout/route.ts | 17 + app/api/bootstrap/route.ts | 12 +- app/api/content-image-upload/route.ts | 2 +- app/api/creator-screenshot/route.ts | 2 +- app/api/recovery-export/route.ts | 2 +- app/api/resources-export/route.ts | 4 +- app/api/upload/route.ts | 2 +- app/api/users/route.ts | 138 ++++ app/globals.css | 262 ++++++ app/login/page.tsx | 69 ++ app/page.tsx | 25 +- app/users-page.tsx | 212 +++++ db/schema.ts | 34 + drizzle/0007_fantastic_sentinels.sql | 22 + drizzle/meta/0007_snapshot.json | 1102 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-auth.ts | 39 +- lib/mvp-db.ts | 35 + lib/user-auth.ts | 285 +++++++ tests/rendered-html.test.mjs | 64 +- 25 files changed, 2370 insertions(+), 132 deletions(-) create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/login/page.tsx create mode 100644 app/users-page.tsx create mode 100644 drizzle/0007_fantastic_sentinels.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 lib/user-auth.ts diff --git a/.dev.vars.example b/.dev.vars.example index dfef0ea..8baab46 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,5 +1,6 @@ KOC_PORTAL_URL=http://localhost:3000 -ADMIN_ALLOWED_EMAIL=operator@example.com +SUPER_ADMIN_USERNAME=admin +SUPER_ADMIN_PASSWORD=qazxsw123admin ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret # Optional override. The production key must be stored as a runtime secret. diff --git a/README.md b/README.md index 872edc1..0f3499f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ npm run build 飞书自建应用需开通电子表格读取、知识库节点读取和云文档素材下载权限, 并将应用添加到目标知识库或电子表格的文档应用中。 +后台登录首次启动还需要配置: + +- `SUPER_ADMIN_USERNAME`:唯一的超级管理员登录账号 +- `SUPER_ADMIN_PASSWORD`:超级管理员初始密码,至少 8 位 +- `ADMIN_INTERNAL_TOKEN`:自动采集等内部任务使用的服务密钥 + +系统首次登录时创建唯一的超级管理员。后续管理员和普通用户均由“用户管理”页面创建,普通用户不能访问 KOC 资源库。 + This starter does not use `wrangler.jsonc`. ## Included Shape @@ -33,63 +41,13 @@ This starter does not use `wrangler.jsonc`. - `examples/d1/` contains an optional D1 example surface - `drizzle.config.ts` supports local migration generation when needed -## Workspace Auth Headers +## 后台账号与角色 -OpenAI workspace sites can read the current user's email from -`oai-authenticated-user-email`. +- 超级管理员:唯一系统管理员,可管理管理员和普通用户。 +- 管理员:可访问全部业务模块,可创建和重置普通用户账号。 +- 普通用户:可使用工作台、任务、内容分发和数据回收,不可查看或导出 KOC 资源库。 -SIWC-authenticated workspace sites may also receive -`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty -`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by -`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`. - -Treat the full name as optional and fall back to email when it is absent: - -```tsx -import { headers } from "next/headers"; - -export default async function Home() { - const requestHeaders = await headers(); - const email = requestHeaders.get("oai-authenticated-user-email"); - const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name"); - const fullName = - encodedFullName && - requestHeaders.get("oai-authenticated-user-full-name-encoding") === - "percent-encoded-utf-8" - ? decodeURIComponent(encodedFullName) - : null; - - const displayName = fullName ?? email; - // ... -} -``` - -## Optional Dispatch-Owned ChatGPT Sign-In - -Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs -optional or required ChatGPT sign-in: - -- Use `getChatGPTUser()` for optional signed-in UI. -- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send - anonymous visitors through Sign in with ChatGPT. -- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for - browser links or actions. -- Pass a same-origin relative `returnTo` path for the destination after sign-in - or sign-out. The helper validates and safely encodes it. -- Mark protected pages with `export const dynamic = "force-dynamic"` because - they depend on per-request identity headers. - -Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the -OAuth cookies, and identity header injection. Do not implement app routes for -those reserved paths. Routes that do not import and call the helper remain -anonymous-compatible. - -SIWC establishes identity only; it does not prove workspace membership. Use the -Sites hosting platform's access policy controls for workspace-wide restrictions, -or enforce explicit server-side membership or allowlist checks. - -Use SIWC for account pages, user-specific dashboards, saved records, and write -actions tied to the current ChatGPT user. Leave public content anonymous. +后台不提供注册、找回密码和普通用户个人改密。密码重置统一由管理员在后台完成。 ## Useful Commands diff --git a/app/admin-app.tsx b/app/admin-app.tsx index 7443d14..0b1fb5a 100644 --- a/app/admin-app.tsx +++ b/app/admin-app.tsx @@ -12,6 +12,8 @@ import { formatShanghaiDate as formatDate, formatShanghaiToday, } from "../lib/date-utils"; +import type { AuthUser } from "../lib/user-auth"; +import UsersPage from "./users-page"; type Partner = { id: string; @@ -122,7 +124,8 @@ type NavKey = | "tasks" | "distributions" | "resources" - | "recovery"; + | "recovery" + | "users"; const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "overview", label: "工作台", mark: "⌂" }, @@ -130,6 +133,7 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ { key: "distributions", label: "内容分发", mark: "↗" }, { key: "resources", label: "KOC资源", mark: "◎" }, { key: "recovery", label: "数据回收", mark: "◫" }, + { key: "users", label: "用户管理", mark: "♙" }, ]; const EMPTY_DATA: DashboardData = { @@ -301,7 +305,7 @@ async function compressScreenshot(file: File) { }); } -export default function Home() { +export default function Home({ currentUser }: { currentUser: AuthUser }) { const [data, setData] = useState(EMPTY_DATA); const [activeNav, setActiveNav] = useState("overview"); const [loading, setLoading] = useState(true); @@ -322,6 +326,12 @@ export default function Home() { const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [exportingTaskId, setExportingTaskId] = useState(null); const [exportingResources, setExportingResources] = useState(false); + const isManager = currentUser.role === "super_admin" || currentUser.role === "admin"; + const visibleNavItems = NAV_ITEMS.filter((item) => { + if (item.key === "resources" && currentUser.role === "user") return false; + if (item.key === "users" && !isManager) return false; + return true; + }); const loadData = useCallback(async () => { try { @@ -399,6 +409,8 @@ export default function Home() { const recentDistributions = data.distributions.slice(0, 6); const navigate = (key: NavKey) => { + if (key === "resources" && currentUser.role === "user") return; + if (key === "users" && !isManager) return; setActiveNav(key); setMenuOpen(false); }; @@ -646,6 +658,15 @@ export default function Home() { title: "数据回收", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", }, + users: { + title: "用户管理", + subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。", + }, + }; + + const logout = async () => { + await fetch("/api/auth/logout", { method: "POST" }).catch(() => null); + window.location.assign("/login"); }; return ( @@ -661,7 +682,7 @@ export default function Home() {