fix(panel): 本人与所有者账户的禁用和删除按钮提前锁定并说明原因,角色改为只读,后端拒绝改用专用错误码 self_protected 与 owner_protected

This commit is contained in:
Lemon-miaow committed 2026-09-25 13:20:01 +08:00
1 parent dee4d87fd1
commit fde677c07e
13 files changed
+269 -27

No files matched your search

+20 -3
View File
@@ -1170,7 +1170,16 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
return true;
}
if (ctx.account.id === acc.id && body.role !== ctx.account.role) {
sendError(ctx.res, 403, "forbidden", "cannot change your own role");
sendError(ctx.res, 403, "self_protected", "cannot change your own role");
return true;
}
if (acc.role === "owner") {
sendError(
ctx.res,
403,
"owner_protected",
"the owner account's role cannot be changed from the panel"
);
return true;
}
acc.role = body.role;
@@ -1196,7 +1205,11 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
// DELETE /api/v1/users/{id}
if (is("DELETE", ctx) && !subAction) {
if (ctx.account.id === acc.id) {
sendError(ctx.res, 403, "forbidden", "cannot delete your own account");
sendError(ctx.res, 403, "self_protected", "cannot delete your own account");
return true;
}
if (acc.role === "owner") {
sendError(ctx.res, 403, "owner_protected", "the owner account cannot be deleted from the panel");
return true;
}
const activeServers = ctx.state.servers.filter(
@@ -1226,10 +1239,14 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
// POST /api/v1/users/{id}/disable
if (is("POST", ctx) && subAction === "disable") {
if (ctx.account.id === acc.id) {
sendError(ctx.res, 403, "forbidden", "cannot disable your own account");
sendError(ctx.res, 403, "self_protected", "cannot disable your own account");
return true;
}
const body = await readJSON<{ disabled: boolean }>(ctx.req);
if (acc.role === "owner" && body.disabled) {
sendError(ctx.res, 403, "owner_protected", "the owner account cannot be disabled from the panel");
return true;
}
acc.disabled = !!body.disabled;
acc.updated_at = new Date().toISOString();
sendJSON(ctx.res, 200, { id: `mock-${acc.id}`, disabled: acc.disabled });
@@ -172,6 +172,8 @@
"users_field_username": "Username",
"users_field_email": "Email",
"users_field_role": "Role",
"users_role_locked_self": "You can't change your own role.",
"users_role_locked_owner": "The owner's role is fixed. Only the host's break-glass console (sudo felis breakGlass) manages the owner.",
"users_save_btn": "Save Changes",
"users_save_ok": "Changes saved successfully.",
"users_linked_accounts": "Linked Minecraft Accounts",
@@ -203,6 +205,8 @@
"users_session_revoke_all_dlg_desc": "Are you sure you want to revoke all active sessions? The user will be logged out from every device.",
"users_session_revoke_confirm": "Revoke",
"users_danger_zone": "Danger Zone",
"users_protected_self": "You can't disable or delete the account you're signed in with.",
"users_protected_owner": "The owner account can't be disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.",
"users_danger_disable": "Disable User",
"users_danger_disable_desc": "Prevent this user from logging in. All active sessions will be revoked immediately.",
"users_danger_disable_btn": "Disable User",
@@ -27,6 +27,8 @@
"restore_unavailable": "Restore isn't available right now — try again later.",
"session_expired": "Your session expired — please sign in again.",
"forbidden": "You are not allowed to do that.",
"self_protected": "You can't do that to the account you're signed in with.",
"owner_protected": "The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.",
"generic": "Something went wrong.",
"otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
"otp_locked": "Too many incorrect attempts, please request a new verification code.",
@@ -172,6 +172,8 @@
"users_field_username": "用户名",
"users_field_email": "邮箱",
"users_field_role": "角色",
"users_role_locked_self": "不能修改自己的角色。",
"users_role_locked_owner": "所有者的角色是固定的,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
"users_save_btn": "保存更改",
"users_save_ok": "更改保存成功。",
"users_linked_accounts": "已关联的 Minecraft 账号",
@@ -203,6 +205,8 @@
"users_session_revoke_all_dlg_desc": "确定撤销所有活跃会话吗?用户将从所有设备登出。",
"users_session_revoke_confirm": "撤销",
"users_danger_zone": "危险操作区",
"users_protected_self": "不能禁用或删除当前登录的账号。",
"users_protected_owner": "所有者账号不能在面板里禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
"users_danger_disable": "禁用用户",
"users_danger_disable_desc": "阻止此用户登录。所有活跃会话将被立即撤销。",
"users_danger_disable_btn": "禁用用户",
@@ -27,6 +27,8 @@
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
"session_expired": "会话已过期——请重新登录。",
"forbidden": "你无权执行此操作。",
"self_protected": "不能对当前登录的账号执行此操作。",
"owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
"generic": "出了点问题,请稍后重试。",
"otp_resend_cooldown": "验证码发送频繁,请稍后再试。",
"otp_locked": "验证码错误次数过多,请重新获取验证码。",
+10
View File
@@ -392,6 +392,16 @@ describe("api access-control wire shapes", () => {
expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i);
});
it("says why a user change was refused for the caller's own or the owner account", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ status: 403, code: "self_protected" })).toBe(
"You can't do that to the account you're signed in with.",
);
expect(humanizeError({ status: 403, code: "owner_protected" })).toBe(
"The owner account can't be demoted, disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.",
);
});
it("maps the backup rationing codes to their own copy", async () => {
const { humanizeError } = await import("./api");
expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i);
+6
View File
@@ -822,6 +822,12 @@ export function humanizeError(e: unknown): string {
return t("passkey_unavailable");
case "last_passkey":
return t("last_passkey");
// User admin (internal/api/handlers_users.go): the caller's own account and
// the owner account are refused, each for its own reason.
case "self_protected":
return t("self_protected");
case "owner_protected":
return t("owner_protected");
case "quota_exceeded":
return t("quota_exceeded");
case "already_claimed":
+27 -3
View File
@@ -5205,7 +5205,15 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Not an owner (forbidden); the caller's own account (self_protected); or the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may remove. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
};
};
@@ -5241,7 +5249,15 @@ export interface operations {
};
400: components["responses"]["BadRequest"];
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Not an owner (forbidden); a change to the caller's own role (self_protected); or a role change on the owner account (owner_protected), which only the host's break-glass console (sudo felis breakGlass) may make. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
/** @description Username conflict. */
409: {
@@ -5284,7 +5300,15 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Not an owner (forbidden); the caller's own account (self_protected); or disabling the owner account (owner_protected). Re-enabling the owner is allowed. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
404: components["responses"]["NotFound"];
};
};
+67 -3
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next";
import { UserDetailPage } from "./UserDetailPage";
@@ -35,9 +35,9 @@ const USER: UserDetail = {
linked_accounts: [{ mc_uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5", auth_source: "thirdparty", verified_at: VERIFIED }],
};
function renderPage() {
function renderPage(id = "u-1") {
return render(
<MemoryRouter initialEntries={["/admin/users/u-1"]}>
<MemoryRouter initialEntries={[`/admin/users/${id}`]}>
<Routes>
<Route path="/admin/users/:id" element={<UserDetailPage />} />
</Routes>
@@ -88,4 +88,68 @@ describe("UserDetailPage", () => {
expect(await screen.findByText(`第三方 Yggdrasil · ${zhVerified}`)).toBeTruthy();
expect(await screen.findByText(zhExpires, { exact: false })).toBeTruthy();
});
describe("danger zone for accounts the server protects", () => {
const SELF_REASON = "You can't disable or delete the account you're signed in with.";
const OWNER_REASON =
"The owner account can't be disabled or deleted from the panel. Only the host's break-glass console (sudo felis breakGlass) manages it.";
const button = (name: string) => screen.getByRole("button", { name }) as HTMLButtonElement;
const describedBy = (b: HTMLElement) =>
document.getElementById(b.getAttribute("aria-describedby") ?? "")?.textContent;
it("locks disable and delete on the signed-in owner's own row and says why", async () => {
calls.getUser.mockResolvedValue({ ...USER, id: "owner-1", username: "root", role: "owner" });
renderPage("owner-1");
expect(await screen.findByText(SELF_REASON)).toBeTruthy();
for (const name of ["Disable User", "Delete User"]) {
expect(button(name).disabled).toBe(true);
expect(describedBy(button(name))).toBe(SELF_REASON);
expect(button(name).parentElement?.getAttribute("title")).toBe(SELF_REASON);
}
expect(button("Unbind passkeys").disabled).toBe(false);
expect(screen.getByText("You can't change your own role.")).toBeTruthy();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("locks disable and delete on another owner with the break-glass reason", async () => {
calls.getUser.mockResolvedValue({ ...USER, id: "owner-2", username: "co-owner", role: "owner" });
renderPage("owner-2");
expect(await screen.findByText(OWNER_REASON)).toBeTruthy();
expect(screen.queryByText(SELF_REASON)).toBeNull();
expect(button("Disable User").disabled).toBe(true);
expect(button("Delete User").disabled).toBe(true);
expect(describedBy(button("Delete User"))).toBe(OWNER_REASON);
expect(
screen.getByText(
"The owner's role is fixed. Only the host's break-glass console (sudo felis breakGlass) manages the owner.",
),
).toBeTruthy();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("still lets a disabled owner be re-enabled", async () => {
calls.getUser.mockResolvedValue({ ...USER, id: "owner-2", role: "owner", disabled: true });
renderPage("owner-2");
expect((await screen.findByRole("button", { name: "Enable" }) as HTMLButtonElement).disabled).toBe(false);
expect(button("Enable").getAttribute("aria-describedby")).toBeNull();
expect(button("Delete User").disabled).toBe(true);
});
it("leaves every action open on an ordinary user", async () => {
calls.getUser.mockResolvedValue(USER);
renderPage();
expect((await screen.findByRole("button", { name: "Disable User" }) as HTMLButtonElement).disabled).toBe(false);
expect(button("Delete User").disabled).toBe(false);
expect(button("Delete User").parentElement?.getAttribute("title")).toBeNull();
expect(screen.queryByText(SELF_REASON)).toBeNull();
expect(screen.queryByText(OWNER_REASON)).toBeNull();
const role = screen.getByRole("combobox");
expect(within(role).getByText("User")).toBeTruthy();
});
});
});
+67 -9
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useId } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { MessageLine, InlineError } from "@/components/MessageLine";
import { RoleBadge } from "@/components/RoleBadge";
@@ -20,6 +20,7 @@ import {
X,
AlertTriangle,
Fingerprint,
Lock,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -86,7 +87,11 @@ export function UserDetailPage() {
icon={(
<div className={cn(
"rounded-full p-2",
user.role === "admin" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",
user.role === "owner"
? "bg-yellow-500/10 text-yellow-600"
: user.role === "admin"
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground",
)}>
<UserRound className="h-6 w-6" />
</div>
@@ -130,7 +135,7 @@ export function UserDetailPage() {
</div>
{/* Danger zone */}
<DangerZone user={user} onChanged={reload} navigate={navigate} />
<DangerZone user={user} isSelf={identity?.user_id === id} onChanged={reload} navigate={navigate} />
</div>
);
}
@@ -195,7 +200,17 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
className="h-9 text-sm"
/>
</div>
{!isSelf && (
{/* Your own role and the owner's are what the server refuses to change
(self_protected / owner_protected), so they show read-only with why. */}
{isSelf || user.role === "owner" ? (
<div className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</p>
<RoleBadge role={user.role} />
<p className="text-xs text-muted-foreground">
{isSelf ? t("users_role_locked_self") : t("users_role_locked_owner")}
</p>
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="user-detail-role" className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</Label>
<Select value={role} onValueChange={(v: "admin" | "user") => setRole(v)}>
@@ -653,15 +668,29 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
function DangerZone({
user,
isSelf,
onChanged,
navigate,
}: {
user: UserDetail;
isSelf: boolean;
onChanged: () => void;
navigate: (path: string) => void;
}) {
const { t } = useTranslation("admin");
const [dlg, setDlg] = useState<"disable" | "delete" | "passkeys" | null>(null);
const reasonId = useId();
// The server refuses to disable or delete the caller's own account
// (self_protected) and the owner account (owner_protected); re-enabling a
// disabled owner is allowed. Say so before the confirm dialog, not after it.
const reason = isSelf
? t("users_protected_self")
: user.role === "owner"
? t("users_protected_owner")
: null;
const blocked = reason ? { reason, id: reasonId } : undefined;
const toggleBlocked = isSelf || (user.role === "owner" && !user.disabled);
return (
<Card className="border-destructive/30">
@@ -669,6 +698,15 @@ function DangerZone({
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle>
</CardHeader>
<CardContent className="space-y-5">
{reason && (
<p
id={reasonId}
className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-amber-800 dark:text-amber-300"
>
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" />
{reason}
</p>
)}
{/* Enable / Disable */}
<DangerRow
icon={user.disabled ? Power : PowerOff}
@@ -677,6 +715,7 @@ function DangerZone({
btnLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
btnVariant={user.disabled ? "default" : "destructive"}
onAction={() => setDlg("disable")}
blocked={toggleBlocked ? blocked : undefined}
/>
{/* Delete user */}
@@ -687,6 +726,7 @@ function DangerZone({
btnLabel={t("users_danger_delete_btn")}
btnVariant="destructive"
onAction={() => setDlg("delete")}
blocked={blocked}
/>
{/* Unbind passkeys — credential remediation, not a lockout */}
@@ -712,6 +752,7 @@ function DangerRow({
btnLabel,
btnVariant,
onAction,
blocked,
}: {
icon: typeof Power;
title: string;
@@ -719,17 +760,34 @@ function DangerRow({
btnLabel: string;
btnVariant: "default" | "destructive" | "outline";
onAction: () => void;
/** Why the server would refuse this, with the id of the line saying so. */
blocked?: { reason: string; id: string };
}) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 p-4">
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 p-4",
blocked && "opacity-70",
)}
>
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
</div>
<Button variant={btnVariant} size="sm" onClick={onAction} className="gap-1.5">
<Icon className="h-4 w-4" />
{btnLabel}
</Button>
{/* A disabled button gets no hover events, so the tooltip sits on a wrapper. */}
<span title={blocked?.reason} className={cn(blocked && "cursor-not-allowed")}>
<Button
variant={btnVariant}
size="sm"
onClick={onAction}
disabled={!!blocked}
aria-describedby={blocked?.id}
className="gap-1.5"
>
{blocked ? <Lock className="h-4 w-4" /> : <Icon className="h-4 w-4" />}
{btnLabel}
</Button>
</span>
</div>
);
}