diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index 5833130ef..ac25bf3a7 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -249,6 +249,17 @@ _openapi: Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.' url: '#return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing' + - depth: 2 + title: List registered HWID devices for a client. Hashes are not exposed. + url: '#list-registered-hwid-devices-for-a-client-hashes-are-not-exposed' + - depth: 2 + title: Clear all registered HWID devices for a client so new devices can + register again. + url: '#clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again' + - depth: 2 + title: Remove a single registered HWID device by its id, freeing one slot under + the HWID limit. + url: '#remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit' structuredData: headings: - content: List every client with its attached inbound IDs and traffic record. The @@ -458,6 +469,14 @@ _openapi: proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.' id: return-every-url-for-one-client-across-all-attached-inbounds--the-same-strings-the-copy-url-button-copies-in-the-panel-ui-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-if-streamsettingsexternalproxy-is-set-returns-one-url-per-external-proxy-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing + - content: List registered HWID devices for a client. Hashes are not exposed. + id: list-registered-hwid-devices-for-a-client-hashes-are-not-exposed + - content: Clear all registered HWID devices for a client so new devices can + register again. + id: clear-all-registered-hwid-devices-for-a-client-so-new-devices-can-register-again + - content: Remove a single registered HWID device by its id, freeing one slot + under the HWID limit. + id: remove-a-single-registered-hwid-device-by-its-id-freeing-one-slot-under-the-hwid-limit contents: [] --- @@ -470,7 +489,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 863b08968..8f144a0cd 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -10254,6 +10254,150 @@ } } } + }, + "/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/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": {} + } + } + } + } + } + } + } } } } diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d15f3a311..909f57286 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": [ diff --git a/frontend/src/components/clients/ClientHwidList.tsx b/frontend/src/components/clients/ClientHwidList.tsx new file mode 100644 index 000000000..8c3380a02 --- /dev/null +++ b/frontend/src/components/clients/ClientHwidList.tsx @@ -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 ( + } loading={loading} onClick={onRefresh}> + {t('refresh')} + , + + + , + , + ]} + > + {hwids.length > 0 ? ( +
+ {hwids.map((entry) => ( +
+
+ + {entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')} + +
+ + {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')} + +
+ + {t('pages.clients.firstSeen')}: {formatDate(entry.firstSeen)} + +
+ + {t('pages.clients.lastSeen')}: {formatDate(entry.lastSeen)} + + {entry.userAgent && ( + <> +
+ + {entry.userAgent} + + + )} +
+ onDelete(entry.id)} + okType="danger" + okText={t('delete')} + cancelText={t('cancel')} + > +
+ ))} +
+ ) : ( + {t('pages.clients.noHwids')} + )} +
+ ); +} diff --git a/frontend/src/hooks/useClientHwids.ts b/frontend/src/hooks/useClientHwids.ts new file mode 100644 index 000000000..5e368bb55 --- /dev/null +++ b/frontend/src/hooks/useClientHwids.ts @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { HttpUtil } from '@/utils'; +import { normalizeClientHwids, type ClientHwidInfo } from '@/lib/clients/hwid-log'; + +interface ApiMsg { + 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([]); + const [hwidsLoading, setHwidsLoading] = useState(false); + const [hwidsClearing, setHwidsClearing] = useState(false); + const [deletingHwidId, setDeletingHwidId] = useState(null); + + async function loadHwids() { + if (!email) return; + setHwidsLoading(true); + try { + const msg = (await HttpUtil.post( + `/panel/api/clients/hwids/${encodeURIComponent(email)}`, + )) as ApiMsg; + 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, + }; +} diff --git a/frontend/src/lib/clients/hwid-log.ts b/frontend/src/lib/clients/hwid-log.ts new file mode 100644 index 000000000..41ff390c0 --- /dev/null +++ b/frontend/src/lib/clients/hwid-log.ts @@ -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', + ); +} diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index a7ecdf214..9ca1bad98 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -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', diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index d07501fa1..e9b2e3203 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -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 { 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([]); - 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; - 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({ )} - setHwidsModalOpen(false)} - footer={[ - , - , - , - ]} - > - {clientHwids.length > 0 ? ( -
- {clientHwids.map((entry) => ( -
- - {entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')} - -
- - {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')} - -
- - {t('pages.clients.firstSeen')}:{' '} - {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'} - -
- - {t('pages.clients.lastSeen')}:{' '} - {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'} - - {entry.userAgent && ( - <> -
- - {entry.userAgent} - - - )} -
- ))} -
- ) : ( - {t('pages.clients.noHwids')} - )} -
+ hwids={clientHwids} + loading={hwidsLoading} + clearing={hwidsClearing} + deletingId={deletingHwidId} + formatDate={hwidDateLabel} + onRefresh={loadHwids} + onClearAll={clearHwids} + onDelete={deleteHwid} + onClose={() => setHwidsModalOpen(false)} + /> ); } diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index 75c5921df..af1986114 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -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({ )} + + {t('pages.clients.limitHwid')} + {!client.limitHwid ? : {client.limitHwid}} + + + {t('pages.clients.hwidLog')} + + + + {t('pages.inbounds.createdAt')} @@ -784,6 +822,20 @@ export default function ClientInfoModal({ {t('tgbot.noIpRecord')} )} + + setHwidsModalOpen(false)} + /> ); } diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 46b944c41..d06c09375 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -78,6 +78,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) { g.POST("/clearIps/:email", a.clearIps) g.POST("/hwids/:email", a.getHwids) g.DELETE("/hwids/:email", a.clearHwids) + g.DELETE("/hwids/:email/:id", a.deleteHwid) g.POST("/onlines", a.onlines) g.POST("/onlinesByGuid", a.onlinesByGuid) g.POST("/clientIpsByGuid", a.clientIpsByGuid) @@ -558,6 +559,19 @@ func (a *ClientController) clearHwids(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil) } +func (a *ClientController) deleteHwid(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + if err := a.clientService.DeleteClientHwid(c.Param("email"), id); err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.clients.hwidDeleted"), nil) +} + func (a *ClientController) onlines(c *gin.Context) { jsonObj(c, a.inboundService.GetOnlineClients(), nil) } diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index c73734ee7..eb973f5a9 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -200,6 +200,27 @@ func (s *ClientService) ClearClientHwids(email string) error { return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error } +// DeleteClientHwid removes one device, scoped to the client's sub_id: ids +// are a global auto-increment, so an id outside this subscription won't match. +func (s *ClientService) DeleteClientHwid(email string, id int) error { + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + return err + } + subID := strings.TrimSpace(rec.SubID) + if subID == "" { + return errors.New("client has no subscription id") + } + res := database.GetDB().Where("sub_id = ? AND id = ?", subID, id).Delete(&model.ClientHwid{}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return errors.New("device not found") + } + return nil +} + func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error { if tx == nil { tx = database.GetDB() diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index a15a42369..392192d4e 100644 --- a/internal/web/service/client_hwid_test.go +++ b/internal/web/service/client_hwid_test.go @@ -151,6 +151,53 @@ func TestClientHwidGateRegistersAndBlocks(t *testing.T) { } } +func TestDeleteClientHwid(t *testing.T) { + initClientHwidTestDB(t) + svc := &ClientService{} + db := database.GetDB() + + rec := seedHwidClient(t, 5) + if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-own"}); err != nil { + t.Fatalf("register own device: %v", err) + } + list, err := svc.ListClientHwids(rec.Email) + if err != nil || len(list) != 1 { + t.Fatalf("list own devices: err=%v list=%+v", err, list) + } + ownID := list[0].Id + + other := &model.ClientRecord{Email: "other@example.com", SubID: "sub-other", UUID: "33333333-2222-4333-8444-555555555555", Enable: true, LimitHwid: 5} + if err := db.Create(other).Error; err != nil { + t.Fatalf("seed other client: %v", err) + } + if _, err := svc.EnforceHwidForSubID(other.SubID, HwidRequest{Hwid: "device-foreign"}); err != nil { + t.Fatalf("register foreign device: %v", err) + } + otherList, err := svc.ListClientHwids(other.Email) + if err != nil || len(otherList) != 1 { + t.Fatalf("list foreign devices: err=%v list=%+v", err, otherList) + } + foreignID := otherList[0].Id + + if err := svc.DeleteClientHwid(rec.Email, foreignID); err == nil { + t.Fatalf("deleting a foreign sub_id's device id should fail") + } + if list, err := svc.ListClientHwids(other.Email); err != nil || len(list) != 1 { + t.Fatalf("foreign device should survive a cross-sub_id delete attempt: err=%v list=%+v", err, list) + } + + if err := svc.DeleteClientHwid(rec.Email, 999999); err == nil { + t.Fatalf("deleting an unknown id should fail") + } + + if err := svc.DeleteClientHwid(rec.Email, ownID); err != nil { + t.Fatalf("delete own device: %v", err) + } + if list, err := svc.ListClientHwids(rec.Email); err != nil || len(list) != 0 { + t.Fatalf("own device should be gone: err=%v list=%+v", err, list) + } +} + func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) { initClientHwidTestDB(t) svc := &ClientService{} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 332a22d17..613002b02 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -746,6 +746,10 @@ "noHwids": "لا توجد أجهزة HWID بعد", "firstSeen": "أول ظهور", "lastSeen": "آخر ظهور", + "deleteHwid": "إزالة الجهاز", + "deleteHwidConfirm": "إزالة هذا الجهاز؟ سيحتاج إلى إعادة التسجيل عند جلب الاشتراك التالي.", + "hwidDeleted": "تمت إزالة الجهاز.", + "clearHwidsConfirm": "إزالة جميع الأجهزة المسجلة؟ سيحتاج كل جهاز إلى إعادة التسجيل عند جلب الاشتراك التالي.", "limitIpFail2banMissing": "Fail2ban غير مثبّت، لذا لا يمكن تطبيق حد عناوين IP. ثبّت Fail2ban من قائمة x-ui النصية لتفعيل هذا الخيار.", "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.", "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index f756f3e28..5ab5c03cc 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -746,6 +746,10 @@ "noHwids": "No HWID devices yet", "firstSeen": "First seen", "lastSeen": "Last seen", + "deleteHwid": "Remove device", + "deleteHwidConfirm": "Remove this device? It will need to re-register on its next subscription fetch.", + "hwidDeleted": "Device removed.", + "clearHwidsConfirm": "Remove all registered devices? Every device will need to re-register on its next subscription fetch.", "limitIpFail2banMissing": "Fail2ban is not installed, so the IP limit cannot be enforced. Install Fail2ban from the x-ui bash menu to enable this option.", "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.", "limitIpDisabled": "The IP limit feature is disabled on this server.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 0207ade08..ab3417c62 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -746,6 +746,10 @@ "noHwids": "Aún no hay dispositivos HWID", "firstSeen": "Visto por primera vez", "lastSeen": "Visto por última vez", + "deleteHwid": "Eliminar dispositivo", + "deleteHwidConfirm": "¿Eliminar este dispositivo? Deberá volver a registrarse en la próxima obtención de la suscripción.", + "hwidDeleted": "Dispositivo eliminado.", + "clearHwidsConfirm": "¿Eliminar todos los dispositivos registrados? Cada dispositivo deberá volver a registrarse en la próxima obtención de la suscripción.", "limitIpFail2banMissing": "Fail2ban no está instalado, por lo que no se puede aplicar el límite de IP. Instala Fail2ban desde el menú bash de x-ui para habilitar esta opción.", "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.", "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 52271f81f..0c3dfebec 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -746,6 +746,10 @@ "noHwids": "هنوز دستگاه HWID ثبت نشده است", "firstSeen": "اولین مشاهده", "lastSeen": "آخرین مشاهده", + "deleteHwid": "حذف دستگاه", + "deleteHwidConfirm": "این دستگاه حذف شود؟ در دریافت بعدی اشتراک باید دوباره ثبت‌نام شود.", + "hwidDeleted": "دستگاه حذف شد.", + "clearHwidsConfirm": "همه دستگاه‌های ثبت‌شده حذف شوند؟ هر دستگاه در دریافت بعدی اشتراک باید دوباره ثبت‌نام شود.", "limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمی‌شود. برای فعال‌سازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.", "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.", "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 0c33b9259..c08b65183 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -746,6 +746,10 @@ "noHwids": "Belum ada perangkat HWID", "firstSeen": "Pertama terlihat", "lastSeen": "Terakhir terlihat", + "deleteHwid": "Hapus perangkat", + "deleteHwidConfirm": "Hapus perangkat ini? Perangkat perlu mendaftar ulang pada pengambilan langganan berikutnya.", + "hwidDeleted": "Perangkat dihapus.", + "clearHwidsConfirm": "Hapus semua perangkat terdaftar? Setiap perangkat perlu mendaftar ulang pada pengambilan langganan berikutnya.", "limitIpFail2banMissing": "Fail2ban tidak terpasang, sehingga batas IP tidak dapat diterapkan. Pasang Fail2ban dari menu bash x-ui untuk mengaktifkan opsi ini.", "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.", "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 980633e22..e748089ec 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -746,6 +746,10 @@ "noHwids": "HWID デバイスはまだありません", "firstSeen": "初回確認", "lastSeen": "最終確認", + "deleteHwid": "デバイスを削除", + "deleteHwidConfirm": "このデバイスを削除しますか?次回のサブスクリプション取得時に再登録が必要になります。", + "hwidDeleted": "デバイスを削除しました。", + "clearHwidsConfirm": "登録済みのすべてのデバイスを削除しますか?各デバイスは次回のサブスクリプション取得時に再登録が必要になります。", "limitIpFail2banMissing": "Fail2ban がインストールされていないため、IP 制限を適用できません。このオプションを有効にするには、x-ui の bash メニューから Fail2ban をインストールしてください。", "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。", "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index bbd1500ae..d1fb0c92d 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -746,6 +746,10 @@ "noHwids": "Ainda não há dispositivos HWID", "firstSeen": "Visto primeiro", "lastSeen": "Visto por último", + "deleteHwid": "Remover dispositivo", + "deleteHwidConfirm": "Remover este dispositivo? Ele precisará se registrar novamente na próxima busca da assinatura.", + "hwidDeleted": "Dispositivo removido.", + "clearHwidsConfirm": "Remover todos os dispositivos registrados? Cada dispositivo precisará se registrar novamente na próxima busca da assinatura.", "limitIpFail2banMissing": "O Fail2ban não está instalado, portanto o limite de IP não pode ser aplicado. Instale o Fail2ban pelo menu bash do x-ui para ativar esta opção.", "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.", "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index f91643535..291152b28 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -746,6 +746,10 @@ "noHwids": "Устройств HWID пока нет", "firstSeen": "Первое появление", "lastSeen": "Последнее появление", + "deleteHwid": "Удалить устройство", + "deleteHwidConfirm": "Удалить это устройство? При следующем запросе подписки оно зарегистрируется заново.", + "hwidDeleted": "Устройство удалено.", + "clearHwidsConfirm": "Удалить все зарегистрированные устройства? Каждое устройство зарегистрируется заново при следующем запросе подписки.", "limitIpFail2banMissing": "Fail2ban не установлен, поэтому ограничение по IP не может быть применено. Установите Fail2ban из bash-меню x-ui, чтобы включить эту опцию.", "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.", "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 62185de73..072c7cb67 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -746,6 +746,10 @@ "noHwids": "Henüz HWID cihazı yok", "firstSeen": "İlk görülme", "lastSeen": "Son görülme", + "deleteHwid": "Cihazı kaldır", + "deleteHwidConfirm": "Bu cihaz kaldırılsın mı? Bir sonraki abonelik alımında yeniden kaydolması gerekecek.", + "hwidDeleted": "Cihaz kaldırıldı.", + "clearHwidsConfirm": "Kayıtlı tüm cihazlar kaldırılsın mı? Her cihazın bir sonraki abonelik alımında yeniden kaydolması gerekecek.", "limitIpFail2banMissing": "Fail2ban yüklü değil, bu nedenle IP sınırı uygulanamaz. Bu seçeneği etkinleştirmek için x-ui bash menüsünden Fail2ban'ı yükleyin.", "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.", "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index df1bb229d..fc78f7469 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -746,6 +746,10 @@ "noHwids": "Пристроїв HWID ще немає", "firstSeen": "Перша поява", "lastSeen": "Остання поява", + "deleteHwid": "Видалити пристрій", + "deleteHwidConfirm": "Видалити цей пристрій? Йому потрібно буде зареєструватися знову під час наступного отримання підписки.", + "hwidDeleted": "Пристрій видалено.", + "clearHwidsConfirm": "Видалити всі зареєстровані пристрої? Кожному пристрою потрібно буде зареєструватися знову під час наступного отримання підписки.", "limitIpFail2banMissing": "Fail2ban не встановлено, тому обмеження за IP не може бути застосоване. Встановіть Fail2ban із bash-меню x-ui, щоб увімкнути цю опцію.", "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.", "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 6763324d1..f48747c52 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -746,6 +746,10 @@ "noHwids": "Chưa có thiết bị HWID", "firstSeen": "Lần đầu thấy", "lastSeen": "Lần cuối thấy", + "deleteHwid": "Xóa thiết bị", + "deleteHwidConfirm": "Xóa thiết bị này? Thiết bị sẽ cần đăng ký lại vào lần lấy gói đăng ký tiếp theo.", + "hwidDeleted": "Đã xóa thiết bị.", + "clearHwidsConfirm": "Xóa tất cả thiết bị đã đăng ký? Mỗi thiết bị sẽ cần đăng ký lại vào lần lấy gói đăng ký tiếp theo.", "limitIpFail2banMissing": "Fail2ban chưa được cài đặt nên không thể áp dụng giới hạn IP. Hãy cài đặt Fail2ban từ menu bash x-ui để bật tùy chọn này.", "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.", "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 5e67369a2..f594f003e 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -746,6 +746,10 @@ "noHwids": "暂无 HWID 设备", "firstSeen": "首次出现", "lastSeen": "最后出现", + "deleteHwid": "移除设备", + "deleteHwidConfirm": "移除此设备?下次获取订阅时它将需要重新注册。", + "hwidDeleted": "设备已移除。", + "clearHwidsConfirm": "移除所有已注册的设备?每台设备在下次获取订阅时都需要重新注册。", "limitIpFail2banMissing": "未安装 Fail2ban,无法实施 IP 限制。请从 x-ui 命令行菜单安装 Fail2ban 以启用此选项。", "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。", "limitIpDisabled": "此服务器已禁用 IP 限制功能。", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 9c875a3c8..90171999e 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -746,6 +746,10 @@ "noHwids": "尚無 HWID 裝置", "firstSeen": "首次出現", "lastSeen": "最後出現", + "deleteHwid": "移除裝置", + "deleteHwidConfirm": "移除此裝置?下次取得訂閱時它將需要重新註冊。", + "hwidDeleted": "裝置已移除。", + "clearHwidsConfirm": "移除所有已註冊的裝置?每台裝置在下次取得訂閱時都需要重新註冊。", "limitIpFail2banMissing": "未安裝 Fail2ban,無法實施 IP 限制。請從 x-ui 命令列選單安裝 Fail2ban 以啟用此選項。", "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。", "limitIpDisabled": "此伺服器已停用 IP 限制功能。",