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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ↻
+
+
+
+
+
+
+
+
+
+
+ ↻
+
+
+
+
+
+
+
+ ↻
+
+
+
+
+
+
+
+
+
+
+ ↻
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('enable') }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('add') }}
+
+
+
+
+
+
+
+ {{ inboundLabel(id) }}
+
+ —
+
+
+ {{ trafficLabel(record) }}
+
+
+ {{ expiryLabel(record) }}
+
+
+
+ {{ record.enable ? t('enable') : t('disable') }}
+
+
+
+
+ {{ t('edit') }}
+ {{ t('delete') }}
+
+
+
+
+
+
+
+
{{ t('pages.clients.empty') || 'No clients yet.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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",