Add user authentication and role management

This commit is contained in:
巫凤萍
2026-08-07 11:06:20 +08:00
parent 1f910c975d
commit c926a6a874
25 changed files with 2370 additions and 132 deletions

View File

@@ -1,5 +1,6 @@
KOC_PORTAL_URL=http://localhost:3000 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 ADMIN_INTERNAL_TOKEN=replace-with-a-random-secret
# Optional override. The production key must be stored as a runtime secret. # Optional override. The production key must be stored as a runtime secret.

View File

@@ -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`. This starter does not use `wrangler.jsonc`.
## Included Shape ## Included Shape
@@ -33,63 +41,13 @@ This starter does not use `wrangler.jsonc`.
- `examples/d1/` contains an optional D1 example surface - `examples/d1/` contains an optional D1 example surface
- `drizzle.config.ts` supports local migration generation when needed - `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 ## Useful Commands

View File

@@ -12,6 +12,8 @@ import {
formatShanghaiDate as formatDate, formatShanghaiDate as formatDate,
formatShanghaiToday, formatShanghaiToday,
} from "../lib/date-utils"; } from "../lib/date-utils";
import type { AuthUser } from "../lib/user-auth";
import UsersPage from "./users-page";
type Partner = { type Partner = {
id: string; id: string;
@@ -122,7 +124,8 @@ type NavKey =
| "tasks" | "tasks"
| "distributions" | "distributions"
| "resources" | "resources"
| "recovery"; | "recovery"
| "users";
const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "overview", label: "工作台", mark: "⌂" }, { key: "overview", label: "工作台", mark: "⌂" },
@@ -130,6 +133,7 @@ const NAV_ITEMS: Array<{ key: NavKey; label: string; mark: string }> = [
{ key: "distributions", label: "内容分发", mark: "↗" }, { key: "distributions", label: "内容分发", mark: "↗" },
{ key: "resources", label: "KOC资源", mark: "◎" }, { key: "resources", label: "KOC资源", mark: "◎" },
{ key: "recovery", label: "数据回收", mark: "◫" }, { key: "recovery", label: "数据回收", mark: "◫" },
{ key: "users", label: "用户管理", mark: "♙" },
]; ];
const EMPTY_DATA: DashboardData = { 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<DashboardData>(EMPTY_DATA); const [data, setData] = useState<DashboardData>(EMPTY_DATA);
const [activeNav, setActiveNav] = useState<NavKey>("overview"); const [activeNav, setActiveNav] = useState<NavKey>("overview");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -322,6 +326,12 @@ export default function Home() {
const [metricForm, setMetricForm] = useState({ exposure: "", views: "" }); const [metricForm, setMetricForm] = useState({ exposure: "", views: "" });
const [exportingTaskId, setExportingTaskId] = useState<string | null>(null); const [exportingTaskId, setExportingTaskId] = useState<string | null>(null);
const [exportingResources, setExportingResources] = useState(false); 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 () => { const loadData = useCallback(async () => {
try { try {
@@ -399,6 +409,8 @@ export default function Home() {
const recentDistributions = data.distributions.slice(0, 6); const recentDistributions = data.distributions.slice(0, 6);
const navigate = (key: NavKey) => { const navigate = (key: NavKey) => {
if (key === "resources" && currentUser.role === "user") return;
if (key === "users" && !isManager) return;
setActiveNav(key); setActiveNav(key);
setMenuOpen(false); setMenuOpen(false);
}; };
@@ -646,6 +658,15 @@ export default function Home() {
title: "数据回收", title: "数据回收",
subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。", subtitle: "按任务设置自动采集日,集中查看最新公开数据与创作者截图。",
}, },
users: {
title: "用户管理",
subtitle: "由后台统一创建账号和重置密码,不开放自助注册与个人改密。",
},
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => null);
window.location.assign("/login");
}; };
return ( return (
@@ -661,7 +682,7 @@ export default function Home() {
<nav className="main-nav" aria-label="主导航"> <nav className="main-nav" aria-label="主导航">
<p className="nav-caption"></p> <p className="nav-caption"></p>
{NAV_ITEMS.map((item) => ( {visibleNavItems.map((item) => (
<button <button
key={item.key} key={item.key}
className={activeNav === item.key ? "active" : ""} className={activeNav === item.key ? "active" : ""}
@@ -683,14 +704,14 @@ export default function Home() {
<span>{stats.activeTasks} </span> <span>{stats.activeTasks} </span>
</div> </div>
</div> </div>
<div className="sidebar-user"> <button className="sidebar-user" onClick={() => void logout()} title="退出登录">
<div className="avatar mint"></div> <div className="avatar mint">{currentUser.username.slice(0, 1).toUpperCase()}</div>
<div> <div>
<strong></strong> <strong>{currentUser.username}</strong>
<span></span> <span>{currentUser.role === "super_admin" ? "超级管理员" : currentUser.role === "admin" ? "管理员" : "普通用户"}</span>
</div>
<span className="chevron"></span>
</div> </div>
<span className="chevron">退</span>
</button>
</aside> </aside>
{menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />} {menuOpen && <button className="sidebar-scrim" onClick={() => setMenuOpen(false)} aria-label="关闭菜单" />}
@@ -750,6 +771,7 @@ export default function Home() {
distributions={recentDistributions} distributions={recentDistributions}
tasks={data.tasks} tasks={data.tasks}
onNavigate={navigate} onNavigate={navigate}
showResources={currentUser.role !== "user"}
/> />
)} )}
{activeNav === "tasks" && ( {activeNav === "tasks" && (
@@ -774,6 +796,9 @@ export default function Home() {
onExport={exportResources} onExport={exportResources}
/> />
)} )}
{activeNav === "users" && isManager && (
<UsersPage currentUser={currentUser} />
)}
{activeNav === "recovery" && ( {activeNav === "recovery" && (
<RecoveryPage <RecoveryPage
tasks={data.tasks} tasks={data.tasks}
@@ -942,6 +967,7 @@ function OverviewPage({
distributions, distributions,
tasks, tasks,
onNavigate, onNavigate,
showResources,
}: { }: {
stats: { stats: {
resources: number; resources: number;
@@ -953,6 +979,7 @@ function OverviewPage({
distributions: Distribution[]; distributions: Distribution[];
tasks: Task[]; tasks: Task[];
onNavigate: (key: NavKey) => void; onNavigate: (key: NavKey) => void;
showResources: boolean;
}) { }) {
const cards = [ const cards = [
{ {
@@ -979,7 +1006,7 @@ function OverviewPage({
hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成", hint: stats.pendingRecovery > 0 ? "需要跟进" : "全部按时完成",
tone: "orange", tone: "orange",
}, },
]; ].filter((card) => showResources || card.label !== "已沉淀账号资源");
return ( return (
<> <>
<div className="stat-grid"> <div className="stat-grid">

View File

@@ -109,7 +109,7 @@ async function createTaskFromSource(
} }
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const body = (await request.json()) as ActionBody; const body = (await request.json()) as ActionBody;

View File

@@ -0,0 +1,47 @@
import {
createSession,
createSessionCookie,
ensureInitialSuperAdmin,
normalizeUsername,
verifyPassword,
} from "../../../../lib/user-auth";
import { getRawDb } from "../../../../lib/mvp-db";
export const runtime = "edge";
export async function POST(request: Request) {
try {
await ensureInitialSuperAdmin();
const body = (await request.json()) as { username?: string; password?: string };
const username = normalizeUsername(body.username);
const password = String(body.password ?? "");
const user = await getRawDb()
.prepare(
`SELECT id, username, role, password_hash, password_salt, password_iterations
FROM users WHERE username = ? LIMIT 1`,
)
.bind(username)
.first<{
id: string;
username: string;
role: string;
password_hash: string;
password_salt: string;
password_iterations: number;
}>();
if (!user || !(await verifyPassword(password, user))) {
return Response.json({ error: "账号或密码错误" }, { status: 401 });
}
const token = await createSession(user.id);
const secure = new URL(request.url).protocol === "https:";
return Response.json(
{ user: { id: user.id, username: user.username, role: user.role } },
{ headers: { "Set-Cookie": createSessionCookie(token, secure) } },
);
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "登录失败" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,17 @@
import {
clearSessionCookie,
deleteSession,
sessionCookieFromHeader,
} from "../../../../lib/user-auth";
export const runtime = "edge";
export async function POST(request: Request) {
const token = sessionCookieFromHeader(request.headers.get("cookie"));
await deleteSession(token);
const secure = new URL(request.url).protocol === "https:";
return Response.json(
{ loggedOut: true },
{ headers: { "Set-Cookie": clearSessionCookie(secure) } },
);
}

View File

@@ -12,12 +12,13 @@ import {
getRawDb, getRawDb,
seedIfEmpty, seedIfEmpty,
} from "../../../lib/mvp-db"; } from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
export const runtime = "edge"; export const runtime = "edge";
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); const principal = await getRequestPrincipal(request);
if (!principal) return authForbidden();
try { try {
await ensureSchema(); await ensureSchema();
await seedIfEmpty(); await seedIfEmpty();
@@ -60,7 +61,12 @@ export async function GET(request: Request) {
} else { } else {
await catchup; await catchup;
} }
return Response.json(await getDashboardData()); const dashboard = await getDashboardData();
return Response.json(
principal.kind === "user" && principal.user.role === "user"
? { ...dashboard, accounts: [] }
: dashboard,
);
} catch (error) { } catch (error) {
return Response.json( return Response.json(
{ error: error instanceof Error ? error.message : "加载失败" }, { error: error instanceof Error ? error.message : "加载失败" },

View File

@@ -5,7 +5,7 @@ import { ensureSchema, getUploadBucket } from "../../../lib/mvp-db";
export const runtime = "edge"; export const runtime = "edge";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const form = await request.formData(); const form = await request.formData();

View File

@@ -8,7 +8,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge"; export const runtime = "edge";
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const distributionId = new URL(request.url).searchParams const distributionId = new URL(request.url).searchParams

View File

@@ -218,7 +218,7 @@ function exactArrayBuffer(bytes: Uint8Array) {
} }
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const taskId = new URL(request.url).searchParams.get("task")?.trim() || ""; const taskId = new URL(request.url).searchParams.get("task")?.trim() || "";

View File

@@ -1,4 +1,4 @@
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth"; import { isManagerRequest, managerForbidden } from "../../../lib/user-auth";
import { parseStoredDate } from "../../../lib/date-utils"; import { parseStoredDate } from "../../../lib/date-utils";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db"; import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import { import {
@@ -48,7 +48,7 @@ function exactArrayBuffer(bytes: Uint8Array) {
} }
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isManagerRequest(request))) return managerForbidden();
try { try {
const body = (await request.json()) as { accountIds?: unknown }; const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) { if (!Array.isArray(body.accountIds)) {

View File

@@ -10,7 +10,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge"; export const runtime = "edge";
export async function POST(request: Request) { export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden(); if (!(await isAdminRequest(request))) return adminForbidden();
try { try {
await ensureSchema(); await ensureSchema();
const form = await request.formData(); const form = await request.formData();

138
app/api/users/route.ts Normal file
View File

@@ -0,0 +1,138 @@
import {
createPasswordRecord,
getRequestPrincipal,
managerForbidden,
normalizeUsername,
type UserRole,
validatePassword,
validateUsername,
} from "../../../lib/user-auth";
import { ensureSchema, getRawDb } from "../../../lib/mvp-db";
export const runtime = "edge";
type UserRow = {
id: string;
username: string;
role: UserRole;
created_at: string;
updated_at: string;
};
async function manager(request: Request) {
const principal = await getRequestPrincipal(request);
if (!principal || principal.kind !== "user") return null;
return ["super_admin", "admin"].includes(principal.user.role)
? principal.user
: null;
}
async function listUsers() {
const result = await getRawDb()
.prepare(
`SELECT id, username, role, created_at, updated_at
FROM users
ORDER BY CASE role WHEN 'super_admin' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END,
created_at ASC`,
)
.all<UserRow>();
return result.results;
}
export async function GET(request: Request) {
await ensureSchema();
if (!(await manager(request))) return managerForbidden();
return Response.json({ users: await listUsers() });
}
export async function POST(request: Request) {
await ensureSchema();
const currentUser = await manager(request);
if (!currentUser) return managerForbidden();
try {
const body = (await request.json()) as {
action?: string;
username?: string;
password?: string;
role?: UserRole;
userId?: string;
};
const password = String(body.password ?? "");
if (!validatePassword(password)) {
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
}
const db = getRawDb();
const passwordRecord = await createPasswordRecord(password);
if (body.action === "create") {
const username = normalizeUsername(body.username);
const role = body.role === "admin" ? "admin" : "user";
if (!validateUsername(username)) {
return Response.json(
{ error: "账号需为2—32位中文、字母、数字或 _ . @ + -" },
{ status: 400 },
);
}
if (currentUser.role !== "super_admin" && role !== "user") {
return managerForbidden();
}
await db
.prepare(
`INSERT INTO users
(id, username, password_hash, password_salt, password_iterations, role)
VALUES (?, ?, ?, ?, ?, ?)`,
)
.bind(
crypto.randomUUID(),
username,
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
role,
)
.run();
} else if (body.action === "reset_password") {
const target = await db
.prepare("SELECT id, role FROM users WHERE id = ?")
.bind(String(body.userId ?? ""))
.first<{ id: string; role: UserRole }>();
if (!target) {
return Response.json({ error: "没有找到这个账号" }, { status: 404 });
}
if (
target.role === "super_admin" &&
!(currentUser.role === "super_admin" && target.id === currentUser.id)
) {
return Response.json({ error: "不能修改其他超级管理员账号" }, { status: 403 });
}
if (currentUser.role !== "super_admin" && target.role !== "user") {
return managerForbidden();
}
await db.batch([
db
.prepare(
`UPDATE users SET
password_hash = ?, password_salt = ?, password_iterations = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
)
.bind(
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
target.id,
),
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
]);
} else {
return Response.json({ error: "不支持的操作" }, { status: 400 });
}
return Response.json({ users: await listUsers() });
} catch (error) {
const message = error instanceof Error ? error.message : "操作失败";
return Response.json(
{ error: message.includes("UNIQUE") ? "这个登录账号已存在" : message },
{ status: message.includes("UNIQUE") ? 409 : 500 },
);
}
}

View File

@@ -79,6 +79,135 @@ body {
text-decoration: none; text-decoration: none;
} }
.login-page {
display: grid;
min-height: 100vh;
grid-template-columns: minmax(0, 1.25fr) minmax(380px, 0.75fr);
gap: 28px;
align-items: stretch;
padding: 42px;
background: #eef2ee;
}
.login-brand,
.login-card {
border-radius: 28px;
}
.login-brand {
display: flex;
min-height: 620px;
flex-direction: column;
justify-content: center;
padding: clamp(42px, 7vw, 96px);
color: white;
background: linear-gradient(135deg, #102d25, #1f6f55);
box-shadow: var(--shadow);
}
.login-logo {
display: grid;
width: 54px;
height: 54px;
place-items: center;
border-radius: 16px;
color: #15553f;
background: #a9e4cc;
font-size: 23px;
font-weight: 800;
}
.login-brand p {
margin: 28px 0 6px;
color: #67d4aa;
font-size: 12px;
font-weight: 800;
letter-spacing: 0.16em;
}
.login-brand h1 {
margin: 0;
font-size: clamp(42px, 6vw, 82px);
font-weight: 520;
letter-spacing: -0.06em;
}
.login-brand span {
margin-top: 20px;
color: rgb(255 255 255 / 0.64);
font-size: 15px;
}
.login-card {
display: flex;
width: 100%;
max-width: 520px;
flex-direction: column;
align-self: center;
justify-self: center;
padding: clamp(32px, 5vw, 58px);
border: 1px solid #dfe6e1;
background: white;
box-shadow: var(--shadow);
}
.login-card h2 {
margin: 6px 0 4px;
font-size: 32px;
letter-spacing: -0.04em;
}
.login-tip {
margin-bottom: 30px;
color: var(--muted);
font-size: 12px;
}
.login-card label,
.user-create-form label {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.login-card label > span,
.user-create-form label > span {
color: #61706b;
font-size: 11px;
font-weight: 650;
}
.login-card input,
.user-create-form input,
.user-create-form select {
height: 48px;
padding: 0 14px;
border: 1px solid #dbe3de;
border-radius: 11px;
outline: none;
background: #fbfcfb;
}
.login-card input:focus,
.user-create-form input:focus,
.user-create-form select:focus {
border-color: #5fb28f;
box-shadow: 0 0 0 3px rgb(50 153 112 / 0.1);
}
.login-card .primary-button {
width: 100%;
height: 50px;
margin-top: 8px;
}
.login-error {
margin: -2px 0 10px;
color: #c45e3a;
font-size: 11px;
}
body { body {
margin: 0; margin: 0;
background: var(--canvas); background: var(--canvas);
@@ -293,6 +422,17 @@ a {
margin-top: 12px; margin-top: 12px;
padding: 10px 8px 0; padding: 10px 8px 0;
border-top: 1px solid rgb(255 255 255 / 0.06); border-top: 1px solid rgb(255 255 255 / 0.06);
border-right: 0;
border-bottom: 0;
border-left: 0;
width: 100%;
color: inherit;
background: transparent;
text-align: left;
}
.sidebar-user:hover {
background: rgb(255 255 255 / 0.035);
} }
.chevron { .chevron {
@@ -2735,6 +2875,89 @@ label small {
font-size: 9px; font-size: 9px;
} }
.user-management-layout {
display: grid;
grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.28fr);
gap: 18px;
}
.user-create-panel,
.user-list-panel {
align-self: start;
}
.user-create-form {
display: flex;
flex-direction: column;
margin-top: 24px;
}
.user-create-form .primary-button {
width: 100%;
height: 45px;
margin-top: 3px;
}
.user-management-message {
margin: 14px 0 0;
padding: 10px 12px;
border-radius: 9px;
color: #27634f;
background: #edf7f2;
font-size: 10px;
}
.user-table {
margin-top: 18px;
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
}
.user-table-head,
.user-table-row {
display: grid;
grid-template-columns: minmax(130px, 1.4fr) minmax(90px, 0.8fr) minmax(90px, 0.8fr) 82px;
gap: 12px;
align-items: center;
padding: 12px 14px;
}
.user-table-head {
color: #899490;
background: #f5f7f5;
font-size: 9px;
}
.user-table-row {
border-top: 1px solid var(--line);
font-size: 10px;
}
.user-table-row > span:not(.role-pill) {
color: #84908c;
}
.role-pill {
width: fit-content;
padding: 5px 8px;
border-radius: 999px;
color: #53625d;
background: #edf1ee;
font-size: 8px;
font-weight: 650;
}
.role-pill.super_admin {
color: #7d5a14;
background: #fff4d9;
}
.role-pill.admin {
color: #22614b;
background: #e5f5ed;
}
.toast { .toast {
position: fixed; position: fixed;
right: 24px; right: 24px;
@@ -2881,6 +3104,19 @@ label small {
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.login-page {
grid-template-columns: 1fr;
padding: 20px;
}
.login-brand {
min-height: 260px;
}
.user-management-layout {
grid-template-columns: 1fr;
}
.sidebar { .sidebar {
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 180ms ease; transition: transform 180ms ease;
@@ -2957,6 +3193,32 @@ label small {
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.login-page {
padding: 12px;
}
.login-brand {
min-height: 210px;
padding: 30px;
}
.login-brand h1 {
font-size: 40px;
}
.login-card {
padding: 28px 22px;
}
.user-table {
overflow-x: auto;
}
.user-table-head,
.user-table-row {
min-width: 560px;
}
.topbar { .topbar {
height: 62px; height: 62px;
} }

69
app/login/page.tsx Normal file
View File

@@ -0,0 +1,69 @@
"use client";
import { FormEvent, useState } from "react";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [working, setWorking] = useState(false);
const submit = async (event: FormEvent) => {
event.preventDefault();
setError("");
setWorking(true);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const result = (await response.json()) as { error?: string };
if (!response.ok) throw new Error(result.error || "登录失败");
window.location.assign("/");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "登录失败");
} finally {
setWorking(false);
}
};
return (
<main className="login-page">
<section className="login-brand">
<div className="login-logo">K</div>
<p>KOC LOOP</p>
<h1></h1>
<span>KOC </span>
</section>
<form className="login-card" onSubmit={submit}>
<p className="eyebrow"></p>
<h2></h2>
<span className="login-tip">使</span>
<label>
<span></span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
required
/>
</label>
<label>
<span></span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <p className="login-error">{error}</p>}
<button className="primary-button" disabled={working}>
{working ? "登录中…" : "登录"}
</button>
</form>
</main>
);
}

View File

@@ -1,24 +1,13 @@
import { chatGPTSignOutPath, requireChatGPTUser } from "./chatgpt-auth"; import { headers } from "next/headers";
import { redirect } from "next/navigation";
import AdminApp from "./admin-app"; import AdminApp from "./admin-app";
import { isAdminEmail } from "../lib/admin-auth"; import { getUserFromCookieHeader } from "../lib/user-auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Page() { export default async function Page() {
const user = await requireChatGPTUser("/"); const requestHeaders = await headers();
const user = await getUserFromCookieHeader(requestHeaders.get("cookie"));
if (!isAdminEmail(user.email)) { if (!user) redirect("/login");
return ( return <AdminApp currentUser={user} />;
<main className="admin-access-denied">
<section>
<p>KOC LOOP</p>
<h1></h1>
<span></span>
<a href={chatGPTSignOutPath("/")}></a>
</section>
</main>
);
}
return <AdminApp />;
} }

212
app/users-page.tsx Normal file
View File

@@ -0,0 +1,212 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import type { AuthUser, UserRole } from "../lib/user-auth";
type ManagedUser = {
id: string;
username: string;
role: UserRole;
created_at: string;
updated_at: string;
};
const ROLE_LABEL: Record<UserRole, string> = {
super_admin: "超级管理员",
admin: "管理员",
user: "普通用户",
};
async function userApi(response: Response) {
const result = (await response.json()) as { users?: ManagedUser[]; error?: string };
if (!response.ok) throw new Error(result.error || "操作失败");
return result.users ?? [];
}
export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
const [users, setUsers] = useState<ManagedUser[]>([]);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState<"admin" | "user">("user");
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [resetPassword, setResetPassword] = useState("");
const [message, setMessage] = useState("");
const [working, setWorking] = useState(false);
const loadUsers = useCallback(async () => {
try {
setUsers(await userApi(await fetch("/api/users", { cache: "no-store" })));
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "加载失败");
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => {
void loadUsers();
}, 0);
return () => window.clearTimeout(timer);
}, [loadUsers]);
const createUser = async (event: FormEvent) => {
event.preventDefault();
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "create", username, password, role }),
}),
);
setUsers(next);
setUsername("");
setPassword("");
setRole("user");
setMessage("账号已创建,可以把账号和密码交给用户登录");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "创建失败");
} finally {
setWorking(false);
}
};
const resetUserPassword = async (event: FormEvent) => {
event.preventDefault();
if (!resetTarget) return;
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "reset_password",
userId: resetTarget.id,
password: resetPassword,
}),
}),
);
setUsers(next);
setResetTarget(null);
setResetPassword("");
setMessage("密码已重置,旧登录状态已退出");
} catch (reason) {
setMessage(reason instanceof Error ? reason.message : "重置失败");
} finally {
setWorking(false);
}
};
const canReset = (item: ManagedUser) => {
if (currentUser.role === "super_admin") {
return item.role !== "super_admin" || item.id === currentUser.id;
}
return item.role === "user";
};
return (
<div className="user-management-layout">
<section className="panel user-create-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<form className="user-create-form" onSubmit={createUser}>
<label>
<span></span>
<input
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="例如linlin01"
autoComplete="off"
required
/>
</label>
<label>
<span></span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少8位"
autoComplete="new-password"
minLength={8}
required
/>
</label>
{currentUser.role === "super_admin" && (
<label>
<span></span>
<select value={role} onChange={(event) => setRole(event.target.value as "admin" | "user")}>
<option value="user"></option>
<option value="admin"></option>
</select>
</label>
)}
<button className="primary-button" disabled={working}>
{working ? "处理中…" : "创建账号"}
</button>
</form>
{message && <p className="user-management-message">{message}</p>}
</section>
<section className="panel user-list-panel">
<div className="panel-heading">
<div>
<h2></h2>
<p> {users.length} </p>
</div>
</div>
<div className="user-table">
<div className="user-table-head">
<span></span><span></span><span></span><span></span>
</div>
{users.map((item) => (
<div className="user-table-row" key={item.id}>
<strong>{item.username}</strong>
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
{canReset(item) ? (
<button className="text-button" onClick={() => { setResetTarget(item); setResetPassword(""); }}>
</button>
) : <span></span>}
</div>
))}
</div>
</section>
{resetTarget && (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setResetTarget(null)}>
<form className="modal-card compact" onSubmit={resetUserPassword} onMouseDown={(event) => event.stopPropagation()}>
<div className="modal-heading">
<div><p className="eyebrow"></p><h2> {resetTarget.username} </h2></div>
<button type="button" onClick={() => setResetTarget(null)} aria-label="关闭">×</button>
</div>
<label>
<span></span>
<input
type="password"
value={resetPassword}
onChange={(event) => setResetPassword(event.target.value)}
minLength={8}
autoComplete="new-password"
required
/>
<small>退</small>
</label>
<div className="modal-actions">
<button type="button" className="ghost-button" onClick={() => setResetTarget(null)}></button>
<button className="primary-button" disabled={working}></button>
</div>
</form>
</div>
)}
</div>
);
}

View File

@@ -186,3 +186,37 @@ export const collectionRuns = sqliteTable(
), ),
], ],
); );
export const users = sqliteTable(
"users",
{
id: text("id").primaryKey(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
passwordSalt: text("password_salt").notNull(),
passwordIterations: integer("password_iterations").notNull(),
role: text("role").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
uniqueIndex("users_username_idx").on(table.username),
uniqueIndex("users_single_super_admin_idx")
.on(table.role)
.where(sql`${table.role} = 'super_admin'`),
],
);
export const authSessions = sqliteTable(
"auth_sessions",
{
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
},
(table) => [
index("auth_sessions_user_id_idx").on(table.userId),
index("auth_sessions_expires_at_idx").on(table.expiresAt),
],
);

View File

@@ -0,0 +1,22 @@
CREATE TABLE `auth_sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE INDEX `auth_sessions_user_id_idx` ON `auth_sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `auth_sessions_expires_at_idx` ON `auth_sessions` (`expires_at`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`username` text NOT NULL,
`password_hash` text NOT NULL,
`password_salt` text NOT NULL,
`password_iterations` integer NOT NULL,
`role` text NOT NULL,
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_idx` ON `users` (`username`);--> statement-breakpoint
CREATE UNIQUE INDEX `users_single_super_admin_idx` ON `users` (`role`) WHERE "users"."role" = 'super_admin';

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,13 @@
"when": 1785380450391, "when": 1785380450391,
"tag": "0006_moaning_dark_phoenix", "tag": "0006_moaning_dark_phoenix",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1786069402457,
"tag": "0007_fantastic_sentinels",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,35 +1,4 @@
import { env } from "cloudflare:workers"; export {
authForbidden as adminForbidden,
export function isAdminEmail(input: string | null | undefined) { isAppRequest as isAdminRequest,
const allowedEmail = String( } from "./user-auth";
(env as unknown as { ADMIN_ALLOWED_EMAIL?: string }).ADMIN_ALLOWED_EMAIL ??
"",
)
.trim()
.toLowerCase();
const requestEmail = String(input ?? "").trim().toLowerCase();
return Boolean(allowedEmail && requestEmail && requestEmail === allowedEmail);
}
export function isAdminRequest(request: Request) {
if (isAdminEmail(request.headers.get("oai-authenticated-user-email"))) {
return true;
}
const expectedToken = String(
(env as unknown as { ADMIN_INTERNAL_TOKEN?: string }).ADMIN_INTERNAL_TOKEN ??
"",
).trim();
const requestToken = String(
request.headers.get("x-koc-admin-token") ?? "",
).trim();
return Boolean(
expectedToken &&
requestToken &&
expectedToken.length === requestToken.length &&
expectedToken === requestToken,
);
}
export function adminForbidden() {
return Response.json({ error: "无后台操作权限" }, { status: 403 });
}

View File

@@ -149,6 +149,22 @@ export async function ensureSchema(database?: D1Database) {
completed_at TEXT, completed_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
password_iterations INTEGER NOT NULL,
role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE IF NOT EXISTS auth_sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
]; ];
for (const statement of statements) { for (const statement of statements) {
@@ -305,6 +321,25 @@ export async function ensureSchema(database?: D1Database) {
ON collection_runs(task_id, scheduled_date)`, ON collection_runs(task_id, scheduled_date)`,
) )
.run(); .run();
await db
.prepare("CREATE UNIQUE INDEX IF NOT EXISTS users_username_idx ON users(username)")
.run();
await db
.prepare(
`CREATE UNIQUE INDEX IF NOT EXISTS users_single_super_admin_idx
ON users(role) WHERE role = 'super_admin'`,
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)",
)
.run();
await db
.prepare(
"CREATE INDEX IF NOT EXISTS auth_sessions_expires_at_idx ON auth_sessions(expires_at)",
)
.run();
await db await db
.prepare( .prepare(
`UPDATE distributions `UPDATE distributions

285
lib/user-auth.ts Normal file
View File

@@ -0,0 +1,285 @@
import { env } from "cloudflare:workers";
import { ensureSchema, getRawDb } from "./mvp-db";
export type UserRole = "super_admin" | "admin" | "user";
export type AuthUser = {
id: string;
username: string;
role: UserRole;
};
export type RequestPrincipal =
| { kind: "internal" }
| { kind: "user"; user: AuthUser };
const SESSION_COOKIE = "koc_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
const PASSWORD_ITERATIONS = 120_000;
function bytesToHex(bytes: Uint8Array) {
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
}
function hexToBytes(value: string) {
if (!/^(?:[0-9a-f]{2})+$/i.test(value)) return new Uint8Array();
return new Uint8Array(value.match(/.{2}/g)?.map((part) => Number.parseInt(part, 16)) ?? []);
}
function utf8(value: string) {
return new TextEncoder().encode(value);
}
async function sha256(value: string) {
return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8(value))));
}
async function derivePasswordHash(
password: string,
saltHex: string,
iterations: number,
) {
const key = await crypto.subtle.importKey(
"raw",
utf8(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
hash: "SHA-256",
salt: hexToBytes(saltHex),
iterations,
},
key,
256,
);
return bytesToHex(new Uint8Array(bits));
}
function safeEqual(left: string, right: string) {
if (left.length !== right.length) return false;
let mismatch = 0;
for (let index = 0; index < left.length; index += 1) {
mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return mismatch === 0;
}
export function normalizeUsername(value: unknown) {
return String(value ?? "").normalize("NFKC").trim().toLowerCase();
}
export function validateUsername(value: string) {
return /^[\p{L}\p{N}_.@+-]{2,32}$/u.test(value);
}
export function validatePassword(value: string) {
return value.length >= 8 && value.length <= 72;
}
export async function createPasswordRecord(password: string) {
const salt = new Uint8Array(16);
crypto.getRandomValues(salt);
const passwordSalt = bytesToHex(salt);
return {
passwordHash: await derivePasswordHash(
password,
passwordSalt,
PASSWORD_ITERATIONS,
),
passwordSalt,
passwordIterations: PASSWORD_ITERATIONS,
};
}
export async function verifyPassword(
password: string,
record: {
password_hash: string;
password_salt: string;
password_iterations: number;
},
) {
const candidate = await derivePasswordHash(
password,
record.password_salt,
record.password_iterations,
);
return safeEqual(candidate, record.password_hash);
}
function getAuthEnv() {
return env as unknown as {
SUPER_ADMIN_USERNAME?: string;
SUPER_ADMIN_PASSWORD?: string;
ADMIN_INTERNAL_TOKEN?: string;
};
}
export async function ensureInitialSuperAdmin() {
await ensureSchema();
const db = getRawDb();
const existing = await db
.prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
.first<{ id: string }>();
if (existing) return;
const username = normalizeUsername(getAuthEnv().SUPER_ADMIN_USERNAME);
const password = String(getAuthEnv().SUPER_ADMIN_PASSWORD ?? "");
if (!validateUsername(username) || !validatePassword(password)) {
throw new Error("超级管理员账号尚未配置,请设置 SUPER_ADMIN_USERNAME 和 SUPER_ADMIN_PASSWORD");
}
const conflict = await db
.prepare("SELECT id FROM users WHERE username = ?")
.bind(username)
.first<{ id: string }>();
if (conflict) {
throw new Error("超级管理员用户名已被占用,请更换 SUPER_ADMIN_USERNAME");
}
const passwordRecord = await createPasswordRecord(password);
try {
await db
.prepare(
`INSERT INTO users
(id, username, password_hash, password_salt, password_iterations, role)
VALUES (?, ?, ?, ?, ?, 'super_admin')`,
)
.bind(
crypto.randomUUID(),
username,
passwordRecord.passwordHash,
passwordRecord.passwordSalt,
passwordRecord.passwordIterations,
)
.run();
} catch {
const concurrent = await db
.prepare("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
.first<{ id: string }>();
if (!concurrent) throw new Error("超级管理员初始化失败");
}
}
export function sessionCookieFromHeader(cookieHeader: string | null) {
const match = String(cookieHeader ?? "")
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${SESSION_COOKIE}=`));
return match ? decodeURIComponent(match.slice(SESSION_COOKIE.length + 1)) : "";
}
export function createSessionCookie(token: string, secure = true) {
return [
`${SESSION_COOKIE}=${encodeURIComponent(token)}`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
secure ? "Secure" : "",
`Max-Age=${SESSION_MAX_AGE_SECONDS}`,
]
.filter(Boolean)
.join("; ");
}
export function clearSessionCookie(secure = true) {
return [
`${SESSION_COOKIE}=`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
secure ? "Secure" : "",
"Max-Age=0",
]
.filter(Boolean)
.join("; ");
}
export async function createSession(userId: string) {
const tokenBytes = new Uint8Array(32);
crypto.getRandomValues(tokenBytes);
const token = bytesToHex(tokenBytes);
const tokenHash = await sha256(token);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SECONDS * 1000).toISOString();
const db = getRawDb();
await db.batch([
db
.prepare("DELETE FROM auth_sessions WHERE expires_at <= ?")
.bind(new Date().toISOString()),
db
.prepare(
`INSERT INTO auth_sessions (token_hash, user_id, expires_at)
VALUES (?, ?, ?)`,
)
.bind(tokenHash, userId, expiresAt),
]);
return token;
}
export async function deleteSession(token: string) {
if (!token) return;
await ensureSchema();
await getRawDb()
.prepare("DELETE FROM auth_sessions WHERE token_hash = ?")
.bind(await sha256(token))
.run();
}
export async function getUserFromSessionToken(token: string): Promise<AuthUser | null> {
if (!token) return null;
await ensureSchema();
const row = await getRawDb()
.prepare(
`SELECT u.id, u.username, u.role
FROM auth_sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > ?
LIMIT 1`,
)
.bind(await sha256(token), new Date().toISOString())
.first<{ id: string; username: string; role: string }>();
if (!row || !["super_admin", "admin", "user"].includes(row.role)) return null;
return { id: row.id, username: row.username, role: row.role as UserRole };
}
export async function getUserFromCookieHeader(cookieHeader: string | null) {
return getUserFromSessionToken(sessionCookieFromHeader(cookieHeader));
}
function hasInternalToken(request: Request) {
const expected = String(getAuthEnv().ADMIN_INTERNAL_TOKEN ?? "").trim();
const received = String(request.headers.get("x-koc-admin-token") ?? "").trim();
return Boolean(expected && received && safeEqual(expected, received));
}
export async function getRequestPrincipal(
request: Request,
): Promise<RequestPrincipal | null> {
if (hasInternalToken(request)) return { kind: "internal" };
const user = await getUserFromCookieHeader(request.headers.get("cookie"));
return user ? { kind: "user", user } : null;
}
export async function isAppRequest(request: Request) {
return Boolean(await getRequestPrincipal(request));
}
export async function isManagerRequest(request: Request) {
const principal = await getRequestPrincipal(request);
return Boolean(
principal &&
(principal.kind === "internal" ||
principal.user.role === "super_admin" ||
principal.user.role === "admin"),
);
}
export function authForbidden() {
return Response.json({ error: "请先登录后再操作" }, { status: 401 });
}
export function managerForbidden() {
return Response.json({ error: "当前账号没有管理权限" }, { status: 403 });
}

View File

@@ -8,8 +8,9 @@ test("builds the KOC LOOP product shell", async () => {
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"), readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"),
]); ]);
assert.match(page, /requireChatGPTUser/); assert.match(page, /getUserFromCookieHeader/);
assert.match(page, /isAdminEmail/); assert.match(page, /redirect\("\/login"\)/);
assert.match(page, /currentUser=\{user\}/);
assert.match(adminApp, /KOC LOOP/); assert.match(adminApp, /KOC LOOP/);
assert.match(adminApp, /内容分发闭环/); assert.match(adminApp, /内容分发闭环/);
assert.match(adminApp, /分发工作台/); assert.match(adminApp, /分发工作台/);
@@ -250,6 +251,63 @@ test("exports complete task recovery data to Excel with embedded images", async
assert.match(workbook, /oneCellAnchor/); assert.match(workbook, /oneCellAnchor/);
}); });
test("provides simple username-password login and three server-enforced roles", async () => {
const [
adminApp,
usersPage,
loginPage,
loginRoute,
logoutRoute,
usersRoute,
auth,
bootstrapRoute,
resourceExportRoute,
schema,
runtimeSchema,
migration,
] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/login/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/auth/login/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/auth/logout/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/users/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/user-auth.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/bootstrap/route.ts", import.meta.url), "utf8"),
readFile(new URL("../app/api/resources-export/route.ts", import.meta.url), "utf8"),
readFile(new URL("../db/schema.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/mvp-db.ts", import.meta.url), "utf8"),
readFile(new URL("../drizzle/0007_fantastic_sentinels.sql", import.meta.url), "utf8"),
]);
assert.match(loginPage, /登录账号/);
assert.match(loginPage, /当前|管理员分配|账号和密码/);
assert.match(loginRoute, /ensureInitialSuperAdmin/);
assert.match(loginRoute, /verifyPassword/);
assert.match(loginRoute, /Set-Cookie/);
assert.match(logoutRoute, /deleteSession/);
assert.match(logoutRoute, /clearSessionCookie/);
assert.match(auth, /PBKDF2/);
assert.match(auth, /HttpOnly/);
assert.match(auth, /SameSite=Lax/);
assert.match(auth, /SELECT id FROM users WHERE role = 'super_admin' LIMIT 1/);
assert.match(usersRoute, /currentUser\.role !== "super_admin" && role !== "user"/);
assert.match(usersRoute, /DELETE FROM auth_sessions WHERE user_id =/);
assert.match(usersPage, /不开放注册和个人改密/);
assert.match(usersPage, /普通用户/);
assert.match(usersPage, /管理员/);
assert.match(adminApp, /item\.key === "resources" && currentUser\.role === "user"/);
assert.match(adminApp, /item\.key === "users" && !isManager/);
assert.match(bootstrapRoute, /principal\.user\.role === "user"/);
assert.match(bootstrapRoute, /accounts: \[\]/);
assert.match(resourceExportRoute, /isManagerRequest/);
assert.match(schema, /authSessions/);
assert.match(schema, /users_single_super_admin_idx/);
assert.match(runtimeSchema, /CREATE TABLE IF NOT EXISTS auth_sessions/);
assert.match(migration, /CREATE TABLE `users`/);
assert.match(migration, /users_single_super_admin_idx/);
});
test("filters and exports the current KOC resource result set", async () => { test("filters and exports the current KOC resource result set", async () => {
const [adminApp, exportRoute, workbook] = await Promise.all([ const [adminApp, exportRoute, workbook] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"), readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
@@ -267,7 +325,7 @@ test("filters and exports the current KOC resource result set", async () => {
assert.match(exportRoute, /小红书号\/抖音号/); assert.match(exportRoute, /小红书号\/抖音号/);
assert.match(exportRoute, /历史合作来源/); assert.match(exportRoute, /历史合作来源/);
assert.match(exportRoute, /合作社资源 · 不可直联/); assert.match(exportRoute, /合作社资源 · 不可直联/);
assert.match(exportRoute, /isAdminRequest/); assert.match(exportRoute, /isManagerRequest/);
assert.match(workbook, /relationships\/hyperlink/); assert.match(workbook, /relationships\/hyperlink/);
}); });