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
+76
-4
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_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_btn": "Delete User",
|
||||||
"users_danger_delete_yes": "Yes, Delete Permanently",
|
"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.",
|
"add_image_desc": "Register an external Docker image reference on the whitelist for later server creation.",
|
||||||
"images_search_placeholder": "Search image name or source...",
|
"images_search_placeholder": "Search image name or source...",
|
||||||
"search_no_results": "No matches",
|
"search_no_results": "No matches",
|
||||||
@@ -174,4 +180,4 @@
|
|||||||
"submissions_search_placeholder": "Search modpack name or submitter...",
|
"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.",
|
"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..."
|
"builds_search_placeholder": "Search build ID, image reference, or status..."
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,12 @@
|
|||||||
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
"users_danger_delete_desc": "软删除此用户。其拥有的服务器将被释放,所有会话将被撤销,账号将被永久禁用。此操作无法通过面板撤销。",
|
||||||
"users_danger_delete_btn": "删除用户",
|
"users_danger_delete_btn": "删除用户",
|
||||||
"users_danger_delete_yes": "是的,永久删除",
|
"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 镜像引用录入白名单,供后续创建服务器使用。",
|
"add_image_desc": "将外部 Docker 镜像引用录入白名单,供后续创建服务器使用。",
|
||||||
"images_search_placeholder": "搜索镜像名称或来源...",
|
"images_search_placeholder": "搜索镜像名称或来源...",
|
||||||
"search_no_results": "无匹配结果",
|
"search_no_results": "无匹配结果",
|
||||||
@@ -174,4 +180,4 @@
|
|||||||
"submissions_search_placeholder": "搜索模组包名称或提交人...",
|
"submissions_search_placeholder": "搜索模组包名称或提交人...",
|
||||||
"trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。",
|
"trigger_build_desc": "输入镜像构建参数,在隔离命名空间中启动 Kaniko 流水线任务。",
|
||||||
"builds_search_placeholder": "搜索构建 ID、镜像引用或状态..."
|
"builds_search_placeholder": "搜索构建 ID、镜像引用或状态..."
|
||||||
}
|
}
|
||||||
@@ -581,4 +581,16 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect((opts as RequestInit).method).toBe("GET");
|
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) =>
|
revokeUserSession: (id: string, hash: string) =>
|
||||||
request<{ ok: boolean }>("DELETE", `/users/${id}/sessions/${encodeURIComponent(hash)}`),
|
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) =>
|
linkAccount: (id: string, mcUuid: string, authSource?: string) =>
|
||||||
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
request<{ ok: boolean; mc_uuid: string; auth_source: string }>(
|
||||||
"POST",
|
"POST",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Link,
|
Link,
|
||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
Fingerprint,
|
||||||
} 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";
|
||||||
@@ -639,7 +640,7 @@ function DangerZone({
|
|||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("admin");
|
const { t } = useTranslation("admin");
|
||||||
const [dlg, setDlg] = useState<"disable" | "delete" | null>(null);
|
const [dlg, setDlg] = useState<"disable" | "delete" | "passkeys" | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/30">
|
<Card className="border-destructive/30">
|
||||||
@@ -667,6 +668,16 @@ function DangerZone({
|
|||||||
onAction={() => setDlg("delete")}
|
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} />
|
<DangerDialogs dlg={dlg} setDlg={setDlg} user={user} onChanged={onChanged} navigate={navigate} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -709,7 +720,7 @@ function DangerDialogs({
|
|||||||
onChanged,
|
onChanged,
|
||||||
navigate,
|
navigate,
|
||||||
}: {
|
}: {
|
||||||
dlg: "disable" | "delete" | null;
|
dlg: "disable" | "delete" | "passkeys" | null;
|
||||||
setDlg: (v: null) => void;
|
setDlg: (v: null) => void;
|
||||||
user: UserDetail;
|
user: UserDetail;
|
||||||
onChanged: () => void;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Disable / Enable dialog */}
|
{/* 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")} />
|
<ConfirmFooter onCancel={close} onConfirm={handleDelete} loading={loading} cancelLabel={t("common:cancel")} confirmLabel={t("users_danger_delete_yes")} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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