feat: optimize user management and add account deletion
This commit is contained in:
@@ -57,14 +57,14 @@ export async function POST(request: Request) {
|
|||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
const db = getRawDb();
|
||||||
|
|
||||||
|
if (body.action === "create") {
|
||||||
const password = String(body.password ?? "");
|
const password = String(body.password ?? "");
|
||||||
if (!validatePassword(password)) {
|
if (!validatePassword(password)) {
|
||||||
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
|
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
|
||||||
}
|
}
|
||||||
const db = getRawDb();
|
|
||||||
const passwordRecord = await createPasswordRecord(password);
|
const passwordRecord = await createPasswordRecord(password);
|
||||||
|
|
||||||
if (body.action === "create") {
|
|
||||||
const username = normalizeUsername(body.username);
|
const username = normalizeUsername(body.username);
|
||||||
const role = body.role === "admin" ? "admin" : "user";
|
const role = body.role === "admin" ? "admin" : "user";
|
||||||
if (!validateUsername(username)) {
|
if (!validateUsername(username)) {
|
||||||
@@ -92,6 +92,11 @@ export async function POST(request: Request) {
|
|||||||
)
|
)
|
||||||
.run();
|
.run();
|
||||||
} else if (body.action === "reset_password") {
|
} else if (body.action === "reset_password") {
|
||||||
|
const password = String(body.password ?? "");
|
||||||
|
if (!validatePassword(password)) {
|
||||||
|
return Response.json({ error: "密码需为8—72位" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const passwordRecord = await createPasswordRecord(password);
|
||||||
const target = await db
|
const target = await db
|
||||||
.prepare("SELECT id, role FROM users WHERE id = ?")
|
.prepare("SELECT id, role FROM users WHERE id = ?")
|
||||||
.bind(String(body.userId ?? ""))
|
.bind(String(body.userId ?? ""))
|
||||||
@@ -124,6 +129,27 @@ export async function POST(request: Request) {
|
|||||||
),
|
),
|
||||||
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
|
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
|
||||||
]);
|
]);
|
||||||
|
} else if (body.action === "delete") {
|
||||||
|
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.id === currentUser.id) {
|
||||||
|
return Response.json({ error: "不能删除当前登录账号" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (target.role === "super_admin") {
|
||||||
|
return Response.json({ error: "不能删除超级管理员账号" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (currentUser.role !== "super_admin" && target.role !== "user") {
|
||||||
|
return managerForbidden();
|
||||||
|
}
|
||||||
|
await db.batch([
|
||||||
|
db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(target.id),
|
||||||
|
db.prepare("DELETE FROM users WHERE id = ? AND role <> 'super_admin'").bind(target.id),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
return Response.json({ error: "不支持的操作" }, { status: 400 });
|
return Response.json({ error: "不支持的操作" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
135
app/globals.css
135
app/globals.css
@@ -2877,25 +2877,42 @@ label small {
|
|||||||
|
|
||||||
.user-management-layout {
|
.user-management-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 0.72fr) minmax(0, 1.28fr);
|
grid-template-columns: 1fr;
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-create-panel,
|
.user-create-panel,
|
||||||
.user-list-panel {
|
.user-list-panel {
|
||||||
align-self: start;
|
align-self: start;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-create-form {
|
.user-create-form {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-direction: column;
|
gap: 12px;
|
||||||
margin-top: 24px;
|
align-items: end;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-form.with-role {
|
||||||
|
grid-template-columns: minmax(190px, 1.15fr) minmax(190px, 1fr) minmax(150px, 0.72fr) 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-form.without-role {
|
||||||
|
grid-template-columns: minmax(190px, 1fr) minmax(190px, 1fr) 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-form label {
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-create-form .primary-button {
|
.user-create-form .primary-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 45px;
|
height: 40px;
|
||||||
margin-top: 3px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-management-message {
|
.user-management-message {
|
||||||
@@ -2908,7 +2925,9 @@ label small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-table {
|
.user-table {
|
||||||
margin-top: 18px;
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2917,10 +2936,10 @@ label small {
|
|||||||
.user-table-head,
|
.user-table-head,
|
||||||
.user-table-row {
|
.user-table-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(130px, 1.4fr) minmax(90px, 0.8fr) minmax(90px, 0.8fr) 82px;
|
grid-template-columns: minmax(160px, 1.4fr) minmax(100px, 0.7fr) minmax(110px, 0.75fr) minmax(180px, auto);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 12px 14px;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-table-head {
|
.user-table-head {
|
||||||
@@ -2934,6 +2953,10 @@ label small {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-table-row:hover {
|
||||||
|
background: #fbfcfa;
|
||||||
|
}
|
||||||
|
|
||||||
.user-table-row > span:not(.role-pill) {
|
.user-table-row > span:not(.role-pill) {
|
||||||
color: #84908c;
|
color: #84908c;
|
||||||
}
|
}
|
||||||
@@ -2958,6 +2981,77 @@ label small {
|
|||||||
background: #e5f5ed;
|
background: #e5f5ed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-action-button {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #dce5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--green-deep);
|
||||||
|
background: white;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-action-button:hover {
|
||||||
|
border-color: #a8cbbd;
|
||||||
|
background: #f4faf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-action-button.danger {
|
||||||
|
border-color: #efd7d3;
|
||||||
|
color: #a8473b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-action-button.danger:hover {
|
||||||
|
border-color: #dfb5ae;
|
||||||
|
background: #fff7f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 38px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 15px;
|
||||||
|
border: 1px solid #a83f34;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: white;
|
||||||
|
background: #b94d41;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button:hover {
|
||||||
|
background: #9f3e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow.danger {
|
||||||
|
color: #b04b40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-delete-warning {
|
||||||
|
margin: 0;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid #f0dedb;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #7f5049;
|
||||||
|
background: #fff8f6;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 24px;
|
right: 24px;
|
||||||
@@ -3101,6 +3195,15 @@ label small {
|
|||||||
.schedule-grid {
|
.schedule-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-create-form.with-role,
|
||||||
|
.user-create-form.without-role {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-form .primary-button {
|
||||||
|
align-self: end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -3216,7 +3319,17 @@ label small {
|
|||||||
|
|
||||||
.user-table-head,
|
.user-table-head,
|
||||||
.user-table-row {
|
.user-table-row {
|
||||||
min-width: 560px;
|
min-width: 660px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-panel,
|
||||||
|
.user-list-panel {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-create-form.with-role,
|
||||||
|
.user-create-form.without-role {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
const [role, setRole] = useState<"admin" | "user">("user");
|
const [role, setRole] = useState<"admin" | "user">("user");
|
||||||
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
|
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
|
||||||
const [resetPassword, setResetPassword] = useState("");
|
const [resetPassword, setResetPassword] = useState("");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ManagedUser | null>(null);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [working, setWorking] = useState(false);
|
const [working, setWorking] = useState(false);
|
||||||
|
|
||||||
@@ -100,6 +101,31 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const canReset = (item: ManagedUser) => {
|
||||||
if (currentUser.role === "super_admin") {
|
if (currentUser.role === "super_admin") {
|
||||||
return item.role !== "super_admin" || item.id === currentUser.id;
|
return item.role !== "super_admin" || item.id === currentUser.id;
|
||||||
@@ -107,6 +133,11 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
return item.role === "user";
|
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 (
|
return (
|
||||||
<div className="user-management-layout">
|
<div className="user-management-layout">
|
||||||
<section className="panel user-create-panel">
|
<section className="panel user-create-panel">
|
||||||
@@ -116,7 +147,10 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<p>账号密码由后台统一分配,不开放注册和个人改密。</p>
|
<p>账号密码由后台统一分配,不开放注册和个人改密。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form className="user-create-form" onSubmit={createUser}>
|
<form
|
||||||
|
className={`user-create-form ${currentUser.role === "super_admin" ? "with-role" : "without-role"}`}
|
||||||
|
onSubmit={createUser}
|
||||||
|
>
|
||||||
<label>
|
<label>
|
||||||
<span>登录账号</span>
|
<span>登录账号</span>
|
||||||
<input
|
<input
|
||||||
@@ -140,7 +174,7 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{currentUser.role === "super_admin" && (
|
{currentUser.role === "super_admin" && (
|
||||||
<label>
|
<label className="user-role-field">
|
||||||
<span>角色</span>
|
<span>角色</span>
|
||||||
<select value={role} onChange={(event) => setRole(event.target.value as "admin" | "user")}>
|
<select value={role} onChange={(event) => setRole(event.target.value as "admin" | "user")}>
|
||||||
<option value="user">普通用户</option>
|
<option value="user">普通用户</option>
|
||||||
@@ -171,11 +205,25 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
<strong>{item.username}</strong>
|
<strong>{item.username}</strong>
|
||||||
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
|
<span className={`role-pill ${item.role}`}>{ROLE_LABEL[item.role]}</span>
|
||||||
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
|
<span>{new Date(item.updated_at).toLocaleDateString("zh-CN", { timeZone: "Asia/Shanghai" })}</span>
|
||||||
{canReset(item) ? (
|
<div className="user-row-actions">
|
||||||
<button className="text-button" onClick={() => { setResetTarget(item); setResetPassword(""); }}>
|
{canReset(item) && (
|
||||||
|
<button
|
||||||
|
className="user-action-button"
|
||||||
|
onClick={() => { setResetTarget(item); setResetPassword(""); }}
|
||||||
|
>
|
||||||
重置密码
|
重置密码
|
||||||
</button>
|
</button>
|
||||||
) : <span>—</span>}
|
)}
|
||||||
|
{canDelete(item) && (
|
||||||
|
<button
|
||||||
|
className="user-action-button danger"
|
||||||
|
onClick={() => setDeleteTarget(item)}
|
||||||
|
>
|
||||||
|
删除账号
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!canReset(item) && !canDelete(item) && <span>—</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +255,58 @@ export default function UsersPage({ currentUser }: { currentUser: AuthUser }) {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
design-qa.md
Normal file
50
design-qa.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# KOC LOOP 用户管理页设计 QA
|
||||||
|
|
||||||
|
- Source visual truth: `/var/folders/0g/8yrmts9s7v5g_xc60sxx0__80000gn/T/codex-clipboard-d06a2293-9e7d-4471-b4ae-5eee942b68e9.png`
|
||||||
|
- Implementation screenshot: `/private/tmp/koc-user-management-final.jpg`
|
||||||
|
- Delete confirmation screenshot: `/private/tmp/koc-user-delete-modal.jpg`
|
||||||
|
- Viewport: desktop `1280 × 720` CSS px; responsive check `680 × 900` CSS px
|
||||||
|
- Pixels and density: source `2738 × 1382`; implementation `1280 × 720`; browser reported `devicePixelRatio = 2`; comparison used the visible layout and computed CSS geometry rather than pixel-perfect scaling because the requested result intentionally changes the source from side-by-side to stacked sections.
|
||||||
|
- State: logged-in super administrator on the user-management screen
|
||||||
|
|
||||||
|
## Full-view comparison evidence
|
||||||
|
|
||||||
|
The source places account creation and the account list side by side, forcing a tall narrow form and compressing the list. The implementation intentionally stacks the sections: the account-creation card spans the page and uses one compact horizontal row on desktop; the account list spans the full row below it. Existing KOC LOOP navigation, typography, colors, border treatment, and panel radius remain unchanged.
|
||||||
|
|
||||||
|
## Focused-region comparison evidence
|
||||||
|
|
||||||
|
The delete confirmation modal was checked separately. It uses the existing modal shell, a restrained destructive color, explicit irreversible-action copy, cancel and confirm actions, and a disabled working state. No new image assets are present on this screen.
|
||||||
|
|
||||||
|
## Required fidelity surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: existing product font stack, heading hierarchy, weights, and field labels are preserved; passed.
|
||||||
|
- Spacing and layout rhythm: panel padding, 16 px vertical section gap, 12 px form gap, and 14 px table rows establish a clearer rhythm; passed after responsive overflow fix.
|
||||||
|
- Colors and visual tokens: existing green, canvas, line, and panel tokens are preserved; destructive actions use a muted red semantic treatment; passed.
|
||||||
|
- Image quality and asset fidelity: this screen has no content imagery or custom visual assets; not applicable.
|
||||||
|
- Copy and content: creation, reset, and deletion copy is concise; deletion clearly states immediate sign-out and irreversibility; passed.
|
||||||
|
|
||||||
|
## Interaction verification
|
||||||
|
|
||||||
|
- Created a local ordinary test account.
|
||||||
|
- Opened the row-level delete confirmation.
|
||||||
|
- Confirmed deletion and verified the row disappeared.
|
||||||
|
- Verified the current super-administrator row has no delete action.
|
||||||
|
- Verified browser console errors: none.
|
||||||
|
- Verified desktop page horizontal overflow: none (`scrollWidth = innerWidth = 1280`).
|
||||||
|
- Verified 680 px responsive page horizontal overflow: none (`scrollWidth = innerWidth = 680`); the table scrolls inside its own container.
|
||||||
|
|
||||||
|
## Comparison history
|
||||||
|
|
||||||
|
1. Initial responsive pass found the account table's minimum width expanding the parent grid at 680 px.
|
||||||
|
2. Added `min-width: 0` to the stacked layout and panels, and constrained the table to its panel.
|
||||||
|
3. Post-fix evidence: page `scrollWidth` reduced from `714` to `680`, matching the viewport; the table retains an internal `660` px scroll surface.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
No actionable P0, P1, or P2 issues remain.
|
||||||
|
|
||||||
|
## Follow-up polish
|
||||||
|
|
||||||
|
No blocking polish items. A future iteration may add search when the account count grows substantially.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
@@ -23,6 +23,23 @@ test("builds the KOC LOOP product shell", async () => {
|
|||||||
await access(new URL("../dist/client/assets", import.meta.url));
|
await access(new URL("../dist/client/assets", import.meta.url));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stacks user management and securely removes departed accounts", async () => {
|
||||||
|
const [usersPage, usersRoute, globalCss] = await Promise.all([
|
||||||
|
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/api/users/route.ts", import.meta.url), "utf8"),
|
||||||
|
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.match(globalCss, /\.user-management-layout\s*\{[^}]*grid-template-columns:\s*1fr/s);
|
||||||
|
assert.match(usersPage, /删除账号/);
|
||||||
|
assert.match(usersPage, /role="alertdialog"/);
|
||||||
|
assert.match(usersRoute, /body\.action === "delete"/);
|
||||||
|
assert.match(usersRoute, /不能删除当前登录账号/);
|
||||||
|
assert.match(usersRoute, /不能删除超级管理员账号/);
|
||||||
|
assert.match(usersRoute, /DELETE FROM auth_sessions WHERE user_id = \?/);
|
||||||
|
assert.match(usersRoute, /DELETE FROM users WHERE id = \? AND role <> 'super_admin'/);
|
||||||
|
});
|
||||||
|
|
||||||
test("ships persistence, uploads, metadata, and no starter preview", async () => {
|
test("ships persistence, uploads, metadata, and no starter preview", async () => {
|
||||||
const [adminApp, layout, packageJson, hosting] = await Promise.all([
|
const [adminApp, layout, packageJson, hosting] = 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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user