feat(sub): add per-client subscription HWID limits (#5802)

* feat(sub): add per-client subscription HWID limits

* fix(sub): address HWID review on shared subId and bulk create

* fix(sub): store HWID devices by sub_id and drop anchor client workaround

* fix(sub): restore UA auto-detect and HTML page routing in subs()

The cherry-pick of the HWID gate onto main's refactored SUBController
had dropped main's UA-based format auto-detection and sub-page handling
from subs(). Restore those branches, slotting enforceHwid after the
HTML page and before format detection so the gate only applies to
machine-readable subscription bodies.

Also adapt tests to main's options-struct constructor and to the
ClientService.Update signature extended with limitHwid.

* fix(frontend): drop axios from HttpUtil.delete

The bulk-delete rework's committed version still referenced axios,
which this file no longer imports, breaking typecheck in CI. Use the
httpRequest wrapper like the other verbs.

---------

Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-08-15 18:20:20 +03:30
committed by GitHub
parent 1793a9b8b4
commit 694ad6deae
45 changed files with 1212 additions and 50 deletions
+105 -1
View File
@@ -1225,6 +1225,9 @@
"keepAlive": {
"type": "integer"
},
"limitHwid": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
@@ -1282,6 +1285,7 @@
"group",
"id",
"keepAlive",
"limitHwid",
"limitIp",
"password",
"preSharedKey",
@@ -5813,6 +5817,7 @@
"totalGB": 53687091200,
"expiryTime": 1735689600000,
"limitIp": 0,
"limitHwid": 0,
"reset": 0,
"inboundIds": [
3,
@@ -5958,6 +5963,7 @@
"expiryTime": 1735689600000,
"tgId": 0,
"limitIp": 0,
"limitHwid": 0,
"enable": true
},
"inboundIds": [
@@ -6024,6 +6030,7 @@
"email": "alice@example.com",
"totalGB": 107374182400,
"expiryTime": 1767225600000,
"limitHwid": 2,
"tgId": 123456789,
"enable": true
}
@@ -6372,7 +6379,7 @@
"tags": [
"Clients"
],
"summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
"summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, HWID devices, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
"operationId": "post_panel_api_clients_delOrphans",
"responses": {
"200": {
@@ -6436,6 +6443,7 @@
"id": "...",
"totalGB": 53687091200,
"expiryTime": 0,
"limitHwid": 2,
"enable": true,
"subId": "..."
},
@@ -6763,6 +6771,7 @@
"email": "alice@example.com",
"totalGB": 53687091200,
"expiryTime": 0,
"limitHwid": 2,
"enable": true
},
"inboundIds": [
@@ -6774,6 +6783,7 @@
"email": "bob@example.com",
"totalGB": 53687091200,
"expiryTime": 0,
"limitHwid": 0,
"enable": true
},
"inboundIds": [
@@ -7568,6 +7578,100 @@
}
}
},
"/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/onlines": {
"post": {
"tags": [
+1
View File
@@ -283,6 +283,7 @@ export const EXAMPLES: Record<string, unknown> = {
"group": "",
"id": 0,
"keepAlive": 0,
"limitHwid": 0,
"limitIp": 0,
"password": "",
"preSharedKey": "",
+4
View File
@@ -1199,6 +1199,9 @@ export const SCHEMAS: Record<string, unknown> = {
"keepAlive": {
"type": "integer"
},
"limitHwid": {
"type": "integer"
},
"limitIp": {
"type": "integer"
},
@@ -1256,6 +1259,7 @@ export const SCHEMAS: Record<string, unknown> = {
"group",
"id",
"keepAlive",
"limitHwid",
"limitIp",
"password",
"preSharedKey",
+1
View File
@@ -294,6 +294,7 @@ export interface ClientRecord {
group: string;
id: number;
keepAlive: number;
limitHwid: number;
limitIp: number;
password: string;
preSharedKey: string;
+1
View File
@@ -314,6 +314,7 @@ export const ClientRecordSchema = z.object({
group: z.string(),
id: z.number().int(),
keepAlive: z.number().int(),
limitHwid: z.number().int(),
limitIp: z.number().int(),
password: z.string(),
preSharedKey: z.string(),
+1
View File
@@ -529,6 +529,7 @@ export function useClients(options: UseClientsOptions = {}) {
totalGB: base.totalGB || 0,
expiryTime: base.expiryTime || 0,
limitIp: base.limitIp || 0,
limitHwid: base.limitHwid || 0,
tgId: Number(base.tgId) || 0,
reset: Number(base.reset) || 0,
group: base.group || '',
+24 -7
View File
@@ -575,7 +575,7 @@ export const sections: readonly Section[] = [
{ name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
],
response:
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\n "subId": "abcd1234",\n "enable": true,\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "limitIp": 0,\n "limitHwid": 0,\n "reset": 0,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true },\n "createdAt": 1735000000000,\n "updatedAt": 1735100000000\n }\n ],\n "total": 2000,\n "filtered": 47,\n "page": 1,\n "pageSize": 25,\n "summary": {\n "total": 2000,\n "active": 1850,\n "onlineCount": 1,\n "depletedCount": 0,\n "expiringCount": 0,\n "deactiveCount": 150,\n "online": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}',
},
{
method: 'GET',
@@ -602,10 +602,10 @@ export const sections: readonly Section[] = [
path: '/panel/api/clients/add',
summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
params: [
{ name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
{ name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, limitHwid, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
{ name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
],
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
response: '{\n "success": true,\n "msg": "Client added"\n}',
},
{
@@ -615,7 +615,7 @@ export const sections: readonly Section[] = [
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
],
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "tgId": 123456789,\n "enable": true\n}',
body: '{\n "email": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "limitHwid": 2,\n "tgId": 123456789,\n "enable": true\n}',
response: '{\n "success": true,\n "msg": "Client updated"\n}',
},
{
@@ -676,14 +676,14 @@ export const sections: readonly Section[] = [
{
method: 'POST',
path: '/panel/api/clients/delOrphans',
summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, HWID devices, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
response: '{\n "success": true,\n "obj": {\n "deleted": 0\n }\n}',
},
{
method: 'GET',
path: '/panel/api/clients/export',
summary: 'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
response: '{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\n "id": "...",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true,\n "subId": "..."\n },\n "inboundIds": [7, 9]\n }\n ]\n}',
response: '{\n "success": true,\n "obj": [\n {\n "client": {\n "email": "alice@example.com",\n "id": "...",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true,\n "subId": "..."\n },\n "inboundIds": [7, 9]\n }\n ]\n}',
},
{
method: 'POST',
@@ -724,7 +724,7 @@ export const sections: readonly Section[] = [
method: 'POST',
path: '/panel/api/clients/bulkCreate',
summary: 'Create many clients in one call. Body is a JSON array of {client, inboundIds} payloads — the same shape /add accepts. Items are processed sequentially; per-email skip reasons are returned for items that fail (e.g., duplicate email). Triggers a single Xray restart at the end if any inbound was running.',
body: '[\n {\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7, 9]\n }\n]',
body: '[\n {\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [7, 9]\n }\n]',
response: '{\n "success": true,\n "obj": {\n "created": 2,\n "skipped": [\n { "email": "alice@example.com", "reason": "email already in use" }\n ]\n }\n}',
},
{
@@ -846,6 +846,23 @@ export const sections: readonly Section[] = [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
},
{
method: 'POST',
path: '/panel/api/clients/hwids/:email',
summary: 'List registered HWID devices for a client. Hashes are not exposed.',
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
],
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "firstSeen": 1735000000000,\n "lastSeen": 1735100000000,\n "userAgent": "Happ/1.0",\n "deviceOs": "android",\n "osVersion": "15",\n "deviceModel": "Pixel 9"\n }\n ]\n}',
},
{
method: 'DELETE',
path: '/panel/api/clients/hwids/:email',
summary: '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: 'POST',
path: '/panel/api/clients/onlines',
@@ -33,6 +33,7 @@ const EMPTY: ClientBulkAddFormValues = {
comment: '',
flow: '',
limitIp: 0,
limitHwid: 0,
totalGB: 0,
expiryTime: 0,
reset: 0,
@@ -176,6 +177,7 @@ export default function ClientBulkAddModal({
expiryTime: current.expiryTime,
reset: Number(current.reset) || 0,
limitIp: Number(current.limitIp) || 0,
limitHwid: Number(current.limitHwid) || 0,
group: current.group,
comment: current.comment,
enable: true,
@@ -301,6 +303,15 @@ export default function ClientBulkAddModal({
/>
</FormField>
<FormField
name="limitHwid"
label={t('pages.clients.limitHwid')}
tooltip={t('pages.clients.limitHwidDesc')}
transform={{ output: (v) => Number(v) || 0 }}
>
<InputNumber min={0} />
</FormField>
<FormField name="comment" label={t('comment')}>
<Input />
</FormField>
+117 -3
View File
@@ -57,6 +57,16 @@ 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 {
@@ -97,6 +107,7 @@ interface ClientFormModalProps {
type Values = ClientFormValues & {
expiryDate: number;
limitHwid: number;
externalLinks: ExternalLinkRow[];
wgPrivateKey: string;
wgPublicKey: string;
@@ -121,6 +132,7 @@ const EMPTY: Values = {
delayedDays: 0,
reset: 0,
limitIp: 0,
limitHwid: 0,
tgId: 0,
group: '',
comment: '',
@@ -189,6 +201,7 @@ export default function ClientFormModal({
const uuid = useWatch({ control: methods.control, name: 'uuid' });
const password = useWatch({ control: methods.control, name: 'password' });
const subId = useWatch({ control: methods.control, name: 'subId' });
const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
const auth = useWatch({ control: methods.control, name: 'auth' });
const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
@@ -204,6 +217,10 @@ 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 [hwidsModalOpen, setHwidsModalOpen] = useState(false);
const fail2ban = useFail2banStatusQuery();
const limitIpDisabled = !fail2ban.usable;
const limitIpNotice = getLimitIpNotice(fail2ban, t);
@@ -215,6 +232,7 @@ export default function ClientFormModal({
useEffect(() => {
if (!open) return;
setIpsModalOpen(false);
setHwidsModalOpen(false);
if (isEdit && client) {
const et = Number(client.expiryTime) || 0;
@@ -233,6 +251,7 @@ export default function ClientFormModal({
totalGB: bytesToGB(client.totalGB || 0),
reset: Number(client.reset) || 0,
limitIp: client.limitIp || 0,
limitHwid: client.limitHwid || 0,
tgId: Number(client.tgId) || 0,
group: client.group || '',
comment: client.comment || '',
@@ -257,6 +276,7 @@ export default function ClientFormModal({
}
methods.reset(seed);
void loadIps();
void loadHwids();
} else {
const wgKeypair = Wireguard.generateKeypair();
methods.reset({
@@ -455,6 +475,34 @@ 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);
}
@@ -478,7 +526,7 @@ export default function ClientFormModal({
const values = methods.getValues();
const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
const validated = schema.safeParse({
email: values.email,
email: values.email,
subId: values.subId,
uuid: values.uuid,
password: values.password,
@@ -491,6 +539,7 @@ export default function ClientFormModal({
delayedDays: values.delayedDays,
reset: values.reset,
limitIp: values.limitIp,
limitHwid: values.limitHwid,
tgId: values.tgId,
group: values.group,
comment: values.comment,
@@ -516,8 +565,9 @@ export default function ClientFormModal({
security: showSecurity ? (values.security || 'auto') : 'auto',
totalGB: totalBytes,
expiryTime,
reset: Number(values.reset) || 0,
reset: Number(values.reset) || 0,
limitIp: Number(values.limitIp) || 0,
limitHwid: Number(values.limitHwid) || 0,
tgId: Number(values.tgId) || 0,
group: values.group,
comment: values.comment,
@@ -621,7 +671,7 @@ export default function ClientFormModal({
</div>
}
>
<FormProvider {...methods}>
<FormProvider {...methods}>
<Form layout="vertical">
<Tabs
defaultActiveKey="basic"
@@ -677,6 +727,21 @@ export default function ClientFormModal({
</Tooltip>
</Form.Item>
</Col>
<Col xs={24} md={6}>
<Form.Item label={t('pages.clients.limitHwid')} tooltip={t('pages.clients.limitHwidDesc')}>
<Space.Compact style={{ display: 'flex' }}>
<InputNumber value={limitHwid} min={0} style={{ flex: 1 }}
onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)} />
{isEdit && (
<Tooltip title={t('pages.clients.hwidLog')}>
<Button aria-label={t('pages.clients.hwidLog')} icon={<EyeOutlined />} loading={hwidsLoading} onClick={openHwidsModal}>
{clientHwids.length > 0 ? clientHwids.length : ''}
</Button>
</Tooltip>
)}
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
@@ -1012,6 +1077,55 @@ export default function ClientFormModal({
<Tag>{t('tgbot.noIpRecord')}</Tag>
)}
</Modal>
<Modal
open={hwidsModalOpen}
title={`${t('pages.clients.hwidLog')}${client?.email ? `${client.email}` : ''}`}
width={520}
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>
</>
);
}
+3
View File
@@ -25,6 +25,7 @@ export const ClientRecordSchema = z.object({
totalGB: z.number().optional(),
expiryTime: z.number().optional(),
limitIp: z.number().optional(),
limitHwid: z.number().optional(),
tgId: z.union([z.number(), z.string()]).optional(),
group: z.string().optional(),
comment: z.string().optional(),
@@ -205,6 +206,7 @@ export const ClientFormSchema = z.object({
delayedDays: z.number().int().min(0),
reset: z.number().int().min(0),
limitIp: z.number().int().min(0),
limitHwid: z.number().int().min(0),
tgId: z.number().int().min(0),
group: z.string(),
comment: z.string(),
@@ -238,6 +240,7 @@ export const ClientBulkAddFormSchema = z.object({
comment: z.string(),
flow: z.string(),
limitIp: z.number().int().min(0),
limitHwid: z.number().int().min(0),
totalGB: z.number().min(0),
expiryTime: z.number(),
reset: z.number().int().min(0),
+17
View File
@@ -105,6 +105,23 @@ export class HttpUtil {
}
}
static async delete<T = unknown>(url: string, options: HttpOptions = {}): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
const resp = await httpRequest('DELETE', url, undefined, rest);
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('DELETE request failed:', error);
const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
const data = err.response?.data;
const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
if (!silent) this._handleMsg(errorMsg);
return errorMsg;
}
}
static async postWithModal<T = unknown>(url: string, data?: unknown, modal?: HttpModal | null): Promise<Msg<T>> {
if (modal) {
modal.loading(true);