feat(panel): expose owner passkey unbind in the user danger zone

DELETE /users/{id}/passkeys shipped as the owner-tier remediation for a
lost or compromised authenticator, but nothing in the panel reached it.
Add the danger-zone action with a confirm dialog; the account keeps its
other doors (email OTP, in-game op-login re-enrollment), so this severs
a credential without locking anyone out. Wire-shape test pins the call.
This commit is contained in:
Lemon-miaow committed 2026-09-23 06:24:45 +08:00
1 parent 35d93d7612
commit 11ac4f50e6
5 files changed
+76 -4

No files matched your search

+7 -1
View File
@@ -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..."
}
}
+7 -1
View File
@@ -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、镜像引用或状态..."
}
}
+12
View File
@@ -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<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/users/u1/passkeys");
expect((opts as RequestInit).method).toBe("DELETE");
});
});
});
+7
View File
@@ -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",
+43 -2
View File
@@ -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 (
<Card className="border-destructive/30">
@@ -667,6 +668,16 @@ function DangerZone({
onAction={() => setDlg("delete")}
/>
{/* Unbind passkeys — credential remediation, not a lockout */}
<DangerRow
icon={Fingerprint}
title={t("users_danger_passkeys")}
desc={t("users_danger_passkeys_desc")}
btnLabel={t("users_danger_passkeys_btn")}
btnVariant="outline"
onAction={() => setDlg("passkeys")}
/>
<DangerDialogs dlg={dlg} setDlg={setDlg} user={user} onChanged={onChanged} navigate={navigate} />
</CardContent>
</Card>
@@ -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({
<ConfirmFooter onCancel={close} onConfirm={handleDelete} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_delete_yes")} />
</DialogContent>
</Dialog>
{/* Unbind passkeys dialog */}
<Dialog open={dlg === "passkeys"} onOpenChange={(v) => { if (!v) close(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
{t("users_danger_passkeys_dlg_title")}
</DialogTitle>
<DialogDescription>
{t("users_danger_passkeys_dlg_desc")}
</DialogDescription>
</DialogHeader>
{err && <p className="text-sm text-destructive">{err}</p>}
<ConfirmFooter onCancel={close} onConfirm={handleUnbindPasskeys} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_passkeys_yes")} />
</DialogContent>
</Dialog>
</>
);
}