Files
koc-loop/app/users-page.tsx

313 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 [deleteTarget, setDeleteTarget] = useState<ManagedUser | null>(null);
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 deleteUser = async () => {
if (!deleteTarget) return;
setWorking(true);
setMessage("");
try {
const next = await userApi(
await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "delete",
userId: deleteTarget.id,
}),
}),
);
setUsers(next);
setDeleteTarget(null);
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";
};
const canDelete = (item: ManagedUser) => {
if (item.id === currentUser.id || item.role === "super_admin") return false;
return currentUser.role === "super_admin" || 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 ${currentUser.role === "super_admin" ? "with-role" : "without-role"}`}
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 className="user-role-field">
<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>
<div className="user-row-actions">
{canReset(item) && (
<button
className="user-action-button"
onClick={() => { setResetTarget(item); setResetPassword(""); }}
>
重置密码
</button>
)}
{canDelete(item) && (
<button
className="user-action-button danger"
onClick={() => setDeleteTarget(item)}
>
删除账号
</button>
)}
{!canReset(item) && !canDelete(item) && <span>—</span>}
</div>
</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>
)}
{deleteTarget && (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => { if (!working) setDeleteTarget(null); }}
>
<div
className="modal-card compact user-delete-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-user-title"
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<div>
<p className="eyebrow danger">删除账号</p>
<h2 id="delete-user-title">确认删除 {deleteTarget.username}?</h2>
</div>
<button
type="button"
onClick={() => setDeleteTarget(null)}
aria-label="关闭"
disabled={working}
>
×
</button>
</div>
<p className="user-delete-warning">
删除后该账号会立即退出所有设备,且无法再次登录。此操作不可撤销。
</p>
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setDeleteTarget(null)}
disabled={working}
>
取消
</button>
<button
type="button"
className="danger-button"
onClick={() => void deleteUser()}
disabled={working}
>
{working ? "删除中…" : "确认删除"}
</button>
</div>
</div>
</div>
)}
</div>
);
}