From 2a9d9a0a6b119f02101b66d8e0b59f70b279ed6f Mon Sep 17 00:00:00 2001
From: Sora39831 <540587985@qq.com>
Date: Sat, 4 Apr 2026 14:59:40 +0800
Subject: [PATCH] feat: add admin user management
---
web/controller/api.go | 6 +
web/controller/base.go | 19 ++
web/controller/user.go | 133 +++++++++++
web/controller/util.go | 6 +
web/controller/xui.go | 6 +
web/html/component/aSidebar.html | 9 +-
web/html/users.html | 297 +++++++++++++++++++++++
web/service/user.go | 344 +++++++++++++++++++++------
web/translation/translate.en_US.toml | 41 ++++
web/translation/translate.zh_CN.toml | 41 ++++
10 files changed, 826 insertions(+), 76 deletions(-)
create mode 100644 web/controller/user.go
create mode 100644 web/html/users.html
diff --git a/web/controller/api.go b/web/controller/api.go
index 1a39f8ed..74a6d301 100644
--- a/web/controller/api.go
+++ b/web/controller/api.go
@@ -14,6 +14,7 @@ type APIController struct {
BaseController
inboundController *InboundController
serverController *ServerController
+ userController *UserController
Tgbot service.Tgbot
}
@@ -48,6 +49,11 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
server := api.Group("/server")
a.serverController = NewServerController(server)
+ // Users API
+ users := api.Group("/users")
+ users.Use(a.checkAdmin)
+ a.userController = NewUserController(users)
+
// Extra routes
api.GET("/backuptotgbot", a.BackuptoTgbot)
}
diff --git a/web/controller/base.go b/web/controller/base.go
index 7bc61b64..4729966c 100644
--- a/web/controller/base.go
+++ b/web/controller/base.go
@@ -29,6 +29,25 @@ func (a *BaseController) checkLogin(c *gin.Context) {
}
}
+// checkAdmin ensures the current request is made by an authenticated admin user.
+func (a *BaseController) checkAdmin(c *gin.Context) {
+ user := session.GetLoginUser(c)
+ if user == nil {
+ a.checkLogin(c)
+ return
+ }
+ if user.Role != "admin" {
+ if isAjax(c) {
+ pureJsonMsg(c, http.StatusForbidden, false, I18nWeb(c, "pages.users.toasts.adminOnly"))
+ } else {
+ c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/user")
+ }
+ c.Abort()
+ return
+ }
+ c.Next()
+}
+
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
func I18nWeb(c *gin.Context, name string, params ...string) string {
anyfunc, funcExists := c.Get("I18n")
diff --git a/web/controller/user.go b/web/controller/user.go
new file mode 100644
index 00000000..6e3ccb07
--- /dev/null
+++ b/web/controller/user.go
@@ -0,0 +1,133 @@
+package controller
+
+import (
+ "errors"
+ "strconv"
+
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+ "github.com/mhsanaei/3x-ui/v2/util/crypto"
+ "github.com/mhsanaei/3x-ui/v2/web/service"
+ "github.com/mhsanaei/3x-ui/v2/web/session"
+)
+
+type managedUserForm struct {
+ Username string `json:"username" form:"username"`
+ Password string `json:"password" form:"password"`
+ Role string `json:"role" form:"role"`
+}
+
+// UserController handles admin user management APIs.
+type UserController struct {
+ userService service.UserService
+ inboundService service.InboundService
+}
+
+// NewUserController creates a new UserController and initializes its routes.
+func NewUserController(g *gin.RouterGroup) *UserController {
+ a := &UserController{}
+ a.initRouter(g)
+ return a
+}
+
+func (a *UserController) initRouter(g *gin.RouterGroup) {
+ g.GET("/list", a.listUsers)
+ g.POST("/add", a.addUser)
+ g.POST("/update/:id", a.updateUser)
+ g.POST("/del/:id", a.deleteUser)
+}
+
+func (a *UserController) listUsers(c *gin.Context) {
+ users, err := a.userService.GetUsers()
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.obtain"), err)
+ return
+ }
+ jsonObj(c, users, nil)
+}
+
+func (a *UserController) addUser(c *gin.Context) {
+ form := &managedUserForm{}
+ if err := c.ShouldBind(form); err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.create"), err)
+ return
+ }
+
+ user, err := a.userService.CreateUser(form.Username, form.Password, form.Role, &a.inboundService)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.create"), a.localizeUserError(c, err))
+ return
+ }
+ jsonMsgObj(c, I18nWeb(c, "pages.users.toasts.create"), user, nil)
+}
+
+func (a *UserController) updateUser(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.update"), err)
+ return
+ }
+
+ form := &managedUserForm{}
+ if err := c.ShouldBind(form); err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.update"), err)
+ return
+ }
+
+ currentUser := session.GetLoginUser(c)
+ user, err := a.userService.UpdateManagedUser(id, form.Username, form.Password, form.Role, currentUser.Id)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.update"), a.localizeUserError(c, err))
+ return
+ }
+
+ if currentUser != nil && currentUser.Id == id {
+ currentUser.Username = user.Username
+ currentUser.Role = user.Role
+ if form.Password != "" {
+ currentUser.Password, _ = crypto.HashPasswordAsBcrypt(form.Password)
+ }
+ session.SetLoginUser(c, currentUser)
+ if saveErr := sessions.Default(c).Save(); saveErr != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.update"), saveErr)
+ return
+ }
+ }
+
+ jsonMsgObj(c, I18nWeb(c, "pages.users.toasts.update"), user, nil)
+}
+
+func (a *UserController) deleteUser(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), err)
+ return
+ }
+
+ currentUser := session.GetLoginUser(c)
+ err = a.userService.DeleteUser(id, currentUser.Id)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), a.localizeUserError(c, err))
+ return
+ }
+ jsonMsg(c, I18nWeb(c, "pages.users.toasts.delete"), nil)
+}
+
+func (a *UserController) localizeUserError(c *gin.Context, err error) error {
+ switch {
+ case errors.Is(err, service.ErrUsernameAlreadyExists):
+ return errors.New(I18nWeb(c, "pages.users.errors.userExists"))
+ case errors.Is(err, service.ErrInvalidUserRole):
+ return errors.New(I18nWeb(c, "pages.users.errors.invalidRole"))
+ case errors.Is(err, service.ErrUserNotFound):
+ return errors.New(I18nWeb(c, "pages.users.errors.userNotFound"))
+ case errors.Is(err, service.ErrCannotDeleteSelf):
+ return errors.New(I18nWeb(c, "pages.users.errors.cannotDeleteSelf"))
+ case errors.Is(err, service.ErrLastAdminRequired):
+ return errors.New(I18nWeb(c, "pages.users.errors.lastAdminRequired"))
+ case errors.Is(err, service.ErrCannotDemoteSelf):
+ return errors.New(I18nWeb(c, "pages.users.errors.cannotDemoteSelf"))
+ default:
+ return err
+ }
+}
diff --git a/web/controller/util.go b/web/controller/util.go
index b11203bd..df4d4b9f 100644
--- a/web/controller/util.go
+++ b/web/controller/util.go
@@ -8,6 +8,7 @@ import (
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/entity"
+ "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
@@ -84,6 +85,11 @@ func html(c *gin.Context, name string, title string, data gin.H) {
data["host"] = host
data["request_uri"] = c.Request.RequestURI
data["base_path"] = c.GetString("base_path")
+ if user := session.GetLoginUser(c); user != nil {
+ data["is_admin"] = user.Role == "admin"
+ data["current_user_id"] = user.Id
+ data["current_username"] = user.Username
+ }
c.HTML(http.StatusOK, name, getContext(data))
}
diff --git a/web/controller/xui.go b/web/controller/xui.go
index a30b8034..44ff5e1e 100644
--- a/web/controller/xui.go
+++ b/web/controller/xui.go
@@ -33,6 +33,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/inbounds", a.inbounds)
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
+ g.GET("/users", a.checkAdmin, a.users)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
@@ -67,3 +68,8 @@ func (a *XUIController) settings(c *gin.Context) {
func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil)
}
+
+// users renders the admin user management page.
+func (a *XUIController) users(c *gin.Context) {
+ html(c, "users.html", "pages.users.title", nil)
+}
diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html
index b69c8f3f..cde8da40 100644
--- a/web/html/component/aSidebar.html
+++ b/web/html/component/aSidebar.html
@@ -64,6 +64,13 @@
icon: 'tool',
title: '{{ i18n "menu.xray"}}'
},
+ {{if .is_admin}}
+ {
+ key: '{{ .base_path }}panel/users',
+ icon: 'team',
+ title: '{{ i18n "menu.users"}}'
+ },
+ {{end}}
{
key: '{{ .base_path }}logout/',
icon: 'logout',
@@ -100,4 +107,4 @@
template: `{{template "component/sidebar/content"}}`,
});
-{{end}}
\ No newline at end of file
+{{end}}
diff --git a/web/html/users.html b/web/html/users.html
new file mode 100644
index 00000000..2d386a0d
--- /dev/null
+++ b/web/html/users.html
@@ -0,0 +1,297 @@
+{{ template "page/head_start" .}}
+{{ template "page/head_end" .}}
+
+{{ template "page/body_start" .}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n "refresh" }}
+ {{ i18n "create" }}
+
+
+
+
+
+
+
+
+
+ {{ i18n "pages.users.listTitle" }}
+ [[ filteredUsers.length ]]
+
+
+
+
+
+ [[ getRoleLabel(record.role) ]]
+
+
+
+
+
+ {{ i18n "edit" }}
+
+
+
+ {{ i18n "delete" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n "pages.users.roles.admin" }}
+ {{ i18n "pages.users.roles.user" }}
+
+
+
+
+
+{{template "page/body_scripts" .}}
+{{template "component/aSidebar" .}}
+{{template "component/aThemeSwitch" .}}
+
+{{ template "page/body_end" .}}
diff --git a/web/service/user.go b/web/service/user.go
index fb713ab5..4704c767 100644
--- a/web/service/user.go
+++ b/web/service/user.go
@@ -17,6 +17,18 @@ import (
// ErrUsernameAlreadyExists is returned when a user tries to register with a taken username.
var ErrUsernameAlreadyExists = errors.New("username already exists")
+var ErrInvalidUserRole = errors.New("role must be admin or user")
+var ErrUserNotFound = errors.New("user not found")
+var ErrCannotDeleteSelf = errors.New("cannot delete current user")
+var ErrLastAdminRequired = errors.New("at least one admin user must remain")
+var ErrCannotDemoteSelf = errors.New("cannot change your own role to non-admin")
+
+// UserInfo is the sanitized user payload returned to the frontend.
+type UserInfo struct {
+ Id int `json:"id"`
+ Username string `json:"username"`
+ Role string `json:"role"`
+}
// UserService provides business logic for user management and authentication.
// It handles user creation, login, password management, and 2FA operations.
@@ -24,6 +36,121 @@ type UserService struct {
settingService SettingService
}
+func normalizeManagedUserInput(username string, password string, role string, passwordRequired bool) (string, string, string, error) {
+ username = strings.TrimSpace(username)
+ password = strings.TrimSpace(password)
+ role = strings.ToLower(strings.TrimSpace(role))
+ if role == "" {
+ role = "user"
+ }
+
+ if username == "" {
+ return "", "", "", errors.New("username can not be empty")
+ }
+ if len(username) < 3 || len(username) > 64 {
+ return "", "", "", errors.New("username must be 3-64 characters")
+ }
+ if role != "admin" && role != "user" {
+ return "", "", "", ErrInvalidUserRole
+ }
+ if passwordRequired && password == "" {
+ return "", "", "", errors.New("password can not be empty")
+ }
+ if password != "" && (len(password) < 8 || len(password) > 128) {
+ return "", "", "", errors.New("password must be 8-128 characters")
+ }
+ return username, password, role, nil
+}
+
+func sanitizeUser(user *model.User) *UserInfo {
+ if user == nil {
+ return nil
+ }
+ return &UserInfo{
+ Id: user.Id,
+ Username: user.Username,
+ Role: user.Role,
+ }
+}
+
+func isUniqueConstraintError(err error) bool {
+ if err == nil {
+ return false
+ }
+ errMsg := err.Error()
+ return strings.Contains(errMsg, "UNIQUE constraint failed") || strings.Contains(errMsg, "Duplicate")
+}
+
+func (s *UserService) countAdmins(tx *gorm.DB) (int64, error) {
+ var count int64
+ err := tx.Model(&model.User{}).Where("role = ?", "admin").Count(&count).Error
+ return count, err
+}
+
+func (s *UserService) addUserClientsToAllInbounds(tx *gorm.DB, username string, inboundService *InboundService) error {
+ inbounds, err := inboundService.GetAllInbounds()
+ if err != nil {
+ return err
+ }
+
+ for _, inbound := range inbounds {
+ clientID := uuid.New().String()
+ client := model.Client{
+ ID: clientID,
+ Email: username,
+ Enable: false,
+ SubID: uuid.New().String()[:8],
+ Comment: "auto-added on registration",
+ }
+
+ clientEntry := map[string]any{
+ "email": client.Email,
+ "enable": client.Enable,
+ "totalGB": 0,
+ "expiryTime": 0,
+ "limitIp": 0,
+ "subId": client.SubID,
+ "comment": client.Comment,
+ "created_at": 0,
+ "updated_at": 0,
+ }
+ switch inbound.Protocol {
+ case "trojan":
+ clientEntry["password"] = clientID
+ case "shadowsocks":
+ clientEntry["password"] = clientID
+ default:
+ clientEntry["id"] = clientID
+ }
+
+ var settings map[string]any
+ if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+ return err
+ }
+ clientsRaw, ok := settings["clients"].([]any)
+ if !ok {
+ clientsRaw = []any{}
+ }
+ clientsRaw = append(clientsRaw, clientEntry)
+ settings["clients"] = clientsRaw
+
+ newSettings, err := json.Marshal(settings)
+ if err != nil {
+ return err
+ }
+ inbound.Settings = string(newSettings)
+
+ if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Update("settings", inbound.Settings).Error; err != nil {
+ return err
+ }
+ if err := inboundService.AddClientStat(tx, inbound.Id, &client); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// GetFirstUser retrieves the first user from the database.
// This is typically used for initial setup or when there's only one admin user.
func (s *UserService) GetFirstUser() (*model.User, error) {
@@ -132,12 +259,147 @@ func (s *UserService) UpdateUser(id int, username string, password string) error
Error
}
-func (s *UserService) RegisterUser(username string, password string, inboundService *InboundService) error {
- if username == "" {
- return errors.New("username can not be empty")
+// GetUsers returns all panel users without sensitive fields.
+func (s *UserService) GetUsers() ([]UserInfo, error) {
+ db := database.GetDB()
+ users := make([]UserInfo, 0)
+ err := db.Model(&model.User{}).
+ Select("id", "username", "role").
+ Order("id asc").
+ Find(&users).
+ Error
+ return users, err
+}
+
+// CreateUser creates a new managed user.
+func (s *UserService) CreateUser(username string, password string, role string, inboundService *InboundService) (*UserInfo, error) {
+ username, password, role, err := normalizeManagedUserInput(username, password, role, true)
+ if err != nil {
+ return nil, err
}
- if password == "" {
- return errors.New("password can not be empty")
+
+ hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
+ if err != nil {
+ return nil, err
+ }
+
+ db := database.GetDB()
+ user := &model.User{
+ Username: username,
+ Password: hashedPassword,
+ Role: role,
+ }
+
+ err = db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(user).Error; err != nil {
+ if isUniqueConstraintError(err) {
+ return ErrUsernameAlreadyExists
+ }
+ return err
+ }
+ if role == "user" {
+ if err := s.addUserClientsToAllInbounds(tx, username, inboundService); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return sanitizeUser(user), nil
+}
+
+// UpdateManagedUser updates username, password, and role for a managed user.
+func (s *UserService) UpdateManagedUser(id int, username string, password string, role string, currentUserId int) (*UserInfo, error) {
+ username, password, role, err := normalizeManagedUserInput(username, password, role, false)
+ if err != nil {
+ return nil, err
+ }
+
+ db := database.GetDB()
+ user := &model.User{}
+ if err := db.Model(&model.User{}).Where("id = ?", id).First(user).Error; err != nil {
+ if database.IsNotFound(err) {
+ return nil, ErrUserNotFound
+ }
+ return nil, err
+ }
+
+ if currentUserId == id && role != "admin" {
+ return nil, ErrCannotDemoteSelf
+ }
+
+ err = db.Transaction(func(tx *gorm.DB) error {
+ if user.Role == "admin" && role != "admin" {
+ adminCount, err := s.countAdmins(tx)
+ if err != nil {
+ return err
+ }
+ if adminCount <= 1 {
+ return ErrLastAdminRequired
+ }
+ }
+
+ updates := map[string]any{
+ "username": username,
+ "role": role,
+ }
+ if password != "" {
+ hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
+ if err != nil {
+ return err
+ }
+ updates["password"] = hashedPassword
+ }
+
+ if err := tx.Model(&model.User{}).Where("id = ?", id).Updates(updates).Error; err != nil {
+ if isUniqueConstraintError(err) {
+ return ErrUsernameAlreadyExists
+ }
+ return err
+ }
+ return tx.Model(&model.User{}).Where("id = ?", id).First(user).Error
+ })
+ if err != nil {
+ return nil, err
+ }
+ return sanitizeUser(user), nil
+}
+
+// DeleteUser deletes a managed user.
+func (s *UserService) DeleteUser(id int, currentUserId int) error {
+ if id == currentUserId {
+ return ErrCannotDeleteSelf
+ }
+
+ db := database.GetDB()
+ user := &model.User{}
+ if err := db.Model(&model.User{}).Where("id = ?", id).First(user).Error; err != nil {
+ if database.IsNotFound(err) {
+ return ErrUserNotFound
+ }
+ return err
+ }
+
+ return db.Transaction(func(tx *gorm.DB) error {
+ if user.Role == "admin" {
+ adminCount, err := s.countAdmins(tx)
+ if err != nil {
+ return err
+ }
+ if adminCount <= 1 {
+ return ErrLastAdminRequired
+ }
+ }
+ return tx.Delete(&model.User{}, id).Error
+ })
+}
+
+func (s *UserService) RegisterUser(username string, password string, inboundService *InboundService) error {
+ username, password, _, err := normalizeManagedUserInput(username, password, "user", true)
+ if err != nil {
+ return err
}
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
@@ -155,80 +417,12 @@ func (s *UserService) RegisterUser(username string, password string, inboundServ
Role: "user",
}
if err := tx.Create(user).Error; err != nil {
- errMsg := err.Error()
- if strings.Contains(errMsg, "UNIQUE constraint failed") || strings.Contains(errMsg, "Duplicate") {
+ if isUniqueConstraintError(err) {
return ErrUsernameAlreadyExists
}
return err
}
-
- // Add the new user as a disabled client to all existing inbounds
- inbounds, err := inboundService.GetAllInbounds()
- if err != nil {
- return err
- }
-
- for _, inbound := range inbounds {
- clientID := uuid.New().String()
- client := model.Client{
- ID: clientID,
- Email: username,
- Enable: false,
- SubID: uuid.New().String()[:8],
- Comment: "auto-added on registration",
- }
-
- // Build the client JSON entry based on protocol
- clientEntry := map[string]any{
- "email": client.Email,
- "enable": client.Enable,
- "totalGB": 0,
- "expiryTime": 0,
- "limitIp": 0,
- "subId": client.SubID,
- "comment": client.Comment,
- "created_at": 0,
- "updated_at": 0,
- }
- switch inbound.Protocol {
- case "trojan":
- clientEntry["password"] = clientID
- case "shadowsocks":
- clientEntry["password"] = clientID
- default:
- clientEntry["id"] = clientID
- }
-
- // Parse inbound settings and append the new client
- var settings map[string]any
- if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
- return err
- }
- clientsRaw, ok := settings["clients"].([]any)
- if !ok {
- clientsRaw = []any{}
- }
- clientsRaw = append(clientsRaw, clientEntry)
- settings["clients"] = clientsRaw
-
- newSettings, err := json.Marshal(settings)
- if err != nil {
- return err
- }
- inbound.Settings = string(newSettings)
-
- // Save the updated inbound settings
- if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).Update("settings", inbound.Settings).Error; err != nil {
- return err
- }
-
- // Create ClientTraffic record for this inbound
- if err := inboundService.AddClientStat(tx, inbound.Id, &client); err != nil {
- return err
- }
- }
-
- return nil
+ return s.addUserClientsToAllInbounds(tx, username, inboundService)
})
}
diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml
index faa51601..f6cf88cf 100644
--- a/web/translation/translate.en_US.toml
+++ b/web/translation/translate.en_US.toml
@@ -13,6 +13,7 @@
"enable" = "Enabled"
"protocol" = "Protocol"
"search" = "Search"
+"refresh" = "Refresh"
"filter" = "Filter"
"loading" = "Loading..."
"second" = "Second"
@@ -94,6 +95,7 @@
"inbounds" = "Inbounds"
"settings" = "Panel Settings"
"xray" = "Xray Configs"
+"users" = "Users"
"logout" = "Log Out"
"link" = "Manage"
@@ -328,6 +330,45 @@
"remained" = "Remained"
"status" = "Status"
+[pages.users]
+"title" = "User Management"
+"listTitle" = "Users"
+"createTitle" = "Create User"
+"editTitle" = "Edit User"
+"searchPlaceholder" = "Search by username or role"
+"usernamePlaceholder" = "Enter username"
+"passwordPlaceholder" = "Leave blank to keep current password"
+"passwordRequiredPlaceholder" = "Enter password"
+"passwordCreateHelp" = "Password length must be 8-128 characters."
+"passwordEditHelp" = "Leave blank if you do not want to reset the password."
+"role" = "Role"
+"deleteConfirm" = "Delete user"
+
+[pages.users.roles]
+"admin" = "Admin"
+"user" = "User"
+
+[pages.users.validation]
+"usernameRequired" = "Username is required"
+"usernameLength" = "Username must be 3-64 characters"
+"passwordRequired" = "Password is required"
+"passwordLength" = "Password must be 8-128 characters"
+
+[pages.users.toasts]
+"obtain" = "Users loaded successfully"
+"create" = "User created successfully"
+"update" = "User updated successfully"
+"delete" = "User deleted successfully"
+"adminOnly" = "Only administrators can access user management"
+
+[pages.users.errors]
+"userExists" = "Username already exists"
+"invalidRole" = "Invalid role"
+"userNotFound" = "User not found"
+"cannotDeleteSelf" = "You cannot delete the currently logged-in user"
+"lastAdminRequired" = "At least one administrator account must remain"
+"cannotDemoteSelf" = "You cannot change the current logged-in user to non-admin"
+
[pages.settings]
"title" = "Panel Settings"
"save" = "Save"
diff --git a/web/translation/translate.zh_CN.toml b/web/translation/translate.zh_CN.toml
index 0b162660..33ebd14c 100644
--- a/web/translation/translate.zh_CN.toml
+++ b/web/translation/translate.zh_CN.toml
@@ -13,6 +13,7 @@
"enable" = "启用"
"protocol" = "协议"
"search" = "搜索"
+"refresh" = "刷新"
"filter" = "筛选"
"loading" = "加载中..."
"second" = "秒"
@@ -94,6 +95,7 @@
"inbounds" = "入站列表"
"settings" = "面板设置"
"xray" = "Xray 设置"
+"users" = "用户管理"
"logout" = "退出登录"
"link" = "管理"
@@ -328,6 +330,45 @@
"remained" = "剩余流量"
"status" = "状态"
+[pages.users]
+"title" = "用户管理"
+"listTitle" = "用户列表"
+"createTitle" = "新增用户"
+"editTitle" = "编辑用户"
+"searchPlaceholder" = "按用户名或角色搜索"
+"usernamePlaceholder" = "请输入用户名"
+"passwordPlaceholder" = "留空表示不修改密码"
+"passwordRequiredPlaceholder" = "请输入密码"
+"passwordCreateHelp" = "密码长度需为 8-128 位。"
+"passwordEditHelp" = "如无需重置密码,可留空。"
+"role" = "角色"
+"deleteConfirm" = "确认删除用户"
+
+[pages.users.roles]
+"admin" = "管理员"
+"user" = "普通用户"
+
+[pages.users.validation]
+"usernameRequired" = "请输入用户名"
+"usernameLength" = "用户名长度必须为 3-64 个字符"
+"passwordRequired" = "请输入密码"
+"passwordLength" = "密码长度必须为 8-128 个字符"
+
+[pages.users.toasts]
+"obtain" = "获取用户列表成功"
+"create" = "创建用户成功"
+"update" = "更新用户成功"
+"delete" = "删除用户成功"
+"adminOnly" = "仅管理员可访问用户管理"
+
+[pages.users.errors]
+"userExists" = "用户名已存在"
+"invalidRole" = "角色无效"
+"userNotFound" = "用户不存在"
+"cannotDeleteSelf" = "不能删除当前登录用户"
+"lastAdminRequired" = "至少需要保留一个管理员账户"
+"cannotDemoteSelf" = "不能将当前登录用户降级为普通用户"
+
[pages.settings]
"title" = "面板设置"
"save" = "保存"