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
@@ -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>
);
}