feat(clients): add top-level Clients tab and CRUD API

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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-17 07:28:55 +02:00
parent ba3c581372
commit 2bcf287cf1
15 changed files with 1221 additions and 4 deletions
+3
View File
@@ -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)
+3
View File
@@ -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,
}
+165
View File
@@ -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()
}
}
+5
View File
@@ -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")
+371
View File
@@ -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(&copyClient, 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
}
+14
View File
@@ -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",