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.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" = "保存"