Unverified Commit 11ac4f50 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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.
parent 35d93d76
Loading
Loading
Loading
Loading
+6 −0
Changes for panel/src/i18n/resources/en-US/admin.json: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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",
+6 −0
Changes for panel/src/i18n/resources/zh-CN/admin.json: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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": "无匹配结果",
+12 −0
Changes for panel/src/lib/api.test.ts: 12 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for panel/src/lib/api.ts: 7 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for panel/src/pages/admin/UserDetailPage.tsx: 43 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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>
    </>
  );
}