浏览代码

feat(clients): allow removing a single HWID device (#6265)

* feat(clients): allow removing a single HWID device

Only "list" and "clear all" existed for registered HWID devices, so
freeing one slot under a client's HWID limit meant clearing every
device and waiting for the ones you kept to re-register. Adds a
per-device delete: DELETE /panel/api/clients/hwids/:email/:id, scoped
to the client's own sub_id (device ids are a global auto-increment,
not per-subID, so this also prevents deleting another client's
device), plus a delete button next to each device in the existing
HWID modal.

Addresses MHSanaei/3x-ui#6245.

* feat(clients): surface HWID limit + device log in the client info card

Mirrors the existing IP-limit row/eye-icon-modal pattern that's
already in this card. The HWID devices modal reuses the same
list/clear-all/per-device-delete UI already shipped for the edit
form's own HWID modal, so a device can be removed without opening the
edit form at all.

* i18n: add HWID single-delete strings to all 13 locales

deleteHwid/deleteHwidConfirm/hwidDeleted were only added to en-US and
ru-RU in the previous commit; backfilling the other 11 locales the
project's own translation set covers.

* fix(clients): address automated review of HWID single-delete PR

- ClientInfoModal: use the existing dateLabel() helper (Jalali-aware)
  for HWID first/last-seen instead of a raw dayjs format, matching
  every other timestamp in the same modal.
- Add okText/cancelText to the delete-device Popconfirm in both
  ClientInfoModal and ClientFormModal so all 13 locales get a
  translated confirm dialog instead of Antd's English default.
- deleteHwid controller: stop reusing the success toast key on both
  error paths, which rendered a red "Update successful" toast on a
  real (not just theoretical) failure such as a stale HWID modal.
- Trim DeleteClientHwid's doc comment to the repo's 2-line cap and
  correct it: deletion is scoped by sub_id, which can span more than
  one ClientRecord, not strictly "this client only".
- Add TestDeleteClientHwid covering cross-sub_id id rejection, unknown
  id rejection, and a real successful delete.

* chore: retrigger CI (previous run stuck installing Playwright Chromium)

* fix(clients): address the arbiter review on the HWID single-delete PR

- Extract the HWID device list into a shared frontend/src/lib/clients/
  hwid-log.ts type/normalizer, a shared useClientHwids hook, and a
  shared ClientHwidListModal component, mirroring the existing IP-log
  pattern. ClientInfoModal and ClientFormModal both render the same
  component now, so the two copies can no longer drift the way they
  already had (different date formatting, different tag styles).
- Add a Popconfirm to the HWID "Clear all" button (previously
  unconfirmed, unlike the per-device delete right next to it) — closes
  the confirm/no-confirm asymmetry the review flagged as the main risk.
- Sync docs/public/openapi.json with the two hwids paths and regenerate
  clients.mdx. Scoped to just those two paths rather than a full copy
  from frontend/public/openapi.json: the docs copy is far enough behind
  on unrelated paths (a host-group API rename) that a full sync breaks
  the Next.js build on locale pages referencing the old shape — out of
  scope for this PR.

* fix(clients): trim HWID list comment blocks to 2 lines

Repo convention caps comment blocks at 2 lines; both were 1 line over.

* chore: retrigger CI

build (arm64) and build (armv6) failed on a transient Go module proxy
network error (INTERNAL_ERROR stream reset), unrelated to this PR's
changes.
Kuzz007 8 小时之前
父节点
当前提交
1250fbb734

文件差异内容过多而无法显示
+ 19 - 0
docs/content/docs/en/reference/api/clients.mdx


+ 144 - 0
docs/public/openapi.json

@@ -10254,6 +10254,150 @@
           }
         }
       }
+    },
+    "/panel/api/clients/hwids/{email}": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "List registered HWID devices for a client. Hashes are not exposed.",
+        "operationId": "post_panel_api_clients_hwids_email",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "id": 1,
+                      "firstSeen": 1735000000000,
+                      "lastSeen": 1735100000000,
+                      "userAgent": "Happ/1.0",
+                      "deviceOs": "android",
+                      "osVersion": "15",
+                      "deviceModel": "Pixel 9"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Clear all registered HWID devices for a client so new devices can register again.",
+        "operationId": "delete_panel_api_clients_hwids_email",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/hwids/{email}/{id}": {
+      "delete": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Remove a single registered HWID device by its id, freeing one slot under the HWID limit.",
+        "operationId": "delete_panel_api_clients_hwids_email_id",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Device id, from the list endpoint.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
     }
   }
 }

+ 50 - 0
frontend/public/openapi.json

@@ -7987,6 +7987,56 @@
         }
       }
     },
