From 1f4e2707a0bacfb260f9c47ff33e02184ebcf955 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 17 May 2026 16:31:38 +0200 Subject: [PATCH] refactor(clients): switch client API endpoints from id to email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All client-scoped routes now use the unique email as the path key (get, update, del, attach, detach, links). Email is the stable, protocol-independent identifier — UUIDs don't exist for trojan or shadowsocks, and internal numeric ids leaked panel implementation detail into the public API. Removed the redundant /traffic/byId/:id endpoint (covered by /traffic/:email) and collapsed /links/:id/:email into /links/:email, which now returns links across every attached inbound for the client. Frontend selection, bulk delete, and toggle state are now keyed by email as well, dropping the id→email lookup workaround. Co-Authored-By: Claude Opus 4.7 --- frontend/src/pages/api-docs/endpoints.js | 42 ++++------- .../src/pages/clients/ClientFormModal.vue | 2 +- frontend/src/pages/clients/ClientsPage.vue | 53 +++++++------- frontend/src/pages/clients/useClients.js | 30 +++++--- web/controller/api_docs_test.go | 2 +- web/controller/client.go | 73 +++++-------------- web/runtime/remote_test.go | 6 +- web/service/client.go | 48 +++++++++++- web/service/inbound.go | 64 +++++++--------- web/service/node.go | 2 +- web/service/server.go | 12 +-- 11 files changed, 162 insertions(+), 172 deletions(-) diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 09e3cdfc..9744b69e 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -381,10 +381,10 @@ export const sections = [ }, { method: 'GET', - path: '/panel/api/clients/get/:id', - summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.', + path: '/panel/api/clients/get/:email', + summary: 'Fetch one client by email, including the inbound IDs it is attached to.', params: [ - { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' }, + { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, ], response: '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}', @@ -402,30 +402,30 @@ export const sections = [ }, { method: 'POST', - path: '/panel/api/clients/update/:id', - summary: 'Update an existing client. Changes propagate to every attached inbound. Body is the JSON client payload.', + path: '/panel/api/clients/update/:email', + summary: 'Update an existing client by email. Changes propagate to every attached inbound. Body is the JSON client payload.', params: [ - { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' }, + { 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 "enable": true\n}', response: '{\n "success": true,\n "msg": "Client updated"\n}', }, { method: 'POST', - path: '/panel/api/clients/del/:id', - summary: 'Delete a client. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.', + path: '/panel/api/clients/del/:email', + summary: 'Delete a client by email. Removes it from every attached inbound and drops its traffic record unless keepTraffic=1 is passed.', params: [ - { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' }, + { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, { name: 'keepTraffic', in: 'query', type: 'integer', desc: 'Pass 1 to retain the xray_client_traffic row after deletion.' }, ], response: '{\n "success": true,\n "msg": "Client deleted"\n}', }, { method: 'POST', - path: '/panel/api/clients/:id/attach', + path: '/panel/api/clients/:email/attach', summary: 'Attach an existing client to one or more additional inbounds. Body is JSON.', params: [ - { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' }, + { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' }, ], body: '{\n "inboundIds": [7, 9]\n}', @@ -433,10 +433,10 @@ export const sections = [ }, { method: 'POST', - path: '/panel/api/clients/:id/detach', + path: '/panel/api/clients/:email/detach', summary: 'Detach a client from one or more inbounds without deleting the client.', params: [ - { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' }, + { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' }, ], body: '{\n "inboundIds": [5]\n}', @@ -508,15 +508,6 @@ export const sections = [ ], response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}', }, - { - method: 'GET', - path: '/panel/api/clients/traffic/byId/:id', - summary: 'Traffic counters for a client identified by its UUID/password.', - params: [ - { name: 'id', in: 'path', type: 'string', desc: 'Client UUID / password.' }, - ], - response: '{\n "success": true,\n "obj": [\n {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n ]\n}', - }, { method: 'GET', path: '/panel/api/clients/subLinks/:subId', @@ -530,12 +521,11 @@ export const sections = [ }, { method: 'GET', - path: '/panel/api/clients/links/:id/:email', + path: '/panel/api/clients/links/:email', summary: - "Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.", + "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, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing.", params: [ - { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' }, - { name: 'email', in: 'path', type: 'string', desc: 'Client email.' }, + { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' }, ], response: '{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}', diff --git a/frontend/src/pages/clients/ClientFormModal.vue b/frontend/src/pages/clients/ClientFormModal.vue index 3547ad8c..ec41b08e 100644 --- a/frontend/src/pages/clients/ClientFormModal.vue +++ b/frontend/src/pages/clients/ClientFormModal.vue @@ -219,7 +219,7 @@ async function onSubmit() { const toDetach = [...original].filter((id) => !next.has(id)); msg = await props.save(clientPayload, { isEdit: true, - id: props.client.id, + email: props.client.email, attach: toAttach, detach: toDetach, }); diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue index b506a0d3..506d944a 100644 --- a/frontend/src/pages/clients/ClientsPage.vue +++ b/frontend/src/pages/clients/ClientsPage.vue @@ -62,17 +62,17 @@ useWebSocket({ invalidate: applyInvalidate, }); -const togglingId = ref(null); +const togglingEmail = ref(null); async function onToggleEnable(row, next) { - togglingId.value = row.id; + togglingEmail.value = row.email; try { const msg = await setEnable(row, next); if (!msg?.success) { message.error(msg?.msg || t('somethingWentWrong')); } } finally { - togglingId.value = null; + togglingEmail.value = null; } } @@ -99,19 +99,19 @@ const rowSelection = computed(() => ({ onChange: (keys) => { selectedRowKeys.value = keys; }, })); -function toggleSelect(id, checked) { +function toggleSelect(email, checked) { const cur = new Set(selectedRowKeys.value); - if (checked) cur.add(id); - else cur.delete(id); + if (checked) cur.add(email); + else cur.delete(email); selectedRowKeys.value = Array.from(cur); } -function isSelected(id) { - return selectedRowKeys.value.includes(id); +function isSelected(email) { + return selectedRowKeys.value.includes(email); } function selectAll(checked) { - selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.id) : []; + selectedRowKeys.value = checked ? filteredClients.value.map((c) => c.email) : []; } const allSelected = computed( @@ -127,11 +127,11 @@ function onBulkAdd() { } function onBulkDelete() { - const ids = [...selectedRowKeys.value]; - if (ids.length === 0) return; + const emails = [...selectedRowKeys.value]; + if (emails.length === 0) return; Modal.confirm({ - title: t('pages.clients.bulkDeleteConfirmTitle', { count: ids.length }) - || `Delete ${ids.length} clients?`, + title: t('pages.clients.bulkDeleteConfirmTitle', { count: emails.length }) + || `Delete ${emails.length} clients?`, content: t('pages.clients.bulkDeleteConfirmContent') || 'Each client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.', okText: t('delete'), @@ -140,8 +140,8 @@ function onBulkDelete() { onOk: async () => { let ok = 0; let failed = 0; - for (const id of ids) { - const msg = await remove(id); + for (const email of emails) { + const msg = await remove(email); if (msg?.success) ok++; else failed++; } @@ -318,7 +318,7 @@ function onDelete(row) { okType: 'danger', cancelText: t('cancel'), onOk: async () => { - const msg = await remove(row.id); + const msg = await remove(row.email); if (msg?.success) message.success(t('pages.clients.toasts.deleted') || 'Client deleted'); }, }); @@ -370,15 +370,14 @@ async function onSave(payload, meta) { if (!meta?.isEdit) { return create(payload); } - const id = meta.id; - const updateMsg = await update(id, payload); + const updateMsg = await update(meta.email, payload); if (!updateMsg?.success) return updateMsg; if (Array.isArray(meta.attach) && meta.attach.length > 0) { - const r = await attach(id, meta.attach); + const r = await attach(meta.email, meta.attach); if (!r?.success) return r; } if (Array.isArray(meta.detach) && meta.detach.length > 0) { - const r = await detach(id, meta.detach); + const r = await detach(meta.email, meta.detach); if (!r?.success) return r; } return updateMsg; @@ -595,7 +594,7 @@ const columns = computed(() => [ - @@ -640,7 +639,7 @@ const columns = computed(() => [