mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +00:00
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:
@@ -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": [
|
||||
|
||||
@@ -283,6 +283,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"group": "",
|
||||
"id": 0,
|
||||
"keepAlive": 0,
|
||||
"limitHwid": 0,
|
||||
"limitIp": 0,
|
||||
"password": "",
|
||||
"preSharedKey": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -294,6 +294,7 @@ export interface ClientRecord {
|
||||
group: string;
|
||||
id: number;
|
||||
keepAlive: number;
|
||||
limitHwid: number;
|
||||
limitIp: number;
|
||||
password: string;
|
||||
preSharedKey: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user