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:
5 files changed
+74
-2
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": "无匹配结果",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user