mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-22 02:47:14 +00:00
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:
@@ -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": [
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user