diff --git a/panel/src/i18n/resources/en-US/admin.json b/panel/src/i18n/resources/en-US/admin.json index 63841fa..cba1d8a 100644 --- a/panel/src/i18n/resources/en-US/admin.json +++ b/panel/src/i18n/resources/en-US/admin.json @@ -167,6 +167,12 @@ "users_danger_delete_desc": "Soft-delete this user. Owned servers are released, all sessions are revoked, and the account is permanently disabled. This action cannot be undone through the panel.", "users_danger_delete_btn": "Delete User", "users_danger_delete_yes": "Yes, Delete Permanently", + "users_danger_passkeys": "Unbind passkeys", + "users_danger_passkeys_desc": "Sever every passkey this account holds — the remediation when an authenticator is lost or compromised. Other login doors (email code, in-game approval) keep working.", + "users_danger_passkeys_btn": "Unbind passkeys", + "users_danger_passkeys_dlg_title": "Unbind passkeys", + "users_danger_passkeys_dlg_desc": "Every passkey for this account will be permanently removed. This is not a lockout: the user can sign in another way and re-enroll from the account page.", + "users_danger_passkeys_yes": "Yes, unbind", "add_image_desc": "Register an external Docker image reference on the whitelist for later server creation.", "images_search_placeholder": "Search image name or source...", "search_no_results": "No matches", @@ -174,4 +180,4 @@ "submissions_search_placeholder": "Search modpack name or submitter...", "trigger_build_desc": "Enter the build parameters to launch a Kaniko pipeline job in an isolated namespace.", "builds_search_placeholder": "Search build ID, image reference, or status..." -} \ No newline at end of file +} diff --git a/panel/src/i18n/resources/zh-CN/admin.json b/panel/src/i18n/resources/zh-CN/admin.json index d8bc203..47d8603 100644 --- a/panel/src/i18n/resources/zh-CN/admin.json +++ b/panel/src/i18n/resources/zh-CN/admin.json @@ -167,6 +167,12 @@ "users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。", "users_danger_delete_btn": "删除用户", "users_danger_delete_yes": "是的,永久删除", + "users_danger_passkeys": "解绑通行密钥", + "users_danger_passkeys_desc": "解除该账号持有的全部通行密钥——密钥丢失或被盗用时的补救手段。其他登录方式(邮箱验证码、游戏内审批)不受影响。", + "users_danger_passkeys_btn": "解绑通行密钥", + "users_danger_passkeys_dlg_title": "解绑通行密钥", + "users_danger_passkeys_dlg_desc": "该账号的全部通行密钥将被永久移除。这不会锁死账号:用户可用其他方式登录后,在账户页重新注册密钥。", + "users_danger_passkeys_yes": "确认解绑", "add_image_desc": "将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。", "images_search_placeholder": "搜索镜像名称或来源...", "search_no_results": "无匹配结果", @@ -174,4 +180,4 @@ "submissions_search_placeholder": "搜索模组包名称或提交人...", "trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。", "builds_search_placeholder": "搜索构建 ID、镜像引用或状态..." -} \ No newline at end of file +} diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index d73dc74..2cae3b1 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -581,4 +581,16 @@ describe("image whitelist and builds wire shapes", () => { expect((opts as RequestInit).method).toBe("GET"); }); }); + + describe("user passkey unbind wire shape", () => { + it("unbindUserPasskeys DELETEs /users/{id}/passkeys", async () => { + const fetchSpy = fakeFetch({ ok: true }); + vi.stubGlobal("fetch", fetchSpy); + const res = await api.unbindUserPasskeys("u1"); + expect(res.ok).toBe(true); + const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; + expect(String(url)).toBe("/users/u1/passkeys"); + expect((opts as RequestInit).method).toBe("DELETE"); + }); + }); }); diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 7a9b72c..0931daf 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -506,6 +506,13 @@ export const api = { revokeUserSession: (id: string, hash: string) => request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`), + // unbindUserPasskeys severs EVERY passkey the user holds (owner-tier account + // remediation for a lost or compromised authenticator). It is deliberately not + // a lockout — the account keeps its other doors (email OTP, in-game op-login + // re-enrollment). Unbinding an account that holds no passkeys is a 200 no-op. + unbindUserPasskeys: (id: string) => + request<{ ok: boolean }>("DELETE", `/users/${id}/passkeys`), + linkAccount: (id: string, mcUuid: string, authSource?: string) => request<{ ok: boolean; mc_uuid: string; auth_source: string }>( "POST", diff --git a/panel/src/pages/admin/UserDetailPage.tsx b/panel/src/pages/admin/UserDetailPage.tsx index f848768..461d6ab 100644 --- a/panel/src/pages/admin/UserDetailPage.tsx +++ b/panel/src/pages/admin/UserDetailPage.tsx @@ -19,6 +19,7 @@ import { Link, X, AlertTriangle, + Fingerprint, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -639,7 +640,7 @@ function DangerZone({ navigate: (path: string) => void; }) { const { t } = useTranslation("admin"); - const [dlg, setDlg] = useState<"disable" | "delete" | null>(null); + const [dlg, setDlg] = useState<"disable" | "delete" | "passkeys" | null>(null); return ( @@ -667,6 +668,16 @@ function DangerZone({ onAction={() => setDlg("delete")} /> + {/* Unbind passkeys — credential remediation, not a lockout */} + setDlg("passkeys")} + /> + @@ -709,7 +720,7 @@ function DangerDialogs({ onChanged, navigate, }: { - dlg: "disable" | "delete" | null; + dlg: "disable" | "delete" | "passkeys" | null; setDlg: (v: null) => void; user: UserDetail; onChanged: () => void; @@ -750,6 +761,19 @@ function DangerDialogs({ } } + async function handleUnbindPasskeys() { + setLoading(true); + setErr(null); + try { + await api.unbindUserPasskeys(user.id); + close(); + onChanged(); + } catch (e) { + setErr(humanizeError(e)); + setLoading(false); + } + } + return ( <> {/* Disable / Enable dialog */} @@ -785,6 +809,23 @@ function DangerDialogs({ + + {/* Unbind passkeys dialog */} + { if (!v) close(); }}> + + + + + {t("users_danger_passkeys_dlg_title")} + + + {t("users_danger_passkeys_dlg_desc")} + + + {err &&

{err}

} + +
+
); }