fix(panel): 本人与所有者账户的禁用和删除按钮提前锁定并说明原因,角色改为只读,后端拒绝改用专用错误码 self_protected 与 owner_protected
This commit is contained in:
13 files changed
+269
-27
No files matched your search
+15
-3
@@ -3542,7 +3542,11 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/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.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
'409':
|
'409':
|
||||||
@@ -3572,7 +3576,11 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/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.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
@@ -3612,7 +3620,11 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
$ref: '#/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.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ type patchUserRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handlePatchUser is the admin-tier patch-user endpoint (PATCH /users/{id}).
|
// handlePatchUser is the admin-tier patch-user endpoint (PATCH /users/{id}).
|
||||||
|
// The two refusals an admin meets on the user page get codes of their own, so
|
||||||
|
// the panel can say why instead of a bare "not allowed": acting on your own
|
||||||
|
// account (a slip that would lock you out), and changing the owner account,
|
||||||
|
// which only the local break-glass console (sudo felis breakGlass) may do.
|
||||||
|
const (
|
||||||
|
codeSelfProtected = "self_protected"
|
||||||
|
codeOwnerProtected = "owner_protected"
|
||||||
|
)
|
||||||
|
|
||||||
func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
@@ -137,7 +146,7 @@ func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Self-demotion guard: an admin/owner may edit their own email or username,
|
// Self-demotion guard: an admin/owner may edit their own email or username,
|
||||||
// but must never downgrade themselves to a lower role.
|
// but must never downgrade themselves to a lower role.
|
||||||
if body.Role != nil && id == p.UserID && *body.Role != p.Role {
|
if body.Role != nil && id == p.UserID && *body.Role != p.Role {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
writeError(w, r, newError(http.StatusForbidden, codeSelfProtected,
|
||||||
"cannot change your own role"))
|
"cannot change your own role"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -148,7 +157,7 @@ func (a *API) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
// UpdateUser then answers the real 404.
|
// UpdateUser then answers the real 404.
|
||||||
if body.Role != nil && *body.Role != "owner" {
|
if body.Role != nil && *body.Role != "owner" {
|
||||||
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
|
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
writeError(w, r, newError(http.StatusForbidden, codeOwnerProtected,
|
||||||
"the owner account's role cannot be changed from the panel"))
|
"the owner account's role cannot be changed from the panel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -195,7 +204,7 @@ func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if id == p.UserID {
|
if id == p.UserID {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
writeError(w, r, newError(http.StatusForbidden, codeSelfProtected,
|
||||||
"cannot delete your own account"))
|
"cannot delete your own account"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,7 +212,7 @@ func (a *API) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Same owner protection as the role guard above: only break-glass retires the
|
// Same owner protection as the role guard above: only break-glass retires the
|
||||||
// owner identity. A failed detail read falls through to the real 404.
|
// owner identity. A failed detail read falls through to the real 404.
|
||||||
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
|
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
writeError(w, r, newError(http.StatusForbidden, codeOwnerProtected,
|
||||||
"the owner account cannot be deleted from the panel"))
|
"the owner account cannot be deleted from the panel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -231,7 +240,7 @@ func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if id == p.UserID {
|
if id == p.UserID {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
writeError(w, r, newError(http.StatusForbidden, codeSelfProtected,
|
||||||
"cannot disable your own account"))
|
"cannot disable your own account"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -249,7 +258,7 @@ func (a *API) handleDisableUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
// break-glass touches the owner identity. Re-enabling stays allowed.
|
// break-glass touches the owner identity. Re-enabling stays allowed.
|
||||||
if body.Disabled {
|
if body.Disabled {
|
||||||
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
|
if d, err := a.Repo.UserDetail(r.Context(), id); err == nil && d.Role == "owner" {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "forbidden",
|
writeError(w, r, newError(http.StatusForbidden, codeOwnerProtected,
|
||||||
"the owner account cannot be disabled from the panel"))
|
"the owner account cannot be disabled from the panel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,19 +25,49 @@ func TestOwnerAccountProtectedFromPanelMutations(t *testing.T) {
|
|||||||
if w.Code != http.StatusForbidden {
|
if w.Code != http.StatusForbidden {
|
||||||
t.Fatalf("demote owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
t.Fatalf("demote owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
if got := decodeErr(t, w); got != "owner_protected" {
|
||||||
|
t.Fatalf("demote owner: error code = %q, want owner_protected", got)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
t.Run("delete refused", func(t *testing.T) {
|
t.Run("delete refused", func(t *testing.T) {
|
||||||
w := do(eh, "DELETE", "/api/v1/users/usr-owner2", "", nil)
|
w := do(eh, "DELETE", "/api/v1/users/usr-owner2", "", nil)
|
||||||
if w.Code != http.StatusForbidden {
|
if w.Code != http.StatusForbidden {
|
||||||
t.Fatalf("delete owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
t.Fatalf("delete owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
if got := decodeErr(t, w); got != "owner_protected" {
|
||||||
|
t.Fatalf("delete owner: error code = %q, want owner_protected", got)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
t.Run("disable refused", func(t *testing.T) {
|
t.Run("disable refused", func(t *testing.T) {
|
||||||
w := do(eh, "POST", "/api/v1/users/usr-owner2/disable", `{"disabled":true}`, jsonHeader)
|
w := do(eh, "POST", "/api/v1/users/usr-owner2/disable", `{"disabled":true}`, jsonHeader)
|
||||||
if w.Code != http.StatusForbidden {
|
if w.Code != http.StatusForbidden {
|
||||||
t.Fatalf("disable owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
t.Fatalf("disable owner: code = %d body %s, want 403", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
|
if got := decodeErr(t, w); got != "owner_protected" {
|
||||||
|
t.Fatalf("disable owner: error code = %q, want owner_protected", got)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
// The caller's own row is refused as self_protected even though it is also
|
||||||
|
// an owner: that is the reason the admin can act on.
|
||||||
|
for _, tc := range []struct{ name, method, path, body string }{
|
||||||
|
{"own role", "PATCH", "/api/v1/users/usr-root", `{"role":"admin"}`},
|
||||||
|
{"own delete", "DELETE", "/api/v1/users/usr-root", ""},
|
||||||
|
{"own disable", "POST", "/api/v1/users/usr-root/disable", `{"disabled":true}`},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name+" refused as self", func(t *testing.T) {
|
||||||
|
var h map[string]string
|
||||||
|
if tc.body != "" {
|
||||||
|
h = jsonHeader
|
||||||
|
}
|
||||||
|
w := do(eh, tc.method, tc.path, tc.body, h)
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("code = %d body %s, want 403", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := decodeErr(t, w); got != "self_protected" {
|
||||||
|
t.Fatalf("error code = %q, want self_protected", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
t.Run("email edits on an owner stay allowed", func(t *testing.T) {
|
t.Run("email edits on an owner stay allowed", func(t *testing.T) {
|
||||||
w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"email":"[email protected]"}`, jsonHeader)
|
w := do(eh, "PATCH", "/api/v1/users/usr-owner2", `{"email":"[email protected]"}`, jsonHeader)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
|
|||||||
+20
-3
@@ -1170,7 +1170,16 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (ctx.account.id === acc.id && body.role !== ctx.account.role) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
acc.role = body.role;
|
acc.role = body.role;
|
||||||
@@ -1196,7 +1205,11 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
// DELETE /api/v1/users/{id}
|
// DELETE /api/v1/users/{id}
|
||||||
if (is("DELETE", ctx) && !subAction) {
|
if (is("DELETE", ctx) && !subAction) {
|
||||||
if (ctx.account.id === acc.id) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const activeServers = ctx.state.servers.filter(
|
const activeServers = ctx.state.servers.filter(
|
||||||
@@ -1226,10 +1239,14 @@ async function handleUserRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
// POST /api/v1/users/{id}/disable
|
// POST /api/v1/users/{id}/disable
|
||||||
if (is("POST", ctx) && subAction === "disable") {
|
if (is("POST", ctx) && subAction === "disable") {
|
||||||
if (ctx.account.id === acc.id) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
const body = await readJSON<{ disabled: boolean }>(ctx.req);
|
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.disabled = !!body.disabled;
|
||||||
acc.updated_at = new Date().toISOString();
|
acc.updated_at = new Date().toISOString();
|
||||||
sendJSON(ctx.res, 200, { id: `mock-${acc.id}`, disabled: acc.disabled });
|
sendJSON(ctx.res, 200, { id: `mock-${acc.id}`, disabled: acc.disabled });
|
||||||
|
|||||||
@@ -172,6 +172,8 @@
|
|||||||
"users_field_username": "Username",
|
"users_field_username": "Username",
|
||||||
"users_field_email": "Email",
|
"users_field_email": "Email",
|
||||||
"users_field_role": "Role",
|
"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_btn": "Save Changes",
|
||||||
"users_save_ok": "Changes saved successfully.",
|
"users_save_ok": "Changes saved successfully.",
|
||||||
"users_linked_accounts": "Linked Minecraft Accounts",
|
"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_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_session_revoke_confirm": "Revoke",
|
||||||
"users_danger_zone": "Danger Zone",
|
"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": "Disable User",
|
||||||
"users_danger_disable_desc": "Prevent this user from logging in. All active sessions will be revoked immediately.",
|
"users_danger_disable_desc": "Prevent this user from logging in. All active sessions will be revoked immediately.",
|
||||||
"users_danger_disable_btn": "Disable User",
|
"users_danger_disable_btn": "Disable User",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"restore_unavailable": "Restore isn't available right now — try again later.",
|
"restore_unavailable": "Restore isn't available right now — try again later.",
|
||||||
"session_expired": "Your session expired — please sign in again.",
|
"session_expired": "Your session expired — please sign in again.",
|
||||||
"forbidden": "You are not allowed to do that.",
|
"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.",
|
"generic": "Something went wrong.",
|
||||||
"otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
|
"otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
|
||||||
"otp_locked": "Too many incorrect attempts, please request a new verification code.",
|
"otp_locked": "Too many incorrect attempts, please request a new verification code.",
|
||||||
|
|||||||
@@ -172,6 +172,8 @@
|
|||||||
"users_field_username": "用户名",
|
"users_field_username": "用户名",
|
||||||
"users_field_email": "邮箱",
|
"users_field_email": "邮箱",
|
||||||
"users_field_role": "角色",
|
"users_field_role": "角色",
|
||||||
|
"users_role_locked_self": "不能修改自己的角色。",
|
||||||
|
"users_role_locked_owner": "所有者的角色是固定的,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
|
||||||
"users_save_btn": "保存更改",
|
"users_save_btn": "保存更改",
|
||||||
"users_save_ok": "更改保存成功。",
|
"users_save_ok": "更改保存成功。",
|
||||||
"users_linked_accounts": "已关联的 Minecraft 账号",
|
"users_linked_accounts": "已关联的 Minecraft 账号",
|
||||||
@@ -203,6 +205,8 @@
|
|||||||
"users_session_revoke_all_dlg_desc": "确定撤销所有活跃会话吗?用户将从所有设备登出。",
|
"users_session_revoke_all_dlg_desc": "确定撤销所有活跃会话吗?用户将从所有设备登出。",
|
||||||
"users_session_revoke_confirm": "撤销",
|
"users_session_revoke_confirm": "撤销",
|
||||||
"users_danger_zone": "危险操作区",
|
"users_danger_zone": "危险操作区",
|
||||||
|
"users_protected_self": "不能禁用或删除当前登录的账号。",
|
||||||
|
"users_protected_owner": "所有者账号不能在面板里禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
|
||||||
"users_danger_disable": "禁用用户",
|
"users_danger_disable": "禁用用户",
|
||||||
"users_danger_disable_desc": "阻止此用户登录。所有活跃会话将被立即撤销。",
|
"users_danger_disable_desc": "阻止此用户登录。所有活跃会话将被立即撤销。",
|
||||||
"users_danger_disable_btn": "禁用用户",
|
"users_danger_disable_btn": "禁用用户",
|
||||||
|
|||||||
@@ -27,6 +27,8 @@
|
|||||||
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
||||||
"session_expired": "会话已过期——请重新登录。",
|
"session_expired": "会话已过期——请重新登录。",
|
||||||
"forbidden": "你无权执行此操作。",
|
"forbidden": "你无权执行此操作。",
|
||||||
|
"self_protected": "不能对当前登录的账号执行此操作。",
|
||||||
|
"owner_protected": "所有者账号不能在面板里降级、禁用或删除,只能在主机的应急控制台(sudo felis breakGlass)上管理。",
|
||||||
"generic": "出了点问题,请稍后重试。",
|
"generic": "出了点问题,请稍后重试。",
|
||||||
"otp_resend_cooldown": "验证码发送频繁,请稍后再试。",
|
"otp_resend_cooldown": "验证码发送频繁,请稍后再试。",
|
||||||
"otp_locked": "验证码错误次数过多,请重新获取验证码。",
|
"otp_locked": "验证码错误次数过多,请重新获取验证码。",
|
||||||
|
|||||||
@@ -392,6 +392,16 @@ describe("api access-control wire shapes", () => {
|
|||||||
expect(humanizeError({ code: "console_unavailable" })).toMatch(/console/i);
|
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 () => {
|
it("maps the backup rationing codes to their own copy", async () => {
|
||||||
const { humanizeError } = await import("./api");
|
const { humanizeError } = await import("./api");
|
||||||
expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i);
|
expect(humanizeError({ code: "backup_cooldown" })).toMatch(/cooldown/i);
|
||||||
|
|||||||
@@ -822,6 +822,12 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("passkey_unavailable");
|
return t("passkey_unavailable");
|
||||||
case "last_passkey":
|
case "last_passkey":
|
||||||
return t("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":
|
case "quota_exceeded":
|
||||||
return t("quota_exceeded");
|
return t("quota_exceeded");
|
||||||
case "already_claimed":
|
case "already_claimed":
|
||||||
|
|||||||
@@ -5205,7 +5205,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
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"];
|
404: components["responses"]["NotFound"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -5241,7 +5249,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
400: components["responses"]["BadRequest"];
|
400: components["responses"]["BadRequest"];
|
||||||
401: components["responses"]["Unauthorized"];
|
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"];
|
404: components["responses"]["NotFound"];
|
||||||
/** @description Username conflict. */
|
/** @description Username conflict. */
|
||||||
409: {
|
409: {
|
||||||
@@ -5284,7 +5300,15 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
401: components["responses"]["Unauthorized"];
|
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"];
|
404: components["responses"]["NotFound"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
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 { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { UserDetailPage } from "./UserDetailPage";
|
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 }],
|
linked_accounts: [{ mc_uuid: "069a79f4-44e9-4726-a5be-fca90e38aaf5", auth_source: "thirdparty", verified_at: VERIFIED }],
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(id = "u-1") {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={["/admin/users/u-1"]}>
|
<MemoryRouter initialEntries={[`/admin/users/${id}`]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/admin/users/:id" element={<UserDetailPage />} />
|
<Route path="/admin/users/:id" element={<UserDetailPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -88,4 +88,68 @@ describe("UserDetailPage", () => {
|
|||||||
expect(await screen.findByText(`第三方 Yggdrasil · ${zhVerified}`)).toBeTruthy();
|
expect(await screen.findByText(`第三方 Yggdrasil · ${zhVerified}`)).toBeTruthy();
|
||||||
expect(await screen.findByText(zhExpires, { exact: false })).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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useId } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { MessageLine, InlineError } from "@/components/MessageLine";
|
import { MessageLine, InlineError } from "@/components/MessageLine";
|
||||||
import { RoleBadge } from "@/components/RoleBadge";
|
import { RoleBadge } from "@/components/RoleBadge";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
|
Lock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -86,7 +87,11 @@ export function UserDetailPage() {
|
|||||||
icon={(
|
icon={(
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"rounded-full p-2",
|
"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" />
|
<UserRound className="h-6 w-6" />
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +135,7 @@ export function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Danger zone */}
|
{/* Danger zone */}
|
||||||
<DangerZone user={user} onChanged={reload} navigate={navigate} />
|
<DangerZone user={user} isSelf={identity?.user_id === id} onChanged={reload} navigate={navigate} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,7 +200,17 @@ function EditProfileCard({ user, onSaved, isSelf }: { user: UserDetail; onSaved:
|
|||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="user-detail-role" className="text-xs font-semibold text-muted-foreground">{t("users_field_role")}</Label>
|
<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)}>
|
<Select value={role} onValueChange={(v: "admin" | "user") => setRole(v)}>
|
||||||
@@ -653,15 +668,29 @@ function SessionsCard({ userId, onChanged }: { userId: string; onChanged: () =>
|
|||||||
|
|
||||||
function DangerZone({
|
function DangerZone({
|
||||||
user,
|
user,
|
||||||
|
isSelf,
|
||||||
onChanged,
|
onChanged,
|
||||||
navigate,
|
navigate,
|
||||||
}: {
|
}: {
|
||||||
user: UserDetail;
|
user: UserDetail;
|
||||||
|
isSelf: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const [dlg, setDlg] = useState<"disable" | "delete" | "passkeys" | null>(null);
|
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 (
|
return (
|
||||||
<Card className="border-destructive/30">
|
<Card className="border-destructive/30">
|
||||||
@@ -669,6 +698,15 @@ function DangerZone({
|
|||||||
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle>
|
<CardTitle className="text-base font-semibold text-destructive">{t("users_danger_zone")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-5">
|
<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 */}
|
{/* Enable / Disable */}
|
||||||
<DangerRow
|
<DangerRow
|
||||||
icon={user.disabled ? Power : PowerOff}
|
icon={user.disabled ? Power : PowerOff}
|
||||||
@@ -677,6 +715,7 @@ function DangerZone({
|
|||||||
btnLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
|
btnLabel={user.disabled ? t("users_danger_enable_btn") : t("users_danger_disable_btn")}
|
||||||
btnVariant={user.disabled ? "default" : "destructive"}
|
btnVariant={user.disabled ? "default" : "destructive"}
|
||||||
onAction={() => setDlg("disable")}
|
onAction={() => setDlg("disable")}
|
||||||
|
blocked={toggleBlocked ? blocked : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete user */}
|
{/* Delete user */}
|
||||||
@@ -687,6 +726,7 @@ function DangerZone({
|
|||||||
btnLabel={t("users_danger_delete_btn")}
|
btnLabel={t("users_danger_delete_btn")}
|
||||||
btnVariant="destructive"
|
btnVariant="destructive"
|
||||||
onAction={() => setDlg("delete")}
|
onAction={() => setDlg("delete")}
|
||||||
|
blocked={blocked}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Unbind passkeys — credential remediation, not a lockout */}
|
{/* Unbind passkeys — credential remediation, not a lockout */}
|
||||||
@@ -712,6 +752,7 @@ function DangerRow({
|
|||||||
btnLabel,
|
btnLabel,
|
||||||
btnVariant,
|
btnVariant,
|
||||||
onAction,
|
onAction,
|
||||||
|
blocked,
|
||||||
}: {
|
}: {
|
||||||
icon: typeof Power;
|
icon: typeof Power;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -719,17 +760,34 @@ function DangerRow({
|
|||||||
btnLabel: string;
|
btnLabel: string;
|
||||||
btnVariant: "default" | "destructive" | "outline";
|
btnVariant: "default" | "destructive" | "outline";
|
||||||
onAction: () => void;
|
onAction: () => void;
|
||||||
|
/** Why the server would refuse this, with the id of the line saying so. */
|
||||||
|
blocked?: { reason: string; id: string };
|
||||||
}) {
|
}) {
|
||||||
return (
|
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>
|
<div>
|
||||||
<p className="text-sm font-medium">{title}</p>
|
<p className="text-sm font-medium">{title}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
|
<p className="text-xs text-muted-foreground mt-0.5">{desc}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant={btnVariant} size="sm" onClick={onAction} className="gap-1.5">
|
{/* A disabled button gets no hover events, so the tooltip sits on a wrapper. */}
|
||||||
<Icon className="h-4 w-4" />
|
<span title={blocked?.reason} className={cn(blocked && "cursor-not-allowed")}>
|
||||||
{btnLabel}
|
<Button
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user