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.
This commit is contained in:
Kuzz007
2026-08-21 15:17:05 +03:00
committed by GitHub
parent 5321665d5b
commit 1250fbb734
25 changed files with 663 additions and 119 deletions
+28 -118
View File
@@ -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)}
/>
</>
);
}
@@ -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)}
/>
</>
);
}