Add user authentication and role management
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
68
README.md
68
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`.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
47
app/api/auth/login/route.ts
Normal file
47
app/api/auth/login/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/api/auth/logout/route.ts
Normal file
17
app/api/auth/logout/route.ts
Normal 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) } },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 : "加载失败" },
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() || "";
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
138
app/api/users/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
262
app/globals.css
262
app/globals.css
@@ -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
69
app/login/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/page.tsx
25
app/page.tsx
@@ -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
212
app/users-page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
db/schema.ts
34
db/schema.ts
@@ -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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
22
drizzle/0007_fantastic_sentinels.sql
Normal file
22
drizzle/0007_fantastic_sentinels.sql
Normal 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';
|
||||||
1102
drizzle/meta/0007_snapshot.json
Normal file
1102
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
285
lib/user-auth.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user