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

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

View File

@@ -109,7 +109,7 @@ async function createTaskFromSource(
}
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
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,
seedIfEmpty,
} from "../../../lib/mvp-db";
import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
import { authForbidden, getRequestPrincipal } from "../../../lib/user-auth";
export const runtime = "edge";
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
const principal = await getRequestPrincipal(request);
if (!principal) return authForbidden();
try {
await ensureSchema();
await seedIfEmpty();
@@ -60,7 +61,12 @@ export async function GET(request: Request) {
} else {
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) {
return Response.json(
{ error: error instanceof Error ? error.message : "加载失败" },

View File

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

View File

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

View File

@@ -218,7 +218,7 @@ function exactArrayBuffer(bytes: Uint8Array) {
}
export async function GET(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
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 { ensureSchema, getRawDb } from "../../../lib/mvp-db";
import {
@@ -48,7 +48,7 @@ function exactArrayBuffer(bytes: Uint8Array) {
}
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isManagerRequest(request))) return managerForbidden();
try {
const body = (await request.json()) as { accountIds?: unknown };
if (!Array.isArray(body.accountIds)) {

View File

@@ -10,7 +10,7 @@ import { adminForbidden, isAdminRequest } from "../../../lib/admin-auth";
export const runtime = "edge";
export async function POST(request: Request) {
if (!isAdminRequest(request)) return adminForbidden();
if (!(await isAdminRequest(request))) return adminForbidden();
try {
await ensureSchema();
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;
}
.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 {
margin: 0;
background: var(--canvas);
@@ -293,6 +422,17 @@ a {
margin-top: 12px;
padding: 10px 8px 0;
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 {
@@ -2735,6 +2875,89 @@ label small {
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 {
position: fixed;
right: 24px;
@@ -2881,6 +3104,19 @@ label small {
}
@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 {
transform: translateX(-100%);
transition: transform 180ms ease;
@@ -2957,6 +3193,32 @@ label small {
}
@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 {
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 { isAdminEmail } from "../lib/admin-auth";
import { getUserFromCookieHeader } from "../lib/user-auth";
export const dynamic = "force-dynamic";
export default async function Page() {
const user = await requireChatGPTUser("/");
if (!isAdminEmail(user.email)) {
return (
<main className="admin-access-denied">
<section>
<p>KOC LOOP</p>
<h1></h1>
<span></span>
<a href={chatGPTSignOutPath("/")}></a>
</section>
</main>
);
}
return <AdminApp />;
const requestHeaders = await headers();
const user = await getUserFromCookieHeader(requestHeaders.get("cookie"));
if (!user) redirect("/login");
return <AdminApp currentUser={user} />;
}

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>
);
}