mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
feat: add admin user management
This commit is contained in:
parent
b0ce1a7ace
commit
2a9d9a0a6b
10 changed files with 826 additions and 76 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
133
web/controller/user.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
297
web/html/users.html
Normal 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" .}}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" = "保存"
|
||||
|
|
|
|||
Loading…
Reference in a new issue