From 2bcf287cf158d650547dbf1136e09d94005cc69c Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 17 May 2026 07:28:55 +0200 Subject: [PATCH] feat(clients): add top-level Clients tab and CRUD API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /panel/api/clients endpoints (list, get, add, update, del, attach, detach) backed by ClientService methods that orchestrate the per-inbound Add/Update/Del flows so a single client row is created once and attached to many inbounds in one operation. The frontend gains a dedicated Clients page (frontend/clients.html + src/pages/clients/) with an AntD table, multi-inbound attach modal, and full CRUD. Axios interceptor learns to honour Content-Type: application/json so the JSON endpoints work alongside the legacy form-encoded ones. The legacy per-inbound client modal stays untouched in this PR — both flows now write to the same source of truth. Co-Authored-By: Claude Opus 4.7 --- frontend/clients.html | 13 + frontend/src/api/axios-init.js | 20 +- frontend/src/components/AppSidebar.vue | 3 + frontend/src/entries/clients.js | 21 + frontend/src/pages/api-docs/endpoints.js | 79 ++++ .../src/pages/clients/ClientFormModal.vue | 230 +++++++++++ frontend/src/pages/clients/ClientsPage.vue | 217 ++++++++++ frontend/src/pages/clients/useClients.js | 78 ++++ frontend/vite.config.js | 3 + web/controller/api.go | 3 + web/controller/api_docs_test.go | 3 + web/controller/client.go | 165 ++++++++ web/controller/xui.go | 5 + web/service/client.go | 371 ++++++++++++++++++ web/translation/en-US.json | 14 + 15 files changed, 1221 insertions(+), 4 deletions(-) create mode 100644 frontend/clients.html create mode 100644 frontend/src/entries/clients.js create mode 100644 frontend/src/pages/clients/ClientFormModal.vue create mode 100644 frontend/src/pages/clients/ClientsPage.vue create mode 100644 frontend/src/pages/clients/useClients.js create mode 100644 web/controller/client.go diff --git a/frontend/clients.html b/frontend/clients.html new file mode 100644 index 00000000..a2c03040 --- /dev/null +++ b/frontend/clients.html @@ -0,0 +1,13 @@ + + + + + + Clients + + +
+
+ + + diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index 3055e883..258c26ee 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -76,7 +76,14 @@ export function setupAxios() { if (config.data instanceof FormData) { config.headers['Content-Type'] = 'multipart/form-data'; } else { - config.data = qs.stringify(config.data, { arrayFormat: 'repeat' }); + const declaredType = String(config.headers['Content-Type'] || config.headers['content-type'] || ''); + if (declaredType.toLowerCase().startsWith('application/json')) { + if (config.data !== undefined && typeof config.data !== 'string') { + config.data = JSON.stringify(config.data); + } + } else { + config.data = qs.stringify(config.data, { arrayFormat: 'repeat' }); + } } return config; }, @@ -104,9 +111,14 @@ export function setupAxios() { if (token) { cfg.headers = cfg.headers || {}; cfg.headers['X-CSRF-Token'] = token; - // axios re-stringifies on retry, so unwind our qs.stringify before - // letting the same request flow through the interceptor again. - if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data); + const declaredType = String(cfg.headers['Content-Type'] || cfg.headers['content-type'] || ''); + if (typeof cfg.data === 'string') { + if (declaredType.toLowerCase().startsWith('application/json')) { + try { cfg.data = JSON.parse(cfg.data); } catch (_e) { /* keep as-is */ } + } else { + cfg.data = qs.parse(cfg.data); + } + } return axios(cfg); } } diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index bf625f65..1fd4dfc9 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'; import { DashboardOutlined, UserOutlined, + TeamOutlined, SettingOutlined, ToolOutlined, ClusterOutlined, @@ -30,6 +31,7 @@ const props = defineProps({ const iconByName = { dashboard: DashboardOutlined, user: UserOutlined, + team: TeamOutlined, setting: SettingOutlined, tool: ToolOutlined, cluster: ClusterOutlined, @@ -42,6 +44,7 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base const tabs = computed(() => [ { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') }, { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') }, + { key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') }, { key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') }, { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, diff --git a/frontend/src/entries/clients.js b/frontend/src/entries/clients.js new file mode 100644 index 00000000..fc9fc161 --- /dev/null +++ b/frontend/src/entries/clients.js @@ -0,0 +1,21 @@ +import { createApp } from 'vue'; +import Antd, { message } from 'ant-design-vue'; +import 'ant-design-vue/dist/reset.css'; + +import { setupAxios } from '@/api/axios-init.js'; +import '@/composables/useTheme.js'; +import { i18n, readyI18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; +import ClientsPage from '@/pages/clients/ClientsPage.vue'; + +setupAxios(); +applyDocumentTitle(); + +const messageContainer = document.getElementById('message'); +if (messageContainer) { + message.config({ getContainer: () => messageContainer }); +} + +readyI18n().then(() => { + createApp(ClientsPage).use(Antd).use(i18n).mount('#app'); +}); diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 70415ce5..2b753fcc 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/frontend/src/pages/api-docs/endpoints.js @@ -494,6 +494,85 @@ export const sections = [ ], }, + { + id: 'clients', + title: 'Clients', + description: + 'Manage clients as first-class entities that can be attached to one or more inbounds. A single client row drives the settings.clients entry in every inbound it belongs to. Endpoints live under /panel/api/clients.', + endpoints: [ + { + method: 'GET', + path: '/panel/api/clients/list', + summary: 'List every client with its attached inbound IDs and traffic record.', + response: + '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "email": "alice@example.com",\n "subId": "abcd1234",\n "uuid": "...",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "enable": true,\n "inboundIds": [3, 5],\n "traffic": { "up": 1024, "down": 4096, "enable": true }\n }\n ]\n}', + }, + { + method: 'GET', + path: '/panel/api/clients/get/:id', + summary: 'Fetch one client by its numeric id, including the inbound IDs it is attached to.', + params: [ + { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id from the clients table.' }, + ], + response: + '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5]\n }\n}', + }, + { + method: 'POST', + 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.', + params: [ + { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, totalGB, expiryTime, limitIp, 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 },\n "inboundIds": [3, 5]\n}', + response: '{\n "success": true,\n "msg": "Client added"\n}', + }, + { + 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.', + params: [ + { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' }, + ], + 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.', + params: [ + { name: 'id', in: 'path', type: 'integer', desc: 'Numeric client id.' }, + { 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', + 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: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach.' }, + ], + body: '{\n "inboundIds": [7, 9]\n}', + response: '{\n "success": true\n}', + }, + { + method: 'POST', + path: '/panel/api/clients/:id/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: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach.' }, + ], + body: '{\n "inboundIds": [5]\n}', + response: '{\n "success": true\n}', + }, + ], + }, + { id: 'nodes', title: 'Nodes', diff --git a/frontend/src/pages/clients/ClientFormModal.vue b/frontend/src/pages/clients/ClientFormModal.vue new file mode 100644 index 00000000..fca8a8ab --- /dev/null +++ b/frontend/src/pages/clients/ClientFormModal.vue @@ -0,0 +1,230 @@ + + + diff --git a/frontend/src/pages/clients/ClientsPage.vue b/frontend/src/pages/clients/ClientsPage.vue new file mode 100644 index 00000000..0ebe57a2 --- /dev/null +++ b/frontend/src/pages/clients/ClientsPage.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/frontend/src/pages/clients/useClients.js b/frontend/src/pages/clients/useClients.js new file mode 100644 index 00000000..5cd62ad6 --- /dev/null +++ b/frontend/src/pages/clients/useClients.js @@ -0,0 +1,78 @@ +import { onMounted, ref, shallowRef } from 'vue'; +import { HttpUtil } from '@/utils'; + +const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } }; + +export function useClients() { + const clients = shallowRef([]); + const inbounds = shallowRef([]); + const loading = ref(false); + const fetched = ref(false); + + async function refresh() { + loading.value = true; + try { + const [clientsMsg, inboundsMsg] = await Promise.all([ + HttpUtil.get('/panel/api/clients/list'), + HttpUtil.get('/panel/api/inbounds/list'), + ]); + if (clientsMsg?.success) { + clients.value = Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []; + } + if (inboundsMsg?.success) { + inbounds.value = Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []; + } + fetched.value = true; + } finally { + loading.value = false; + } + } + + async function create(payload) { + const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS); + if (msg?.success) await refresh(); + return msg; + } + + async function update(id, client) { + const msg = await HttpUtil.post(`/panel/api/clients/update/${id}`, client, JSON_HEADERS); + if (msg?.success) await refresh(); + return msg; + } + + async function remove(id, keepTraffic = false) { + const url = keepTraffic + ? `/panel/api/clients/del/${id}?keepTraffic=1` + : `/panel/api/clients/del/${id}`; + const msg = await HttpUtil.post(url); + if (msg?.success) await refresh(); + return msg; + } + + async function attach(id, inboundIds) { + const msg = await HttpUtil.post(`/panel/api/clients/${id}/attach`, { inboundIds }, JSON_HEADERS); + if (msg?.success) await refresh(); + return msg; + } + + async function detach(id, inboundIds) { + const msg = await HttpUtil.post(`/panel/api/clients/${id}/detach`, { inboundIds }, JSON_HEADERS); + if (msg?.success) await refresh(); + return msg; + } + + onMounted(refresh); + + return { + clients, + inbounds, + loading, + fetched, + refresh, + create, + update, + remove, + attach, + detach, + }; +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 91d42c19..b414e813 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -22,6 +22,8 @@ const BASE_MIGRATED_ROUTES = { 'panel/settings/': '/settings.html', 'panel/inbounds': '/inbounds.html', 'panel/inbounds/': '/inbounds.html', + 'panel/clients': '/clients.html', + 'panel/clients/': '/clients.html', 'panel/xray': '/xray.html', 'panel/xray/': '/xray.html', 'panel/nodes': '/nodes.html', @@ -150,6 +152,7 @@ export default defineConfig({ login: path.resolve(__dirname, 'login.html'), settings: path.resolve(__dirname, 'settings.html'), inbounds: path.resolve(__dirname, 'inbounds.html'), + clients: path.resolve(__dirname, 'clients.html'), xray: path.resolve(__dirname, 'xray.html'), nodes: path.resolve(__dirname, 'nodes.html'), apiDocs: path.resolve(__dirname, 'api-docs.html'), diff --git a/web/controller/api.go b/web/controller/api.go index e066af77..b7ac15c1 100644 --- a/web/controller/api.go +++ b/web/controller/api.go @@ -65,6 +65,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom inbounds := api.Group("/inbounds") a.inboundController = NewInboundController(inbounds) + clients := api.Group("/clients") + NewClientController(clients) + // Server API server := api.Group("/server") a.serverController = NewServerController(server) diff --git a/web/controller/api_docs_test.go b/web/controller/api_docs_test.go index d2b1a089..f9a4dc63 100644 --- a/web/controller/api_docs_test.go +++ b/web/controller/api_docs_test.go @@ -87,6 +87,8 @@ func TestAPIRoutesDocumented(t *testing.T) { basePath = "/panel/api" case "inbound.go": basePath = "/panel/api/inbounds" + case "client.go": + basePath = "/panel/api/clients" case "server.go": basePath = "/panel/api/server" case "node.go": @@ -127,6 +129,7 @@ func TestAPIRoutesDocumented(t *testing.T) { // Skip SPA page routes (these are UI pages, not API endpoints) spaPages := map[string]bool{ "/": true, "/panel/": true, "/panel/inbounds": true, + "/panel/clients": true, "/panel/nodes": true, "/panel/settings": true, "/panel/xray": true, "/panel/api-docs": true, } diff --git a/web/controller/client.go b/web/controller/client.go new file mode 100644 index 00000000..e7234791 --- /dev/null +++ b/web/controller/client.go @@ -0,0 +1,165 @@ +package controller + +import ( + "strconv" + + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/web/service" + + "github.com/gin-gonic/gin" +) + +type ClientController struct { + clientService service.ClientService + inboundService service.InboundService + xrayService service.XrayService +} + +func NewClientController(g *gin.RouterGroup) *ClientController { + a := &ClientController{} + a.initRouter(g) + return a +} + +func (a *ClientController) initRouter(g *gin.RouterGroup) { + g.GET("/list", a.list) + g.GET("/get/:id", a.get) + g.POST("/add", a.create) + g.POST("/update/:id", a.update) + g.POST("/del/:id", a.delete) + g.POST("/:id/attach", a.attach) + g.POST("/:id/detach", a.detach) +} + +func (a *ClientController) list(c *gin.Context) { + rows, err := a.clientService.List() + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, rows, nil) +} + +func (a *ClientController) get(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + rec, err := a.clientService.GetByID(id) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + inboundIds, err := a.clientService.GetInboundIdsForRecord(id) + if err != nil { + jsonMsg(c, I18nWeb(c, "get"), err) + return + } + jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil) +} + +func (a *ClientController) create(c *gin.Context) { + var payload service.ClientCreatePayload + if err := c.ShouldBindJSON(&payload); err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + needRestart, err := a.clientService.Create(&a.inboundService, &payload) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } +} + +func (a *ClientController) update(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + var updated model.Client + if err := c.ShouldBindJSON(&updated); err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + needRestart, err := a.clientService.Update(&a.inboundService, id, updated) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } +} + +func (a *ClientController) delete(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + keepTraffic := c.Query("keepTraffic") == "1" + needRestart, err := a.clientService.Delete(&a.inboundService, id, keepTraffic) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } +} + +type attachDetachBody struct { + InboundIds []int `json:"inboundIds"` +} + +func (a *ClientController) attach(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + var body attachDetachBody + if err := c.ShouldBindJSON(&body); err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + needRestart, err := a.clientService.Attach(&a.inboundService, id, body.InboundIds) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } +} + +func (a *ClientController) detach(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + var body attachDetachBody + if err := c.ShouldBindJSON(&body); err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + needRestart, err := a.clientService.Detach(&a.inboundService, id, body.InboundIds) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } +} diff --git a/web/controller/xui.go b/web/controller/xui.go index 2fcf346b..7f7f81de 100644 --- a/web/controller/xui.go +++ b/web/controller/xui.go @@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) { g.GET("/", a.index) g.GET("/inbounds", a.inbounds) + g.GET("/clients", a.clients) g.GET("/nodes", a.nodes) g.GET("/settings", a.settings) g.GET("/xray", a.xraySettings) @@ -62,6 +63,10 @@ func (a *XUIController) inbounds(c *gin.Context) { serveDistPage(c, "inbounds.html") } +func (a *XUIController) clients(c *gin.Context) { + serveDistPage(c, "clients.html") +} + // nodes renders the multi-panel nodes management page. func (a *XUIController) nodes(c *gin.Context) { serveDistPage(c, "nodes.html") diff --git a/web/service/client.go b/web/service/client.go index 354e3fc5..34140b3e 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -1,15 +1,42 @@ package service import ( + "encoding/json" "errors" "strings" + "time" + "github.com/google/uuid" "github.com/mhsanaei/3x-ui/v3/database" "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/util/common" + "github.com/mhsanaei/3x-ui/v3/xray" "gorm.io/gorm" ) +type ClientWithAttachments struct { + model.ClientRecord + InboundIds []int `json:"inboundIds"` + Traffic *xray.ClientTraffic `json:"traffic,omitempty"` +} + +func clientKeyForProtocol(p model.Protocol, rec *model.ClientRecord) string { + if rec == nil { + return "" + } + switch p { + case model.Trojan: + return rec.Password + case model.Shadowsocks: + return rec.Email + case model.Hysteria, model.Hysteria2: + return rec.Auth + default: + return rec.UUID + } +} + type ClientService struct{} func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error { @@ -141,3 +168,347 @@ func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, } return ids, nil } + +func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) { + row := &model.ClientRecord{} + if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil { + return nil, err + } + return row, nil +} + +func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) { + var ids []int + err := database.GetDB().Table("client_inbounds"). + Where("client_id = ?", id). + Order("inbound_id ASC"). + Pluck("inbound_id", &ids).Error + if err != nil { + return nil, err + } + return ids, nil +} + +func (s *ClientService) List() ([]ClientWithAttachments, error) { + db := database.GetDB() + var rows []model.ClientRecord + if err := db.Order("id ASC").Find(&rows).Error; err != nil { + return nil, err + } + if len(rows) == 0 { + return []ClientWithAttachments{}, nil + } + + clientIds := make([]int, 0, len(rows)) + emails := make([]string, 0, len(rows)) + for i := range rows { + clientIds = append(clientIds, rows[i].Id) + if rows[i].Email != "" { + emails = append(emails, rows[i].Email) + } + } + + var links []model.ClientInbound + if err := db.Where("client_id IN ?", clientIds).Find(&links).Error; err != nil { + return nil, err + } + attachments := make(map[int][]int, len(rows)) + for _, l := range links { + attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId) + } + + trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails)) + if len(emails) > 0 { + var stats []xray.ClientTraffic + if err := db.Where("email IN ?", emails).Find(&stats).Error; err != nil { + return nil, err + } + for i := range stats { + trafficByEmail[stats[i].Email] = &stats[i] + } + } + + out := make([]ClientWithAttachments, 0, len(rows)) + for i := range rows { + out = append(out, ClientWithAttachments{ + ClientRecord: rows[i], + InboundIds: attachments[rows[i].Id], + Traffic: trafficByEmail[rows[i].Email], + }) + } + return out, nil +} + +type ClientCreatePayload struct { + Client model.Client `json:"client"` + InboundIds []int `json:"inboundIds"` +} + +func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) { + if payload == nil { + return false, common.NewError("empty payload") + } + client := payload.Client + if strings.TrimSpace(client.Email) == "" { + return false, common.NewError("client email is required") + } + if len(payload.InboundIds) == 0 { + return false, common.NewError("at least one inbound is required") + } + + if client.SubID == "" { + client.SubID = uuid.NewString() + } + if !client.Enable { + client.Enable = true + } + now := time.Now().UnixMilli() + if client.CreatedAt == 0 { + client.CreatedAt = now + } + client.UpdatedAt = now + + existing := &model.ClientRecord{} + err := database.GetDB().Where("email = ?", client.Email).First(existing).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return false, err + } + emailTaken := !errors.Is(err, gorm.ErrRecordNotFound) + if emailTaken { + if existing.SubID == "" || existing.SubID != client.SubID { + return false, common.NewError("email already in use:", client.Email) + } + } + + needRestart := false + for _, ibId := range payload.InboundIds { + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + if err := s.fillProtocolDefaults(&client, inbound.Protocol); err != nil { + return needRestart, err + } + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {client}}) + if mErr != nil { + return needRestart, mErr + } + nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{ + Id: ibId, + Settings: string(settingsPayload), + }) + if addErr != nil { + return needRestart, addErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) fillProtocolDefaults(c *model.Client, p model.Protocol) error { + switch p { + case model.VMESS, model.VLESS: + if c.ID == "" { + c.ID = uuid.NewString() + } + case model.Trojan, model.Shadowsocks: + if c.Password == "" { + c.Password = strings.ReplaceAll(uuid.NewString(), "-", "") + } + case model.Hysteria, model.Hysteria2: + if c.Auth == "" { + c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "") + } + } + return nil +} + +func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + inboundIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + + if strings.TrimSpace(updated.Email) == "" { + return false, common.NewError("client email is required") + } + if updated.SubID == "" { + updated.SubID = existing.SubID + } + if updated.SubID == "" { + updated.SubID = uuid.NewString() + } + updated.UpdatedAt = time.Now().UnixMilli() + if updated.CreatedAt == 0 { + updated.CreatedAt = existing.CreatedAt + } + + needRestart := false + for _, ibId := range inboundIds { + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + oldKey := clientKeyForProtocol(inbound.Protocol, existing) + if oldKey == "" { + continue + } + if err := s.fillProtocolDefaults(&updated, inbound.Protocol); err != nil { + return needRestart, err + } + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {updated}}) + if mErr != nil { + return needRestart, mErr + } + nr, upErr := inboundSvc.UpdateInboundClient(&model.Inbound{ + Id: ibId, + Settings: string(settingsPayload), + }, oldKey) + if upErr != nil { + return needRestart, upErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + inboundIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + + needRestart := false + for _, ibId := range inboundIds { + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + key := clientKeyForProtocol(inbound.Protocol, existing) + if key == "" { + continue + } + nr, delErr := inboundSvc.DelInboundClient(ibId, key) + if delErr != nil { + return needRestart, delErr + } + if nr { + needRestart = true + } + } + + db := database.GetDB() + if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { + return needRestart, err + } + if !keepTraffic && existing.Email != "" { + if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil { + return needRestart, err + } + if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { + return needRestart, err + } + } + if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil { + return needRestart, err + } + return needRestart, nil +} + +func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + currentIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + have := make(map[int]struct{}, len(currentIds)) + for _, x := range currentIds { + have[x] = struct{}{} + } + + clientWire := existing.ToClient() + clientWire.UpdatedAt = time.Now().UnixMilli() + + needRestart := false + for _, ibId := range inboundIds { + if _, attached := have[ibId]; attached { + continue + } + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + copyClient := *clientWire + if err := s.fillProtocolDefaults(©Client, inbound.Protocol); err != nil { + return needRestart, err + } + settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {copyClient}}) + if mErr != nil { + return needRestart, mErr + } + nr, addErr := inboundSvc.AddInboundClient(&model.Inbound{ + Id: ibId, + Settings: string(settingsPayload), + }) + if addErr != nil { + return needRestart, addErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} + +func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) { + existing, err := s.GetByID(id) + if err != nil { + return false, err + } + currentIds, err := s.GetInboundIdsForRecord(id) + if err != nil { + return false, err + } + have := make(map[int]struct{}, len(currentIds)) + for _, x := range currentIds { + have[x] = struct{}{} + } + + needRestart := false + for _, ibId := range inboundIds { + if _, attached := have[ibId]; !attached { + continue + } + inbound, getErr := inboundSvc.GetInbound(ibId) + if getErr != nil { + return needRestart, getErr + } + key := clientKeyForProtocol(inbound.Protocol, existing) + if key == "" { + continue + } + nr, delErr := inboundSvc.DelInboundClient(ibId, key) + if delErr != nil { + return needRestart, delErr + } + if nr { + needRestart = true + } + } + return needRestart, nil +} diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 8231b7e8..ee21ffe5 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -94,6 +94,7 @@ "ultraDark": "Ultra Dark", "dashboard": "Overview", "inbounds": "Inbounds", + "clients": "Clients", "nodes": "Nodes", "settings": "Panel Settings", "xray": "Xray Configs", @@ -397,6 +398,19 @@ "renew": "Auto Renew", "renewDesc": "Auto-renewal after expiration. (0 = disable)(unit: day)" }, + "clients": { + "title": "Clients", + "addTitle": "Add Client", + "editTitle": "Edit Client", + "attachedInbounds": "Attached inbounds", + "selectInbound": "Select one or more inbounds", + "empty": "No clients yet — add one to get started.", + "deleteConfirmTitle": "Delete client {email}?", + "deleteConfirmContent": "This removes the client from every attached inbound and drops its traffic record. This cannot be undone.", + "toasts": { + "deleted": "Client deleted" + } + }, "nodes": { "title": "Nodes", "addNode": "Add Node",