feat: add admin user management

This commit is contained in:
Sora39831 2026-04-04 14:59:40 +08:00
parent b0ce1a7ace
commit 2a9d9a0a6b
10 changed files with 826 additions and 76 deletions

View file

@ -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)
}

View file

@ -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")

133
web/controller/user.go Normal file
View file

@ -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
}
}

View file

@ -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))
}

View file

@ -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)
}

View file

@ -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"}}`,
});
</script>
{{end}}
{{end}}

297
web/html/users.html Normal file
View file

@ -0,0 +1,297 @@
{{ template "page/head_start" .}}
{{ template "page/head_end" .}}
{{ template "page/body_start" .}}
<a-layout id="app" v-cloak :class="themeSwitcher.currentTheme + ' users-page'">
<a-sidebar></a-sidebar>
<a-layout id="content-layout">
<a-layout-content>
<a-spin :spinning="loading" :delay="200" tip='{{ i18n "loading"}}'>
<transition name="list" appear>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 8 : 12]">
<a-col>
<a-card hoverable>
<a-row :gutter="[12, 12]">
<a-col :xs="24" :md="12">
<a-input-search
v-model="searchText"
allow-clear
:placeholder='{{ printf "%q" (i18n "pages.users.searchPlaceholder") }}'>
</a-input-search>
</a-col>
<a-col :xs="24" :md="12">
<a-space :style="toolbarStyle">
<a-button icon="reload" @click="loadUsers">{{ i18n "refresh" }}</a-button>
<a-button type="primary" icon="plus" @click="openCreateModal">{{ i18n "create" }}</a-button>
</a-space>
</a-col>
</a-row>
</a-card>
</a-col>
<a-col>
<a-card hoverable>
<template #title>
<a-space>
<span>{{ i18n "pages.users.listTitle" }}</span>
<a-tag color="blue">[[ filteredUsers.length ]]</a-tag>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="filteredUsers"
:row-key="record => record.id"
:pagination="{ pageSize: 10, showSizeChanger: true }"
:scroll="isMobile ? { x: 560 } : undefined">
<template slot="role" slot-scope="text, record">
<a-tag :color="record.role === 'admin' ? 'gold' : 'blue'">
[[ getRoleLabel(record.role) ]]
</a-tag>
</template>
<template slot="actions" slot-scope="text, record">
<a-space>
<a-button size="small" icon="edit" @click="openEditModal(record)">
{{ i18n "edit" }}
</a-button>
<a-popconfirm
:title="deleteConfirm(record)"
:ok-text='{{ printf "%q" (i18n "confirm") }}'
:cancel-text='{{ printf "%q" (i18n "cancel") }}'
@confirm="deleteUser(record)">
<a-button size="small" type="danger" icon="delete" :disabled="record.id === currentUserId">
{{ i18n "delete" }}
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</transition>
</a-spin>
</a-layout-content>
</a-layout>
<a-modal
v-model="userModal.visible"
:title="userModal.isEdit ? '{{ i18n "pages.users.editTitle" }}' : '{{ i18n "pages.users.createTitle" }}'"
:confirm-loading="userModal.submitting"
:ok-text="userModal.isEdit ? '{{ i18n "update" }}' : '{{ i18n "create" }}'"
:cancel-text='{{ printf "%q" (i18n "cancel") }}'
:class="themeSwitcher.currentTheme"
@ok="submitUser">
<a-form layout="vertical">
<a-form-item
:label='{{ printf "%q" (i18n "username") }}'
:validate-status="formErrors.username ? 'error' : ''"
:help="formErrors.username || ''">
<a-input
v-model.trim="form.username"
:placeholder='{{ printf "%q" (i18n "pages.users.usernamePlaceholder") }}'>
</a-input>
</a-form-item>
<a-form-item
:label='{{ printf "%q" (i18n "password") }}'
:validate-status="formErrors.password ? 'error' : ''"
:help="formErrors.password || passwordHelp">
<a-input
v-model="form.password"
type="password"
:placeholder="userModal.isEdit ? '{{ i18n "pages.users.passwordPlaceholder" }}' : '{{ i18n "pages.users.passwordRequiredPlaceholder" }}'">
</a-input>
</a-form-item>
<a-form-item :label='{{ printf "%q" (i18n "pages.users.role") }}'>
<a-select v-model="form.role" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="admin">{{ i18n "pages.users.roles.admin" }}</a-select-option>
<a-select-option value="user">{{ i18n "pages.users.roles.user" }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
</a-layout>
{{template "page/body_scripts" .}}
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
<script>
const app = new Vue({
delimiters: ['[[', ']]'],
mixins: [MediaQueryMixin],
el: '#app',
data: {
themeSwitcher,
loading: false,
searchText: '',
currentUserId: {{ .current_user_id }},
users: [],
columns: [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 90,
sorter: (a, b) => a.id - b.id,
},
{
title: '{{ i18n "username" }}',
dataIndex: 'username',
key: 'username',
sorter: (a, b) => a.username.localeCompare(b.username),
},
{
title: '{{ i18n "pages.users.role" }}',
key: 'role',
scopedSlots: { customRender: 'role' },
filters: [
{ text: '{{ i18n "pages.users.roles.admin" }}', value: 'admin' },
{ text: '{{ i18n "pages.users.roles.user" }}', value: 'user' },
],
onFilter: (value, record) => record.role === value,
},
{
title: '{{ i18n "pages.settings.actions" }}',
key: 'actions',
width: 180,
scopedSlots: { customRender: 'actions' },
},
],
userModal: {
visible: false,
isEdit: false,
editingId: null,
submitting: false,
},
form: {
username: '',
password: '',
role: 'user',
},
formErrors: {
username: '',
password: '',
},
},
computed: {
filteredUsers() {
const keyword = this.searchText.trim().toLowerCase();
if (!keyword) {
return this.users;
}
return this.users.filter((user) => {
return user.username.toLowerCase().includes(keyword) || this.getRoleLabel(user.role).toLowerCase().includes(keyword);
});
},
passwordHelp() {
return this.userModal.isEdit
? '{{ i18n "pages.users.passwordEditHelp" }}'
: '{{ i18n "pages.users.passwordCreateHelp" }}';
},
toolbarStyle() {
return {
float: this.isMobile ? 'none' : 'right',
display: this.isMobile ? 'flex' : 'inline-flex',
};
},
},
async mounted() {
await this.loadUsers();
},
methods: {
getRoleLabel(role) {
return role === 'admin'
? '{{ i18n "pages.users.roles.admin" }}'
: '{{ i18n "pages.users.roles.user" }}';
},
resetForm() {
this.form = {
username: '',
password: '',
role: 'user',
};
this.formErrors = {
username: '',
password: '',
};
},
openCreateModal() {
this.resetForm();
this.userModal.visible = true;
this.userModal.isEdit = false;
this.userModal.editingId = null;
},
openEditModal(record) {
this.resetForm();
this.userModal.visible = true;
this.userModal.isEdit = true;
this.userModal.editingId = record.id;
this.form.username = record.username;
this.form.role = record.role;
},
validateForm() {
const username = this.form.username.trim();
const password = this.form.password.trim();
this.formErrors.username = '';
this.formErrors.password = '';
if (!username) {
this.formErrors.username = '{{ i18n "pages.users.validation.usernameRequired" }}';
} else if (username.length < 3 || username.length > 64) {
this.formErrors.username = '{{ i18n "pages.users.validation.usernameLength" }}';
}
if (!this.userModal.isEdit && !password) {
this.formErrors.password = '{{ i18n "pages.users.validation.passwordRequired" }}';
} else if (password && (password.length < 8 || password.length > 128)) {
this.formErrors.password = '{{ i18n "pages.users.validation.passwordLength" }}';
}
return !this.formErrors.username && !this.formErrors.password;
},
async loadUsers() {
this.loading = true;
try {
const msg = await HttpUtil.get('/panel/api/users/list');
if (msg.success) {
this.users = Array.isArray(msg.obj) ? msg.obj : [];
}
} finally {
this.loading = false;
}
},
async submitUser() {
if (!this.validateForm()) {
return;
}
this.userModal.submitting = true;
try {
const payload = {
username: this.form.username.trim(),
password: this.form.password.trim(),
role: this.form.role,
};
const url = this.userModal.isEdit
? `/panel/api/users/update/${this.userModal.editingId}`
: '/panel/api/users/add';
const msg = await HttpUtil.post(url, payload);
if (msg.success) {
this.userModal.visible = false;
await this.loadUsers();
}
} finally {
this.userModal.submitting = false;
}
},
deleteConfirm(record) {
return `{{ i18n "pages.users.deleteConfirm" }}: ${record.username}`;
},
async deleteUser(record) {
const msg = await HttpUtil.post(`/panel/api/users/del/${record.id}`, {});
if (msg.success) {
await this.loadUsers();
}
},
},
});
</script>
{{ template "page/body_end" .}}

View file

@ -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)
})
}

View file

@ -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"

View file

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