+    "/panel/api/clients/hwids/{email}/{id}": {
+      "delete": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Remove a single registered HWID device by its id, freeing one slot under the HWID limit.",
+        "operationId": "delete_panel_api_clients_hwids_email_id",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Device id, from the list endpoint.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/onlines": {
       "post": {
         "tags": [

+ 129 - 0
frontend/src/components/clients/ClientHwidList.tsx

@@ -0,0 +1,129 @@
+import { Button, Modal, Popconfirm, Tag, Typography } from 'antd';
+import { DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
+import { useTranslation } from 'react-i18next';
+import type { ClientHwidInfo } from '@/lib/clients/hwid-log';
+
+interface ClientHwidListModalProps {
+  open: boolean;
+  email?: string;
+  zIndex?: number;
+  hwids: ClientHwidInfo[];
+  loading: boolean;
+  clearing: boolean;
+  deletingId: number | null;
+  formatDate: (ts: number) => string;
+  onRefresh: () => void;
+  onClearAll: () => void;
+  onDelete: (id: number) => void;
+  onClose: () => void;
+}
+
+// The single place the HWID device list is rendered — the edit form and the
+// info card share it so date format and row layout can't drift apart again.
+export default function ClientHwidListModal({
+  open,
+  email,
+  zIndex,
+  hwids,
+  loading,
+  clearing,
+  deletingId,
+  formatDate,
+  onRefresh,
+  onClearAll,
+  onDelete,
+  onClose,
+}: ClientHwidListModalProps) {
+  const { t } = useTranslation();
+
+  return (
+    <Modal
+      open={open}
+      title={`${t('pages.clients.hwidLog')}${email ? ` — ${email}` : ''}`}
+      width={520}
+      zIndex={zIndex}
+      onCancel={onClose}
+      footer={[
+        <Button key="refresh" icon={<ReloadOutlined />} loading={loading} onClick={onRefresh}>
+          {t('refresh')}
+        </Button>,
+        <Popconfirm
+          key="clear"
+          title={t('pages.clients.clearHwidsConfirm')}
+          onConfirm={onClearAll}
+          okType="danger"
+          okText={t('delete')}
+          cancelText={t('cancel')}
+        >
+          <Button danger loading={clearing} disabled={hwids.length === 0}>
+            {t('pages.clients.clearAll')}
+          </Button>
+        </Popconfirm>,
+        <Button key="close" type="primary" onClick={onClose}>
+          {t('close')}
+        </Button>,
+      ]}
+    >
+      {hwids.length > 0 ? (
+        <div style={{ maxHeight: 360, overflowY: 'auto' }}>
+          {hwids.map((entry) => (
+            <div
+              key={entry.id}
+              style={{
+                display: 'flex',
+                alignItems: 'flex-start',
+                gap: 8,
+                borderBottom: '1px solid var(--ant-color-border-secondary)',
+                padding: '8px 0',
+              }}
+            >
+              <div style={{ flex: 1, minWidth: 0 }}>
+                <Typography.Text strong>
+                  {entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {t('pages.clients.firstSeen')}: {formatDate(entry.firstSeen)}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {t('pages.clients.lastSeen')}: {formatDate(entry.lastSeen)}
+                </Typography.Text>
+                {entry.userAgent && (
+                  <>
+                    <br />
+                    <Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>
+                      {entry.userAgent}
+                    </Typography.Text>
+                  </>
+                )}
+              </div>
+              <Popconfirm
+                title={t('pages.clients.deleteHwidConfirm')}
+                onConfirm={() => onDelete(entry.id)}
+                okType="danger"
+                okText={t('delete')}
+                cancelText={t('cancel')}
+              >
+                <Button
+                  danger
+                  type="text"
+                  size="small"
+                  aria-label={t('pages.clients.deleteHwid')}
+                  icon={<DeleteOutlined />}
+                  loading={deletingId === entry.id}
+                />
+              </Popconfirm>
+            </div>
+          ))}
+        </div>
+      ) : (
+        <Tag>{t('pages.clients.noHwids')}</Tag>
+      )}
+    </Modal>
+  );
+}

+ 75 - 0
frontend/src/hooks/useClientHwids.ts

@@ -0,0 +1,75 @@
+import { useState } from 'react';
+import { HttpUtil } from '@/utils';
+import { normalizeClientHwids, type ClientHwidInfo } from '@/lib/clients/hwid-log';
+
+interface ApiMsg<T = unknown> {
+  success?: boolean;
+  obj?: T;
+}
+
+// Fetch/mutate state for one client's registered-device list, shared by the
+// edit form and the info card. No email (add-client form) => every action no-ops.
+export function useClientHwids(email: string | undefined) {
+  const [clientHwids, setClientHwids] = useState<ClientHwidInfo[]>([]);
+  const [hwidsLoading, setHwidsLoading] = useState(false);
+  const [hwidsClearing, setHwidsClearing] = useState(false);
+  const [deletingHwidId, setDeletingHwidId] = useState<number | null>(null);
+
+  async function loadHwids() {
+    if (!email) return;
+    setHwidsLoading(true);
+    try {
+      const msg = (await HttpUtil.post(
+        `/panel/api/clients/hwids/${encodeURIComponent(email)}`,
+      )) as ApiMsg<unknown[]>;
+      if (!msg?.success) {
+        setClientHwids([]);
+        return;
+      }
+      setClientHwids(normalizeClientHwids(msg.obj));
+    } finally {
+      setHwidsLoading(false);
+    }
+  }
+
+  async function clearHwids() {
+    if (!email) return;
+    setHwidsClearing(true);
+    try {
+      const msg = (await HttpUtil.delete(
+        `/panel/api/clients/hwids/${encodeURIComponent(email)}`,
+      )) as ApiMsg;
+      if (msg?.success) setClientHwids([]);
+    } finally {
+      setHwidsClearing(false);
+    }
+  }
+
+  async function deleteHwid(id: number) {
+    if (!email) return;
+    setDeletingHwidId(id);
+    try {
+      const msg = (await HttpUtil.delete(
+        `/panel/api/clients/hwids/${encodeURIComponent(email)}/${id}`,
+      )) as ApiMsg;
+      if (msg?.success) setClientHwids((prev) => prev.filter((entry) => entry.id !== id));
+    } finally {
+      setDeletingHwidId(null);
+    }
+  }
+
+  function resetHwids() {
+    setClientHwids([]);
+  }
+
+  return {
+    clientHwids,
+    hwidsLoading,
+    hwidsClearing,
+    deletingHwidId,
+    loadHwids,
+    clearHwids,
+    deleteHwid,
+    resetHwids,
+  };
+}

+ 21 - 0
frontend/src/lib/clients/hwid-log.ts

@@ -0,0 +1,21 @@
+// Shape of one entry in a client's HWID (registered-device) log, as returned
+// by POST /panel/api/clients/hwids/:email.
+export type ClientHwidInfo = {
+  id: number;
+  firstSeen: number;
+  lastSeen: number;
+  userAgent: string;
+  deviceOs: string;
+  osVersion: string;
+  deviceModel: string;
+};
+
+// normalizeClientHwids accepts the API payload and returns typed entries,
+// dropping anything that isn't a real HWID row (missing/non-numeric id).
+export function normalizeClientHwids(obj: unknown): ClientHwidInfo[] {
+  if (!Array.isArray(obj)) return [];
+  return obj.filter(
+    (x): x is ClientHwidInfo =>
+      !!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number',
+  );
+}

+ 10 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -1187,6 +1187,16 @@ export const sections: readonly Section[] = [
           'Clear all registered HWID devices for a client so new devices can register again.',
         params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
       },
+      {
+        method: 'DELETE',
+        path: '/panel/api/clients/hwids/:email/:id',
+        summary:
+          'Remove a single registered HWID device by its id, freeing one slot under the HWID limit.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+          { name: 'id', in: 'path', type: 'number', desc: 'Device id, from the list endpoint.' },
+        ],
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/onlines',

+ 28 - 118
frontend/src/pages/clients/ClientFormModal.tsx

@@ -30,12 +30,15 @@ import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import { Controller, FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
 
-import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
+import { HttpUtil, IntlUtil, RandomUtil, Wireguard } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
+import { useDatepicker } from '@/hooks/useDatepicker';
+import { useClientHwids } from '@/hooks/useClientHwids';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
+import ClientHwidListModal from '@/components/clients/ClientHwidList';
 import { TLS_FLOW_CONTROL, TRAFFIC_RESETS } from '@/schemas/primitives';
 import type {
   ClientRecord,
@@ -79,16 +82,6 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
-interface ClientHwidInfo {
-  id: number;
-  firstSeen: number;
-  lastSeen: number;
-  userAgent: string;
-  deviceOs: string;
-  osVersion: string;
-  deviceModel: string;
-}
-
 type Mode = 'add' | 'edit';
 
 interface SaveMetaEdit {
@@ -252,10 +245,19 @@ export default function ClientFormModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
-  const [clientHwids, setClientHwids] = useState<ClientHwidInfo[]>([]);
-  const [hwidsLoading, setHwidsLoading] = useState(false);
-  const [hwidsClearing, setHwidsClearing] = useState(false);
+  const {
+    clientHwids,
+    hwidsLoading,
+    hwidsClearing,
+    deletingHwidId,
+    loadHwids,
+    clearHwids,
+    deleteHwid,
+  } = useClientHwids(client?.email);
   const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
+  const { datepicker } = useDatepicker();
+  const hwidDateLabel = (ts: number) =>
+    !ts || ts <= 0 ? '-' : IntlUtil.formatDate(ts, datepicker);
   const fail2ban = useFail2banStatusQuery();
   const limitIpDisabled = !fail2ban.usable;
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
@@ -535,46 +537,11 @@ export default function ClientFormModal({
     }
   }
 
-  async function loadHwids() {
-    if (!isEdit || !client?.email) return;
-    setHwidsLoading(true);
-    try {
-      const msg = (await HttpUtil.post(
-        `/panel/api/clients/hwids/${encodeURIComponent(client.email)}`,
-      )) as ApiMsg<unknown[]>;
-      if (!msg?.success || !Array.isArray(msg.obj)) {
-        setClientHwids([]);
-        return;
-      }
-      setClientHwids(
-        msg.obj.filter(
-          (x): x is ClientHwidInfo =>
-            !!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number',
-        ),
-      );
-    } finally {
-      setHwidsLoading(false);
-    }
-  }
-
   function openHwidsModal() {
     setHwidsModalOpen(true);
     if (clientHwids.length === 0) void loadHwids();
   }
 
-  async function clearHwids() {
-    if (!isEdit || !client?.email) return;
-    setHwidsClearing(true);
-    try {
-      const msg = (await HttpUtil.delete(
-        `/panel/api/clients/hwids/${encodeURIComponent(client.email)}`,
-      )) as ApiMsg;
-      if (msg?.success) setClientHwids([]);
-    } finally {
-      setHwidsClearing(false);
-    }
-  }
-
   function close() {
     onOpenChange(false);
   }
@@ -1431,77 +1398,20 @@ export default function ClientFormModal({
         )}
       </Modal>
 
-      <Modal
+      <ClientHwidListModal
         open={hwidsModalOpen}
-        title={`${t('pages.clients.hwidLog')}${client?.email ? ` — ${client.email}` : ''}`}
-        width={520}
+        email={client?.email}
         zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
-        onCancel={() => setHwidsModalOpen(false)}
-        footer={[
-          <Button
-            key="refresh"
-            icon={<ReloadOutlined />}
-            loading={hwidsLoading}
-            onClick={loadHwids}
-          >
-            {t('refresh')}
-          </Button>,
-          <Button
-            key="clear"
-            danger
-            loading={hwidsClearing}
-            disabled={clientHwids.length === 0}
-            onClick={clearHwids}
-          >
-            {t('pages.clients.clearAll')}
-          </Button>,
-          <Button key="close" type="primary" onClick={() => setHwidsModalOpen(false)}>
-            {t('close')}
-          </Button>,
-        ]}
-      >
-        {clientHwids.length > 0 ? (
-          <div style={{ maxHeight: 360, overflowY: 'auto' }}>
-            {clientHwids.map((entry) => (
-              <div
-                key={entry.id}
-                style={{
-                  borderBottom: '1px solid var(--ant-color-border-secondary)',
-                  padding: '8px 0',
-                }}
-              >
-                <Typography.Text strong>
-                  {entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}
-                </Typography.Text>
-                <br />
-                <Typography.Text type="secondary">
-                  {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')}
-                </Typography.Text>
-                <br />
-                <Typography.Text type="secondary">
-                  {t('pages.clients.firstSeen')}:{' '}
-                  {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'}
-                </Typography.Text>
-                <br />
-                <Typography.Text type="secondary">
-                  {t('pages.clients.lastSeen')}:{' '}
-                  {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'}
-                </Typography.Text>
-                {entry.userAgent && (
-                  <>
-                    <br />
-                    <Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>
-                      {entry.userAgent}
-                    </Typography.Text>
-                  </>
-                )}
-              </div>
-            ))}
-          </div>
-        ) : (
-          <Tag>{t('pages.clients.noHwids')}</Tag>
-        )}
-      </Modal>
+        hwids={clientHwids}
+        loading={hwidsLoading}
+        clearing={hwidsClearing}
+        deletingId={deletingHwidId}
+        formatDate={hwidDateLabel}
+        onRefresh={loadHwids}
+        onClearAll={clearHwids}
+        onDelete={deleteHwid}
+        onClose={() => setHwidsModalOpen(false)}
+      />
     </>
   );
 }

+ 52 - 0
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -13,10 +13,12 @@ import { ClipboardManager, FileManager, HttpUtil, IntlUtil, SizeFormatter } from
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { useDatepicker } from '@/hooks/useDatepicker';
+import { useClientHwids } from '@/hooks/useClientHwids';
 import type { ClientRecord, InboundOption } from '@/hooks/useClients';
 import { isPostQuantumLink } from '@/lib/xray/inbound-link';
 import { LinkTags, linkMetaText, parseLinkParts } from '@/lib/xray/link-label';
 import { QrPanel } from '@/pages/inbounds/qr';
+import ClientHwidListModal from '@/components/clients/ClientHwidList';
 import ConfigBlock from '@/components/clients/ConfigBlock';
 import {
   buildWireguardClientConfig,
@@ -105,6 +107,17 @@ export default function ClientInfoModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const {
+    clientHwids,
+    hwidsLoading,
+    hwidsClearing,
+    deletingHwidId,
+    loadHwids,
+    clearHwids,
+    deleteHwid,
+    resetHwids,
+  } = useClientHwids(client?.email);
+  const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
   const [downloadingFormat, setDownloadingFormat] = useState<
     keyof typeof SUBSCRIPTION_DOWNLOAD_NAMES | null
   >(null);
@@ -118,6 +131,8 @@ export default function ClientInfoModal({
       setLinks([]);
       setClientIps([]);
       setIpsModalOpen(false);
+      resetHwids();
+      setHwidsModalOpen(false);
     }
   }
 
@@ -230,6 +245,11 @@ export default function ClientInfoModal({
     if (clientIps.length === 0) void loadIps();
   }
 
+  function openHwidsModal() {
+    setHwidsModalOpen(true);
+    if (clientHwids.length === 0) void loadHwids();
+  }
+
   return (
     <>
       {messageContextHolder}
@@ -419,6 +439,24 @@ export default function ClientInfoModal({
                     </td>
                   </tr>
                 )}
+                <tr>
+                  <td>{t('pages.clients.limitHwid')}</td>
+                  <td>{!client.limitHwid ? <Tag>∞</Tag> : <Tag>{client.limitHwid}</Tag>}</td>
+                </tr>
+                <tr>
+                  <td>{t('pages.clients.hwidLog')}</td>
+                  <td>
+                    <Button
+                      size="small"
+                      icon={<EyeOutlined />}
+                      aria-label={t('pages.clients.hwidLog')}
+                      loading={hwidsLoading}
+                      onClick={openHwidsModal}
+                    >
+                      {clientHwids.length > 0 ? clientHwids.length : ''}
+                    </Button>
+                  </td>
+                </tr>
                 <tr>
                   <td>{t('pages.inbounds.createdAt')}</td>
                   <td>
@@ -784,6 +822,20 @@ export default function ClientInfoModal({
           <Tag>{t('tgbot.noIpRecord')}</Tag>
         )}
       </Modal>
+
+      <ClientHwidListModal
+        open={hwidsModalOpen}
+        email={client?.email}
+        hwids={clientHwids}
+        loading={hwidsLoading}
+        clearing={hwidsClearing}
+        deletingId={deletingHwidId}
+        formatDate={dateLabel}
+        onRefresh={loadHwids}
+        onClearAll={clearHwids}
+        onDelete={deleteHwid}
+        onClose={() => setHwidsModalOpen(false)}
+      />
     </>
   );
 }

+ 14 - 0
internal/web/controller/client.go

@@ -78,6 +78,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/hwids/:email", a.getHwids)
 	g.DELETE("/hwids/:email", a.clearHwids)
+	g.DELETE("/hwids/:email/:id", a.deleteHwid)
 	g.POST("/onlines", a.onlines)
 	g.POST("/onlinesByGuid", a.onlinesByGuid)
 	g.POST("/clientIpsByGuid", a.clientIpsByGuid)
@@ -558,6 +559,19 @@ func (a *ClientController) clearHwids(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
 }
 
+func (a *ClientController) deleteHwid(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.clientService.DeleteClientHwid(c.Param("email"), id); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.clients.hwidDeleted"), nil)
+}
+
 func (a *ClientController) onlines(c *gin.Context) {
 	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
 }

+ 21 - 0
internal/web/service/client_hwid.go

@@ -200,6 +200,27 @@ func (s *ClientService) ClearClientHwids(email string) error {
 	return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
 }
 
+// DeleteClientHwid removes one device, scoped to the client's sub_id: ids
+// are a global auto-increment, so an id outside this subscription won't match.
+func (s *ClientService) DeleteClientHwid(email string, id int) error {
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return errors.New("client has no subscription id")
+	}
+	res := database.GetDB().Where("sub_id = ? AND id = ?", subID, id).Delete(&model.ClientHwid{})
+	if res.Error != nil {
+		return res.Error
+	}
+	if res.RowsAffected == 0 {
+		return errors.New("device not found")
+	}
+	return nil
+}
+
 func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
 	if tx == nil {
 		tx = database.GetDB()

+ 47 - 0
internal/web/service/client_hwid_test.go

@@ -151,6 +151,53 @@ func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
 	}
 }
 
+func TestDeleteClientHwid(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+	db := database.GetDB()
+
+	rec := seedHwidClient(t, 5)
+	if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-own"}); err != nil {
+		t.Fatalf("register own device: %v", err)
+	}
+	list, err := svc.ListClientHwids(rec.Email)
+	if err != nil || len(list) != 1 {
+		t.Fatalf("list own devices: err=%v list=%+v", err, list)
+	}
+	ownID := list[0].Id
+
+	other := &model.ClientRecord{Email: "[email protected]", SubID: "sub-other", UUID: "33333333-2222-4333-8444-555555555555", Enable: true, LimitHwid: 5}
+	if err := db.Create(other).Error; err != nil {
+		t.Fatalf("seed other client: %v", err)
+	}
+	if _, err := svc.EnforceHwidForSubID(other.SubID, HwidRequest{Hwid: "device-foreign"}); err != nil {
+		t.Fatalf("register foreign device: %v", err)
+	}
+	otherList, err := svc.ListClientHwids(other.Email)
+	if err != nil || len(otherList) != 1 {
+		t.Fatalf("list foreign devices: err=%v list=%+v", err, otherList)
+	}
+	foreignID := otherList[0].Id
+
+	if err := svc.DeleteClientHwid(rec.Email, foreignID); err == nil {
+		t.Fatalf("deleting a foreign sub_id's device id should fail")
+	}
+	if list, err := svc.ListClientHwids(other.Email); err != nil || len(list) != 1 {
+		t.Fatalf("foreign device should survive a cross-sub_id delete attempt: err=%v list=%+v", err, list)
+	}
+
+	if err := svc.DeleteClientHwid(rec.Email, 999999); err == nil {
+		t.Fatalf("deleting an unknown id should fail")
+	}
+
+	if err := svc.DeleteClientHwid(rec.Email, ownID); err != nil {
+		t.Fatalf("delete own device: %v", err)
+	}
+	if list, err := svc.ListClientHwids(rec.Email); err != nil || len(list) != 0 {
+		t.Fatalf("own device should be gone: err=%v list=%+v", err, list)
+	}
+}
+
 func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
 	initClientHwidTestDB(t)
 	svc := &ClientService{}

+ 4 - 0
internal/web/translation/ar-EG.json

@@ -746,6 +746,10 @@
       "noHwids": "لا توجد أجهزة HWID بعد",
       "firstSeen": "أول ظهور",
       "lastSeen": "آخر ظهور",
+      "deleteHwid": "إزالة الجهاز",
+      "deleteHwidConfirm": "إزالة هذا الجهاز؟ سيحتاج إلى إعادة التسجيل عند جلب الاشتراك التالي.",
+      "hwidDeleted": "تمت إزالة الجهاز.",
+      "clearHwidsConfirm": "إزالة جميع الأجهزة المسجلة؟ سيحتاج كل جهاز إلى إعادة التسجيل عند جلب الاشتراك التالي.",
       "limitIpFail2banMissing": "Fail2ban غير مثبّت، لذا لا يمكن تطبيق حد عناوين IP. ثبّت Fail2ban من قائمة x-ui النصية لتفعيل هذا الخيار.",
       "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
       "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",

+ 4 - 0
internal/web/translation/en-US.json

@@ -746,6 +746,10 @@
       "noHwids": "No HWID devices yet",
       "firstSeen": "First seen",
       "lastSeen": "Last seen",
+      "deleteHwid": "Remove device",
+      "deleteHwidConfirm": "Remove this device? It will need to re-register on its next subscription fetch.",
+      "hwidDeleted": "Device removed.",
+      "clearHwidsConfirm": "Remove all registered devices? Every device will need to re-register on its next subscription fetch.",
       "limitIpFail2banMissing": "Fail2ban is not installed, so the IP limit cannot be enforced. Install Fail2ban from the x-ui bash menu to enable this option.",
       "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
       "limitIpDisabled": "The IP limit feature is disabled on this server.",

+ 4 - 0
internal/web/translation/es-ES.json

@@ -746,6 +746,10 @@
       "noHwids": "Aún no hay dispositivos HWID",
       "firstSeen": "Visto por primera vez",
       "lastSeen": "Visto por última vez",
+      "deleteHwid": "Eliminar dispositivo",
+      "deleteHwidConfirm": "¿Eliminar este dispositivo? Deberá volver a registrarse en la próxima obtención de la suscripción.",
+      "hwidDeleted": "Dispositivo eliminado.",
+      "clearHwidsConfirm": "¿Eliminar todos los dispositivos registrados? Cada dispositivo deberá volver a registrarse en la próxima obtención de la suscripción.",
       "limitIpFail2banMissing": "Fail2ban no está instalado, por lo que no se puede aplicar el límite de IP. Instala Fail2ban desde el menú bash de x-ui para habilitar esta opción.",
       "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
       "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",

+ 4 - 0
internal/web/translation/fa-IR.json

@@ -746,6 +746,10 @@
       "noHwids": "هنوز دستگاه HWID ثبت نشده است",
       "firstSeen": "اولین مشاهده",
       "lastSeen": "آخرین مشاهده",
+      "deleteHwid": "حذف دستگاه",
+      "deleteHwidConfirm": "این دستگاه حذف شود؟ در دریافت بعدی اشتراک باید دوباره ثبت‌نام شود.",
+      "hwidDeleted": "دستگاه حذف شد.",
+      "clearHwidsConfirm": "همه دستگاه‌های ثبت‌شده حذف شوند؟ هر دستگاه در دریافت بعدی اشتراک باید دوباره ثبت‌نام شود.",
       "limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمی‌شود. برای فعال‌سازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.",
       "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
       "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",

+ 4 - 0
internal/web/translation/id-ID.json

@@ -746,6 +746,10 @@
       "noHwids": "Belum ada perangkat HWID",
       "firstSeen": "Pertama terlihat",
       "lastSeen": "Terakhir terlihat",
+      "deleteHwid": "Hapus perangkat",
+      "deleteHwidConfirm": "Hapus perangkat ini? Perangkat perlu mendaftar ulang pada pengambilan langganan berikutnya.",
+      "hwidDeleted": "Perangkat dihapus.",
+      "clearHwidsConfirm": "Hapus semua perangkat terdaftar? Setiap perangkat perlu mendaftar ulang pada pengambilan langganan berikutnya.",
       "limitIpFail2banMissing": "Fail2ban tidak terpasang, sehingga batas IP tidak dapat diterapkan. Pasang Fail2ban dari menu bash x-ui untuk mengaktifkan opsi ini.",
       "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
       "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",

+ 4 - 0
internal/web/translation/ja-JP.json

@@ -746,6 +746,10 @@
       "noHwids": "HWID デバイスはまだありません",
       "firstSeen": "初回確認",
       "lastSeen": "最終確認",
+      "deleteHwid": "デバイスを削除",
+      "deleteHwidConfirm": "このデバイスを削除しますか?次回のサブスクリプション取得時に再登録が必要になります。",
+      "hwidDeleted": "デバイスを削除しました。",
+      "clearHwidsConfirm": "登録済みのすべてのデバイスを削除しますか?各デバイスは次回のサブスクリプション取得時に再登録が必要になります。",
       "limitIpFail2banMissing": "Fail2ban がインストールされていないため、IP 制限を適用できません。このオプションを有効にするには、x-ui の bash メニューから Fail2ban をインストールしてください。",
       "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
       "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",

+ 4 - 0
internal/web/translation/pt-BR.json

@@ -746,6 +746,10 @@
       "noHwids": "Ainda não há dispositivos HWID",
       "firstSeen": "Visto primeiro",
       "lastSeen": "Visto por último",
+      "deleteHwid": "Remover dispositivo",
+      "deleteHwidConfirm": "Remover este dispositivo? Ele precisará se registrar novamente na próxima busca da assinatura.",
+      "hwidDeleted": "Dispositivo removido.",
+      "clearHwidsConfirm": "Remover todos os dispositivos registrados? Cada dispositivo precisará se registrar novamente na próxima busca da assinatura.",
       "limitIpFail2banMissing": "O Fail2ban não está instalado, portanto o limite de IP não pode ser aplicado. Instale o Fail2ban pelo menu bash do x-ui para ativar esta opção.",
       "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
       "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",

+ 4 - 0
internal/web/translation/ru-RU.json

@@ -746,6 +746,10 @@
       "noHwids": "Устройств HWID пока нет",
       "firstSeen": "Первое появление",
       "lastSeen": "Последнее появление",
+      "deleteHwid": "Удалить устройство",
+      "deleteHwidConfirm": "Удалить это устройство? При следующем запросе подписки оно зарегистрируется заново.",
+      "hwidDeleted": "Устройство удалено.",
+      "clearHwidsConfirm": "Удалить все зарегистрированные устройства? Каждое устройство зарегистрируется заново при следующем запросе подписки.",
       "limitIpFail2banMissing": "Fail2ban не установлен, поэтому ограничение по IP не может быть применено. Установите Fail2ban из bash-меню x-ui, чтобы включить эту опцию.",
       "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
       "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",

+ 4 - 0
internal/web/translation/tr-TR.json

@@ -746,6 +746,10 @@
       "noHwids": "Henüz HWID cihazı yok",
       "firstSeen": "İlk görülme",
       "lastSeen": "Son görülme",
+      "deleteHwid": "Cihazı kaldır",
+      "deleteHwidConfirm": "Bu cihaz kaldırılsın mı? Bir sonraki abonelik alımında yeniden kaydolması gerekecek.",
+      "hwidDeleted": "Cihaz kaldırıldı.",
+      "clearHwidsConfirm": "Kayıtlı tüm cihazlar kaldırılsın mı? Her cihazın bir sonraki abonelik alımında yeniden kaydolması gerekecek.",
       "limitIpFail2banMissing": "Fail2ban yüklü değil, bu nedenle IP sınırı uygulanamaz. Bu seçeneği etkinleştirmek için x-ui bash menüsünden Fail2ban'ı yükleyin.",
       "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
       "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",

+ 4 - 0
internal/web/translation/uk-UA.json

@@ -746,6 +746,10 @@
       "noHwids": "Пристроїв HWID ще немає",
       "firstSeen": "Перша поява",
       "lastSeen": "Остання поява",
+      "deleteHwid": "Видалити пристрій",
+      "deleteHwidConfirm": "Видалити цей пристрій? Йому потрібно буде зареєструватися знову під час наступного отримання підписки.",
+      "hwidDeleted": "Пристрій видалено.",
+      "clearHwidsConfirm": "Видалити всі зареєстровані пристрої? Кожному пристрою потрібно буде зареєструватися знову під час наступного отримання підписки.",
       "limitIpFail2banMissing": "Fail2ban не встановлено, тому обмеження за IP не може бути застосоване. Встановіть Fail2ban із bash-меню x-ui, щоб увімкнути цю опцію.",
       "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
       "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",

+ 4 - 0
internal/web/translation/vi-VN.json

@@ -746,6 +746,10 @@
       "noHwids": "Chưa có thiết bị HWID",
       "firstSeen": "Lần đầu thấy",
       "lastSeen": "Lần cuối thấy",
+      "deleteHwid": "Xóa thiết bị",
+      "deleteHwidConfirm": "Xóa thiết bị này? Thiết bị sẽ cần đăng ký lại vào lần lấy gói đăng ký tiếp theo.",
+      "hwidDeleted": "Đã xóa thiết bị.",
+      "clearHwidsConfirm": "Xóa tất cả thiết bị đã đăng ký? Mỗi thiết bị sẽ cần đăng ký lại vào lần lấy gói đăng ký tiếp theo.",
       "limitIpFail2banMissing": "Fail2ban chưa được cài đặt nên không thể áp dụng giới hạn IP. Hãy cài đặt Fail2ban từ menu bash x-ui để bật tùy chọn này.",
       "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
       "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",

+ 4 - 0
internal/web/translation/zh-CN.json

@@ -746,6 +746,10 @@
       "noHwids": "暂无 HWID 设备",
       "firstSeen": "首次出现",
       "lastSeen": "最后出现",
+      "deleteHwid": "移除设备",
+      "deleteHwidConfirm": "移除此设备?下次获取订阅时它将需要重新注册。",
+      "hwidDeleted": "设备已移除。",
+      "clearHwidsConfirm": "移除所有已注册的设备?每台设备在下次获取订阅时都需要重新注册。",
       "limitIpFail2banMissing": "未安装 Fail2ban,无法实施 IP 限制。请从 x-ui 命令行菜单安装 Fail2ban 以启用此选项。",
       "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
       "limitIpDisabled": "此服务器已禁用 IP 限制功能。",

+ 4 - 0
internal/web/translation/zh-TW.json

@@ -746,6 +746,10 @@
       "noHwids": "尚無 HWID 裝置",
       "firstSeen": "首次出現",
       "lastSeen": "最後出現",
+      "deleteHwid": "移除裝置",
+      "deleteHwidConfirm": "移除此裝置?下次取得訂閱時它將需要重新註冊。",
+      "hwidDeleted": "裝置已移除。",
+      "clearHwidsConfirm": "移除所有已註冊的裝置?每台裝置在下次取得訂閱時都需要重新註冊。",
       "limitIpFail2banMissing": "未安裝 Fail2ban,無法實施 IP 限制。請從 x-ui 命令列選單安裝 Fail2ban 以啟用此選項。",
       "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
       "limitIpDisabled": "此伺服器已停用 IP 限制功能。",

部分文件因为文件数量过多而无法显示