This commit is contained in:
parent
9be7a6f990
commit
fcbcf91d66
132
backend/controllers/account_group.go
Normal file
132
backend/controllers/account_group.go
Normal file
@ -0,0 +1,132 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
|
||||
func requireAccountScope(ctx *context.Context, w http.ResponseWriter, targetID int) bool {
|
||||
if ctx == nil || !accountgroup.CanAccess(db.Instance, ctx.UserID, ctx.EffectiveRole(), targetID) {
|
||||
writeSecurityError(w, response.NoAccessPrivileges, "无权查看或管理该账号")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func GetAccountGroups(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
if ctx == nil {
|
||||
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||
return
|
||||
}
|
||||
groups, err := accountgroup.VisibleGroups(db.Instance, ctx.UserID, ctx.EffectiveRole())
|
||||
if err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
response.NewSuccessResponse(map[string]any{"groups": groups, "can_manage": ctx.EffectiveRole() == access.RoleSuperAdmin}).FPrint(w)
|
||||
}
|
||||
|
||||
func requireAccountGroupManagement(ctx *context.Context, w http.ResponseWriter) bool {
|
||||
if err := authorizeCapability(ctx, access.CapabilityAccountGroupManage, "", false); err != nil {
|
||||
writeUserAuthorizationError(w, err, "无权管理账号分组")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func SaveAccountGroup(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
if !requireAccountGroupManagement(ctx, w) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if decodeUserRequest(w, req, &input) != nil || input.ID < 0 {
|
||||
writeAccountGroupError(w, accountgroup.ErrInvalid)
|
||||
return
|
||||
}
|
||||
name, err := accountgroup.NormalizeName(input.Name)
|
||||
if err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
if db.Instance == nil {
|
||||
writeSecurityError(w, response.ServerError, "数据库不可用")
|
||||
return
|
||||
}
|
||||
item := models.AccountGroup{ID: input.ID, Name: name}
|
||||
if item.ID == 0 {
|
||||
_, err = db.Instance.Insert(&item)
|
||||
} else {
|
||||
var found bool
|
||||
found, err = db.Instance.ID(item.ID).Exist(&models.AccountGroup{})
|
||||
if err == nil && !found {
|
||||
err = accountgroup.ErrInvalid
|
||||
}
|
||||
if err == nil {
|
||||
_, err = db.Instance.ID(item.ID).Cols("name").Update(&item)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(item).FPrint(w)
|
||||
}
|
||||
|
||||
func SetAccountGroupMembership(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
if !requireAccountGroupManagement(ctx, w) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
UserID int `json:"user_id"`
|
||||
GroupIDs []int `json:"group_ids"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
if decodeUserRequest(w, req, &input) != nil || input.UserID <= 0 || input.Version < 1 || input.GroupIDs == nil {
|
||||
writeAccountGroupError(w, accountgroup.ErrInvalid)
|
||||
return
|
||||
}
|
||||
if db.Instance == nil {
|
||||
writeSecurityError(w, response.ServerError, "数据库不可用")
|
||||
return
|
||||
}
|
||||
var user models.User
|
||||
found, err := db.Instance.ID(input.UserID).Get(&user)
|
||||
if err != nil || !found {
|
||||
writeAccountGroupError(w, accountgroup.ErrInvalid)
|
||||
return
|
||||
}
|
||||
if input.Version != user.SecurityVersion {
|
||||
writeAccountGroupError(w, accountgroup.ErrConflict)
|
||||
return
|
||||
}
|
||||
if err := accountgroup.SetMembership(db.Instance, user, input.GroupIDs); err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
InvalidateSecurityRuntimeUser(user.ID, user.Account)
|
||||
response.NewSuccessResponse(map[string]any{"user_id": user.ID}).FPrint(w)
|
||||
}
|
||||
|
||||
func writeAccountGroupError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, accountgroup.ErrDenied):
|
||||
writeSecurityError(w, response.NoAccessPrivileges, "无权查看或管理该分组")
|
||||
case errors.Is(err, accountgroup.ErrInvalid):
|
||||
writeSecurityError(w, response.ParamsError, "分组参数无效:名称为 1–32 个字符,普通用户只能属于一个分组")
|
||||
case errors.Is(err, accountgroup.ErrConflict):
|
||||
writeSecurityError(w, response.ParamsError, "账号状态已变化,请刷新列表后重试")
|
||||
default:
|
||||
writeSecurityError(w, response.ServerError, "分组操作失败,请检查是否重名并刷新后重试")
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/db"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
appcontext "git.pcux.cn/xuans/Smail/utils/context"
|
||||
)
|
||||
@ -29,7 +30,10 @@ func runtimeLogActors(requestContext context.Context, ctx *appcontext.Context) (
|
||||
return nil, logstore.ErrUnavailable
|
||||
}
|
||||
var users []models.User
|
||||
if err := db.Instance.Context(requestContext).Cols("id").Where(models.OrdinaryUserPredicate).Find(&users); err != nil {
|
||||
query := db.Instance.Context(requestContext).Cols("id").Where(models.OrdinaryUserPredicate)
|
||||
defer query.Close()
|
||||
accountgroup.ApplyScope(query, ctx.UserID, ctx.EffectiveRole())
|
||||
if err := query.Find(&users); err != nil {
|
||||
return nil, logstore.ErrUnavailable
|
||||
}
|
||||
for _, user := range users {
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
"git.pcux.cn/xuans/Smail/services/notification"
|
||||
"git.pcux.cn/xuans/Smail/utils/context"
|
||||
@ -19,14 +20,17 @@ import (
|
||||
)
|
||||
|
||||
type notificationRequest struct {
|
||||
UserID int `json:"user_id"`
|
||||
Version int `json:"version"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
MinuteLimit int `json:"minute_limit"`
|
||||
DayLimit int `json:"day_limit"`
|
||||
UserID int `json:"user_id"`
|
||||
Version int `json:"version"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
MinuteLimit int `json:"minute_limit"`
|
||||
DayLimit int `json:"day_limit"`
|
||||
}
|
||||
|
||||
func loadNotificationUser(ctx *context.Context, w http.ResponseWriter, userID int, write bool) (*models.User, bool) {
|
||||
if !requireAccountScope(ctx, w, userID) {
|
||||
return nil, false
|
||||
}
|
||||
if ctx == nil || ctx.UserID <= 0 {
|
||||
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||
return nil, false
|
||||
@ -76,7 +80,9 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt
|
||||
}
|
||||
}
|
||||
user, ok := loadNotificationUser(ctx, w, userID, false)
|
||||
if !ok { return }
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := notification.Load(db.Instance, user.ID)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ServerError, "读取系统通知授权失败")
|
||||
@ -84,20 +90,24 @@ func GetNotificationStatus(ctx *context.Context, w http.ResponseWriter, req *htt
|
||||
}
|
||||
minuteUsed, dayUsed := 0, 0
|
||||
now := time.Now().UTC()
|
||||
if item.MinuteKey == now.Format("2006-01-02T15:04") { minuteUsed = item.MinuteCount }
|
||||
if item.DayKey == now.Format("2006-01-02") { dayUsed = item.DayCount }
|
||||
if item.MinuteKey == now.Format("2006-01-02T15:04") {
|
||||
minuteUsed = item.MinuteCount
|
||||
}
|
||||
if item.DayKey == now.Format("2006-01-02") {
|
||||
dayUsed = item.DayCount
|
||||
}
|
||||
domain := config.CurrentMailDomains().Primary
|
||||
ports := config.CurrentMailPorts()
|
||||
response.NewSuccessResponse(map[string]any{
|
||||
"user_id": user.ID, "account": user.Account, "enabled": item.Enabled == 1,
|
||||
"configured": notification.Configured(item, *user), "version": item.CodeVersion,
|
||||
"account_available": user.Disabled == 0 && user.MustChangePassword == 0,
|
||||
"can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID),
|
||||
"minute_limit": item.MinuteLimit, "day_limit": item.DayLimit,
|
||||
"can_manage": access.CanManageNotification(ctx.EffectiveRole(), user.EffectiveRole(), ctx.UserID == user.ID),
|
||||
"minute_limit": item.MinuteLimit, "day_limit": item.DayLimit,
|
||||
"minute_used": minuteUsed, "day_used": dayUsed,
|
||||
"host": "smtp." + domain, "tls_port": ports.SMTPTLS, "starttls_port": ports.SMTPSTARTTLS,
|
||||
"username": notification.UsernamePrefix + user.Account + "@" + domain,
|
||||
"sender": validation.NotificationAddress(domain), "sender_name": "系统通知",
|
||||
"sender": validation.NotificationAddress(domain), "sender_name": "系统通知",
|
||||
"max_recipients": notification.MaxRecipients, "max_message_bytes": notification.MaxMessageBytes,
|
||||
}).FPrint(w)
|
||||
}
|
||||
@ -113,21 +123,26 @@ func ListNotificationAccounts(ctx *context.Context, w http.ResponseWriter, req *
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
|
||||
if page < 1 || page > 100000 { page = 1 }
|
||||
if page < 1 || page > 100000 {
|
||||
page = 1
|
||||
}
|
||||
query := db.Instance.Table(&models.User{})
|
||||
defer query.Close()
|
||||
accountgroup.ApplyScope(query, ctx.UserID, ctx.EffectiveRole())
|
||||
if ctx.EffectiveRole() == access.RoleAdmin {
|
||||
query.Where("(" + models.OrdinaryUserPredicate + ") OR id = ?", ctx.UserID)
|
||||
query.And("("+models.OrdinaryUserPredicate+") OR id = ?", ctx.UserID)
|
||||
}
|
||||
var users []models.User
|
||||
total, err := query.Asc("id").Limit(20, (page - 1) * 20).FindAndCount(&users)
|
||||
total, err := query.Asc("id").Limit(20, (page-1)*20).FindAndCount(&users)
|
||||
if err != nil {
|
||||
writeSecurityError(w, response.ServerError, "读取账号列表失败")
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(users))
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users { userIDs = append(userIDs, user.ID) }
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
grants := make(map[int]models.NotificationGrant)
|
||||
if len(userIDs) > 0 {
|
||||
var rows []models.NotificationGrant
|
||||
@ -135,7 +150,9 @@ func ListNotificationAccounts(ctx *context.Context, w http.ResponseWriter, req *
|
||||
writeSecurityError(w, response.ServerError, "读取账号通知状态失败")
|
||||
return
|
||||
}
|
||||
for _, item := range rows { grants[item.UserID] = item }
|
||||
for _, item := range rows {
|
||||
grants[item.UserID] = item
|
||||
}
|
||||
}
|
||||
for _, user := range users {
|
||||
item := grants[user.ID]
|
||||
@ -152,10 +169,15 @@ func SetNotificationAuthorization(ctx *context.Context, w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
user, ok := loadNotificationUser(ctx, w, input.UserID, true)
|
||||
if !ok { return }
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
err := notification.SetAuthorization(db.Instance, *user, input.Version, *input.Enabled, input.MinuteLimit, input.DayLimit)
|
||||
recordNotificationManagement(ctx, user.ID, "设置系统通知授权", err, map[string]any{"enabled": *input.Enabled, "minute_limit": input.MinuteLimit, "day_limit": input.DayLimit})
|
||||
if err != nil { writeNotificationError(w, err); return }
|
||||
if err != nil {
|
||||
writeNotificationError(w, err)
|
||||
return
|
||||
}
|
||||
response.NewSuccessResponse(map[string]any{"user_id": user.ID, "enabled": *input.Enabled}).FPrint(w)
|
||||
}
|
||||
|
||||
@ -166,17 +188,24 @@ func RotateNotificationCredential(ctx *context.Context, w http.ResponseWriter, r
|
||||
return
|
||||
}
|
||||
user, ok := loadNotificationUser(ctx, w, input.UserID, true)
|
||||
if !ok { return }
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
code, err := notification.Rotate(db.Instance, *user, input.Version)
|
||||
recordNotificationManagement(ctx, user.ID, "重置系统通知凭据", err, nil)
|
||||
if err != nil { writeNotificationError(w, err); return }
|
||||
if err != nil {
|
||||
writeNotificationError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
response.NewSuccessResponse(map[string]any{"code": code, "user_id": user.ID}).FPrint(w)
|
||||
}
|
||||
|
||||
func recordNotificationManagement(ctx *context.Context, userID int, action string, err error, changes map[string]any) {
|
||||
result := "success"
|
||||
if err != nil { result = "failure" }
|
||||
if err != nil {
|
||||
result = "failure"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"target_user_id": userID, "changes": changes})
|
||||
logstore.Record(ctx, logstore.Entry{Kind: "operation", ActorID: strconv.Itoa(ctx.UserID), ActorRole: string(ctx.EffectiveRole()), Action: action,
|
||||
Section: "系统通知发信", Result: result, CreatedAt: time.Now(), Payload: payload})
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/dto/response"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
"git.pcux.cn/xuans/Smail/session"
|
||||
"git.pcux.cn/xuans/Smail/utils/array"
|
||||
@ -107,11 +108,14 @@ func CreateUser(ctx *context.Context, w http.ResponseWriter, req *http.Request)
|
||||
}
|
||||
|
||||
type userListRequest struct {
|
||||
CurrentPage int `json:"current_page"`
|
||||
PageSize int `json:"page_size"`
|
||||
GroupID *int `json:"group_id"`
|
||||
CurrentPage int `json:"current_page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type userListItem struct {
|
||||
GroupIDs []int `json:"GroupIDs,omitempty"`
|
||||
MembershipVersion int `json:"MembershipVersion,omitempty"`
|
||||
ID int `json:"ID"`
|
||||
Account string `json:"Account"`
|
||||
Name string `json:"Name"`
|
||||
@ -176,8 +180,24 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
var users []models.User
|
||||
query := db.Instance.Table(&models.User{})
|
||||
defer query.Close()
|
||||
if ctx.EffectiveRole() == access.RoleAdmin {
|
||||
query.Where(models.OrdinaryUserPredicate)
|
||||
accountgroup.ApplyScope(query, ctx.UserID, ctx.EffectiveRole())
|
||||
if reqData.GroupID != nil {
|
||||
groups, err := accountgroup.VisibleGroups(db.Instance, ctx.UserID, ctx.EffectiveRole())
|
||||
if err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
allowed := false
|
||||
for _, group := range groups {
|
||||
if group.ID == *reqData.GroupID {
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
writeAccountGroupError(w, accountgroup.ErrDenied)
|
||||
return
|
||||
}
|
||||
accountgroup.ApplyGroup(query, *reqData.GroupID)
|
||||
}
|
||||
totalNum, err := query.Asc("id").Limit(reqData.PageSize, offset).FindAndCount(&users)
|
||||
if err != nil {
|
||||
@ -192,8 +212,21 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
items := make([]userListItem, 0, len(users))
|
||||
memberships := map[int][]int{}
|
||||
if ctx.EffectiveRole() == access.RoleSuperAdmin {
|
||||
ids := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
memberships, err = accountgroup.Memberships(db.Instance, ids)
|
||||
if err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, user := range users {
|
||||
items = append(items, userListItem{
|
||||
GroupIDs: memberships[user.ID],
|
||||
ID: user.ID,
|
||||
Account: user.Account,
|
||||
Name: user.Name,
|
||||
@ -205,6 +238,9 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
ExternalEmailEnabled: user.ExternalEmailEnabled == 1,
|
||||
ExternalEmailConfigured: externalEmailConfigured[user.ID],
|
||||
})
|
||||
if ctx.EffectiveRole() == access.RoleSuperAdmin {
|
||||
items[len(items)-1].MembershipVersion = user.SecurityVersion
|
||||
}
|
||||
}
|
||||
response.NewSuccessResponse(map[string]any{
|
||||
"current_page": reqData.CurrentPage,
|
||||
@ -284,6 +320,9 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
targetRole := user.EffectiveRole()
|
||||
if !requireAccountScope(ctx, w, user.ID) {
|
||||
return
|
||||
}
|
||||
if err := authorizeCapability(ctx, access.CapabilityUserManage, targetRole, false); err != nil {
|
||||
response.NewErrorResponse(response.NoAccessPrivileges, "无权管理该用户", "").FPrint(w)
|
||||
return
|
||||
@ -388,7 +427,9 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
if reqData.Password != "" || user.Disabled != originalDisabled || user.EffectiveRole() != originalRole {
|
||||
updateColumns = append(updateColumns, "session_version", "security_version")
|
||||
}
|
||||
if num, err := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", originalSessionVersion, originalSecurityVersion).Cols(updateColumns...).Update(&user); err != nil {
|
||||
update := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", originalSessionVersion, originalSecurityVersion).Cols(updateColumns...)
|
||||
accountgroup.ApplyScope(update, ctx.UserID, ctx.EffectiveRole())
|
||||
if num, err := update.Update(&user); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("update user failed")
|
||||
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
|
||||
return
|
||||
@ -403,6 +444,17 @@ func EditUser(ctx *context.Context, w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if user.EffectiveRole() != access.RoleAdmin {
|
||||
count, err := tx.Where("user_id = ?", user.ID).Count(&models.AccountGroupMember{})
|
||||
if err != nil {
|
||||
writeAccountGroupError(w, err)
|
||||
return
|
||||
}
|
||||
if count > 1 {
|
||||
writeSecurityError(w, response.ParamsError, "请先将账号调整为一个分组,再更改为普通用户")
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("commit user update failed")
|
||||
response.NewErrorResponse(response.ServerError, "更新用户失败", "").FPrint(w)
|
||||
|
||||
@ -27,6 +27,9 @@ type managedUserSecurityRequest struct {
|
||||
}
|
||||
|
||||
func loadManagedSecurityUser(ctx *context.Context, w http.ResponseWriter, userID int) (*models.User, bool) {
|
||||
if !requireAccountScope(ctx, w, userID) {
|
||||
return nil, false
|
||||
}
|
||||
if ctx == nil || ctx.UserID <= 0 {
|
||||
writeSecurityError(w, response.NeedLogin, "请先登录")
|
||||
return nil, false
|
||||
|
||||
25
backend/db/migration/account_group.go
Normal file
25
backend/db/migration/account_group.go
Normal file
@ -0,0 +1,25 @@
|
||||
package migration
|
||||
|
||||
func accountGroupStatements() map[Dialect][]string {
|
||||
members := `CREATE TABLE IF NOT EXISTS account_group_member (
|
||||
user_id BIGINT NOT NULL,
|
||||
group_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
)`
|
||||
return map[Dialect][]string{
|
||||
DialectSQLite: {
|
||||
`CREATE TABLE IF NOT EXISTS account_group (id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(64) NOT NULL UNIQUE)`,
|
||||
members,
|
||||
`CREATE INDEX IF NOT EXISTS "idx_account_group_members" ON account_group_member (group_id, user_id)`,
|
||||
},
|
||||
DialectMySQL: {
|
||||
`CREATE TABLE IF NOT EXISTS account_group (id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL UNIQUE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
`CREATE TABLE IF NOT EXISTS account_group_member (user_id BIGINT NOT NULL, group_id BIGINT NOT NULL, PRIMARY KEY (user_id, group_id), KEY idx_account_group_members (group_id, user_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
},
|
||||
DialectPostgres: {
|
||||
`CREATE TABLE IF NOT EXISTS account_group (id BIGSERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL UNIQUE)`,
|
||||
members,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_group_members ON account_group_member (group_id, user_id)`,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -17,8 +17,8 @@ func notificationGrantStatements() map[Dialect][]string {
|
||||
day_count INTEGER NOT NULL DEFAULT 0
|
||||
)`
|
||||
return map[Dialect][]string{
|
||||
DialectSQLite: {statement},
|
||||
DialectMySQL: {statement + " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"},
|
||||
DialectSQLite: {statement},
|
||||
DialectMySQL: {statement + " ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"},
|
||||
DialectPostgres: {statement},
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,7 @@ func DefaultMigrations() []Migration {
|
||||
DialectPostgres: {configstate.Schema("postgres")},
|
||||
}),
|
||||
DefineForDialects(25, "create_notification_grants", notificationGrantStatements()),
|
||||
DefineForDialects(26, "create_account_groups", accountGroupStatements()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -85,6 +85,9 @@ func registerV1Routes(mux *http.ServeMux) {
|
||||
registerV1MethodRoute(mux, "/api/v1/user/profile", "/api/user/profile", http.MethodPost, 64<<10, controllers.UpdateProfile)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/info", "/api/user/info", http.MethodPost, 64<<10, controllers.Info)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/list", "/api/user/list", http.MethodPost, 64<<10, controllers.UserList)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/groups", "/api/user/groups", http.MethodGet, 0, controllers.GetAccountGroups)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/groups/save", "/api/user/groups/save", http.MethodPost, 4<<10, controllers.SaveAccountGroup)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/groups/membership", "/api/user/groups/membership", http.MethodPost, 4<<10, controllers.SetAccountGroupMembership)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes", "/api/user/security/recovery-codes", http.MethodGet, 0, controllers.GetManagedRecoveryCodeStatus)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes/generate", "/api/user/security/recovery-codes/generate", http.MethodPost, 16<<10, controllers.GenerateManagedRecoveryCodes)
|
||||
registerV1MethodRoute(mux, "/api/v1/user/security/recovery-codes/revoke", "/api/user/security/recovery-codes/revoke", http.MethodPost, 4<<10, controllers.RevokeManagedRecoveryCodes)
|
||||
|
||||
@ -15,8 +15,8 @@ import (
|
||||
|
||||
// loggedOperations 注册显式的静态状态变更路由,仅记录状态变更操作,不将列表查询误认为操作,也不复制请求或响应载荷。
|
||||
var loggedOperations = map[string]string{
|
||||
"/api/system/notification/authorization": "修改系统通知发信权限",
|
||||
"/api/system/notification/credential": "生成系统通知发信凭据",
|
||||
"/api/system/notification/authorization": "修改系统通知发信权限",
|
||||
"/api/system/notification/credential": "生成系统通知发信凭据",
|
||||
"/api/email/draft/save": "保存邮件草稿",
|
||||
"/api/login": "处理账号登录",
|
||||
"/api/logout": "退出登录",
|
||||
@ -55,6 +55,8 @@ var loggedOperations = map[string]string{
|
||||
"/api/security/external-email/disable": "解绑外部邮箱",
|
||||
"/api/settings/modify_password": "修改账号密码",
|
||||
"/api/user/create": "创建用户",
|
||||
"/api/user/groups/save": "保存账号分组",
|
||||
"/api/user/groups/membership": "调整账号所属分组",
|
||||
"/api/user/edit": "编辑用户",
|
||||
"/api/user/profile": "修改个人资料",
|
||||
"/api/user/security/recovery-codes/generate": "管理用户安全码",
|
||||
|
||||
@ -2,7 +2,6 @@ package smtp_server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/auth"
|
||||
@ -15,6 +14,7 @@ import (
|
||||
"github.com/emersion/go-smtp"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The Backend implements SMTP server methods.
|
||||
|
||||
16
backend/models/account_group.go
Normal file
16
backend/models/account_group.go
Normal file
@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
// AccountGroup 是账号管理分组,与邮箱文件夹分开;编号零为隐式默认分组。
|
||||
type AccountGroup struct {
|
||||
ID int `xorm:"id pk autoincr" json:"id"`
|
||||
Name string `xorm:"varchar(64) notnull unique" json:"name"`
|
||||
}
|
||||
|
||||
func (AccountGroup) TableName() string { return "account_group" }
|
||||
|
||||
type AccountGroupMember struct {
|
||||
UserID int `xorm:"user_id pk" json:"user_id"`
|
||||
GroupID int `xorm:"group_id pk" json:"group_id"`
|
||||
}
|
||||
|
||||
func (AccountGroupMember) TableName() string { return "account_group_member" }
|
||||
@ -26,6 +26,7 @@ const (
|
||||
CapabilityAuditRead Capability = "audit.read"
|
||||
CapabilityAuditExport Capability = "audit.export"
|
||||
CapabilityNotificationManage Capability = "notification.manage"
|
||||
CapabilityAccountGroupManage Capability = "account_group.manage"
|
||||
)
|
||||
|
||||
type Policy struct {
|
||||
@ -37,6 +38,7 @@ type Policy struct {
|
||||
}
|
||||
|
||||
var policies = map[Capability]Policy{
|
||||
CapabilityAccountGroupManage: {Capability: CapabilityAccountGroupManage, HighRisk: true, RequiresRecentAuth: true},
|
||||
CapabilityNotificationManage: {Capability: CapabilityNotificationManage, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true, RequiresTarget: true},
|
||||
CapabilityConfigRead: {Capability: CapabilityConfigRead},
|
||||
CapabilityConfigWriteGlobal: {Capability: CapabilityConfigWriteGlobal, HighRisk: true, RequiresRecentAuth: true, RequiresRecoveryCode: true},
|
||||
@ -64,6 +66,7 @@ var policies = map[Capability]Policy{
|
||||
|
||||
var grants = map[Role]map[Capability]struct{}{
|
||||
RoleSuperAdmin: grant(
|
||||
CapabilityAccountGroupManage,
|
||||
CapabilityNotificationManage,
|
||||
CapabilityPluginReadGlobal, CapabilityUserCreate, CapabilityProfileWriteOwn,
|
||||
CapabilityConfigRead, CapabilityConfigWriteGlobal, CapabilityConfigRollback,
|
||||
|
||||
@ -9,6 +9,12 @@ type Endpoint struct {
|
||||
}
|
||||
|
||||
var managementEndpoints = []Endpoint{
|
||||
{Method: "POST", Path: "/api/system/notification/authorization", Capability: CapabilityNotificationManage},
|
||||
{Method: "POST", Path: "/api/system/notification/credential", Capability: CapabilityNotificationManage},
|
||||
{Method: "POST", Path: "/api/v1/user/groups/save", Capability: CapabilityAccountGroupManage},
|
||||
{Method: "POST", Path: "/api/v1/user/groups/membership", Capability: CapabilityAccountGroupManage},
|
||||
{Method: "POST", Path: "/api/user/groups/save", Capability: CapabilityAccountGroupManage},
|
||||
{Method: "POST", Path: "/api/user/groups/membership", Capability: CapabilityAccountGroupManage},
|
||||
{Method: "POST", Path: "/api/v1/system/notification/authorization", Capability: CapabilityNotificationManage},
|
||||
{Method: "POST", Path: "/api/v1/system/notification/credential", Capability: CapabilityNotificationManage},
|
||||
{Method: "POST", Path: "/api/settings/modify_password", Capability: CapabilityPasswordChangeOwn},
|
||||
|
||||
216
backend/services/accountgroup/service.go
Normal file
216
backend/services/accountgroup/service.go
Normal file
@ -0,0 +1,216 @@
|
||||
package accountgroup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDenied = errors.New("account group scope denied")
|
||||
ErrInvalid = errors.New("invalid account group")
|
||||
ErrConflict = errors.New("account group membership changed")
|
||||
)
|
||||
|
||||
// ScopePredicate 的外层必须是用户表。使用实时子查询,不缓存管理员的分组范围。
|
||||
// 未建立成员关系的旧账号以及新账号都属于默认分组,无需迁移或复制账号数据。
|
||||
func ScopePredicate(actorID int) (string, []any) {
|
||||
return `(id IN (
|
||||
SELECT gm.user_id FROM account_group_member gm
|
||||
JOIN account_group_member am ON gm.group_id = am.group_id
|
||||
WHERE am.user_id = ?
|
||||
) OR ((id NOT IN (SELECT user_id FROM account_group_member)
|
||||
OR id IN (SELECT user_id FROM account_group_member WHERE group_id = 0))
|
||||
AND (NOT EXISTS (SELECT 1 FROM account_group_member WHERE user_id = ?)
|
||||
OR EXISTS (SELECT 1 FROM account_group_member WHERE user_id = ? AND group_id = 0))))`,
|
||||
[]any{actorID, actorID, actorID}
|
||||
}
|
||||
|
||||
func ApplyScope(query *xorm.Session, actorID int, role access.Role) {
|
||||
if actorID <= 0 {
|
||||
query.And("1 = 0")
|
||||
return
|
||||
}
|
||||
switch role {
|
||||
case access.RoleSuperAdmin:
|
||||
return
|
||||
case access.RoleAdmin:
|
||||
predicate, args := ScopePredicate(actorID)
|
||||
query.And(predicate, args...)
|
||||
default:
|
||||
query.And("id = ?", actorID)
|
||||
}
|
||||
}
|
||||
|
||||
func ApplyGroup(query *xorm.Session, groupID int) {
|
||||
if groupID == 0 {
|
||||
query.And("(id NOT IN (SELECT user_id FROM account_group_member) OR id IN (SELECT user_id FROM account_group_member WHERE group_id = 0))")
|
||||
} else {
|
||||
query.And("id IN (SELECT user_id FROM account_group_member WHERE group_id = ?)", groupID)
|
||||
}
|
||||
}
|
||||
|
||||
func CanAccess(engine *xorm.Engine, actorID int, role access.Role, targetID int) bool {
|
||||
if engine == nil || actorID <= 0 || targetID <= 0 {
|
||||
return false
|
||||
}
|
||||
query := engine.Table(&models.User{}).Where("id = ?", targetID)
|
||||
defer query.Close()
|
||||
ApplyScope(query, actorID, role)
|
||||
found, err := query.Exist(&models.User{})
|
||||
return err == nil && found
|
||||
}
|
||||
|
||||
func Memberships(engine *xorm.Engine, userIDs []int) (map[int][]int, error) {
|
||||
result := make(map[int][]int, len(userIDs))
|
||||
if engine == nil {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
var rows []models.AccountGroupMember
|
||||
if err := engine.In("user_id", userIDs).Asc("group_id").Find(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
result[row.UserID] = append(result[row.UserID], row.GroupID)
|
||||
}
|
||||
for _, id := range userIDs {
|
||||
if len(result[id]) == 0 {
|
||||
result[id] = []int{0}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func VisibleGroups(engine *xorm.Engine, actorID int, role access.Role) ([]models.AccountGroup, error) {
|
||||
if engine == nil || actorID <= 0 || (role != access.RoleAdmin && role != access.RoleSuperAdmin) {
|
||||
return nil, ErrDenied
|
||||
}
|
||||
query := engine.Table(&models.AccountGroup{})
|
||||
defer query.Close()
|
||||
showDefault := role == access.RoleSuperAdmin
|
||||
if role == access.RoleAdmin {
|
||||
members, err := Memberships(engine, []int{actorID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := members[actorID]
|
||||
query.In("id", ids)
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
showDefault = true
|
||||
}
|
||||
}
|
||||
}
|
||||
groups := make([]models.AccountGroup, 0)
|
||||
if err := query.Asc("id").Find(&groups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if showDefault {
|
||||
groups = append([]models.AccountGroup{{ID: 0, Name: "默认分组"}}, groups...)
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func NormalizeName(value string) (string, error) {
|
||||
name := strings.TrimSpace(value)
|
||||
if name == "" || name == "默认分组" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 32 {
|
||||
return "", ErrInvalid
|
||||
}
|
||||
for _, r := range name {
|
||||
if unicode.IsControl(r) {
|
||||
return "", ErrInvalid
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// ValidateMembership 必须在持有目标用户写锁的事务内调用,避免降级和分组变更并发突破单组约束。
|
||||
func ValidateMembership(tx *xorm.Session, role access.Role, input []int) ([]int, error) {
|
||||
if len(input) > 100 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
ids := make([]int, 0, len(input))
|
||||
seen := map[int]bool{}
|
||||
for _, id := range input {
|
||||
if id < 0 || seen[id] {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
ids = []int{0}
|
||||
}
|
||||
if role != access.RoleAdmin && len(ids) != 1 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
found, err := tx.ID(id).Exist(&models.AccountGroup{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func ReplaceMembership(tx *xorm.Session, userID int, ids []int) error {
|
||||
if _, err := tx.Where("user_id = ?", userID).Delete(&models.AccountGroupMember{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 1 && ids[0] == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := make([]models.AccountGroupMember, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
rows = append(rows, models.AccountGroupMember{UserID: userID, GroupID: id})
|
||||
}
|
||||
_, err := tx.Insert(&rows)
|
||||
return err
|
||||
}
|
||||
|
||||
func SetMembership(engine *xorm.Engine, user models.User, input []int) error {
|
||||
if engine == nil || user.ID <= 0 {
|
||||
return ErrDenied
|
||||
}
|
||||
tx := engine.NewSession()
|
||||
defer tx.Close()
|
||||
if err := tx.Begin(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
updated := user
|
||||
updated.SessionVersion++
|
||||
updated.SecurityVersion++
|
||||
n, err := tx.ID(user.ID).Where("session_version = ? AND security_version = ?", user.SessionVersion, user.SecurityVersion).
|
||||
Cols("session_version", "security_version").Update(&updated)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
ids, err := ValidateMembership(tx, user.EffectiveRole(), input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ReplaceMembership(tx, user.ID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
@ -12,10 +12,10 @@ import (
|
||||
|
||||
// ProtocolSession 保存不可变身份与凭据快照,拒绝后的连接不能通过重新启用恢复权限。
|
||||
type ProtocolSession struct {
|
||||
user models.User
|
||||
grant *credentials.ProtocolGrant
|
||||
user models.User
|
||||
grant *credentials.ProtocolGrant
|
||||
notification *notification.Grant
|
||||
invalid atomic.Bool
|
||||
invalid atomic.Bool
|
||||
}
|
||||
|
||||
func AuthenticateProtocolSession(protocol, remoteAddress, account, plain string) (*models.User, *ProtocolSession, error) {
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/db/migration"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||
"git.pcux.cn/xuans/Smail/utils/logpolicy"
|
||||
)
|
||||
|
||||
@ -197,7 +198,13 @@ func (s *Store) Query(ctx context.Context, query Query) ([]Entry, error) {
|
||||
if s.dialect == migration.DialectMySQL {
|
||||
userTable, idType = "`user`", "CHAR"
|
||||
}
|
||||
statement += "actor_id IN (SELECT CAST(id AS " + idType + ") FROM " + userTable + " WHERE " + models.OrdinaryUserPredicate + ")"
|
||||
actorID, parseErr := strconv.Atoi(query.ScopeActorID)
|
||||
if parseErr != nil || actorID <= 0 {
|
||||
return nil, ErrInvalid
|
||||
}
|
||||
groupScope, groupArgs := accountgroup.ScopePredicate(actorID)
|
||||
statement += "actor_id IN (SELECT CAST(id AS " + idType + ") FROM " + userTable + " WHERE " + models.OrdinaryUserPredicate + " AND " + groupScope + ")"
|
||||
args = append(args, groupArgs...)
|
||||
// 代管视图还要核对操作发生时的角色,不能因账号降级暴露旧管理操作。
|
||||
// 缺少历史角色的操作仅本人和超级管理员可见,收发信元数据不包含管理操作。
|
||||
if isOperationKind(query.Kind) {
|
||||
|
||||
@ -15,19 +15,19 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
UsernamePrefix = "notify:"
|
||||
UsernamePrefix = "notify:"
|
||||
DefaultMinuteLimit = 10
|
||||
DefaultDayLimit = 100
|
||||
MaxRecipients = 10
|
||||
MaxMessageBytes = 1 << 20
|
||||
DefaultDayLimit = 100
|
||||
MaxRecipients = 10
|
||||
MaxMessageBytes = 1 << 20
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDenied = errors.New("notification authorization denied")
|
||||
ErrConflict = errors.New("notification authorization changed")
|
||||
ErrQuota = errors.New("notification quota exceeded")
|
||||
ErrDenied = errors.New("notification authorization denied")
|
||||
ErrConflict = errors.New("notification authorization changed")
|
||||
ErrQuota = errors.New("notification quota exceeded")
|
||||
ErrUnavailable = errors.New("notification authorization unavailable")
|
||||
ErrLimits = errors.New("invalid notification limits")
|
||||
ErrLimits = errors.New("invalid notification limits")
|
||||
)
|
||||
|
||||
func IsUsername(username string) bool {
|
||||
@ -118,11 +118,11 @@ func Rotate(engine *xorm.Engine, user models.User, expected int) (string, error)
|
||||
}
|
||||
|
||||
type Grant struct {
|
||||
engine *xorm.Engine
|
||||
userID int
|
||||
version int
|
||||
hash string
|
||||
sessionVersion int
|
||||
engine *xorm.Engine
|
||||
userID int
|
||||
version int
|
||||
hash string
|
||||
sessionVersion int
|
||||
securityVersion int
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/models"
|
||||
"git.pcux.cn/xuans/Smail/services/access"
|
||||
"git.pcux.cn/xuans/Smail/services/accountgroup"
|
||||
"git.pcux.cn/xuans/Smail/utils/credential"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
@ -377,6 +378,9 @@ func (s *ConfigService) UpdateOwnTelegramSettings(actorUserID int, settings Tele
|
||||
|
||||
// UpdateTelegramSettingsForUser 查询目标账户的当前角色后才进行跨用户授权。
|
||||
func (s *ConfigService) UpdateTelegramSettingsForUser(actor models.User, targetUserID int, settings TelegramUserSettings, expectedVersion uint64) (UserSettingsStatus, error) {
|
||||
if s == nil || !accountgroup.CanAccess(s.engine, actor.ID, actor.EffectiveRole(), targetUserID) {
|
||||
return UserSettingsStatus{}, &access.Denial{Code: access.CodeAccessDenied}
|
||||
}
|
||||
if actor.ID <= 0 || targetUserID <= 0 {
|
||||
return UserSettingsStatus{}, ErrPluginStoreUnavailable
|
||||
}
|
||||
|
||||
143
frontend/src/views/components/UserManagement/AccountGroups.js
Normal file
143
frontend/src/views/components/UserManagement/AccountGroups.js
Normal file
@ -0,0 +1,143 @@
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { http } from '@/utils/axios'
|
||||
import { isRecentAuthenticationCancelled, requestWithRecentAuthentication } from '@/utils/recentAuthentication'
|
||||
|
||||
export function useAccountGroups(globalStatus) {
|
||||
const accountGroups = ref([])
|
||||
const groupsLoading = ref(false)
|
||||
const groupsError = ref('')
|
||||
const canManageGroups = computed(() => globalStatus.hasCapability('account_group.manage'))
|
||||
const groupSaving = ref(false)
|
||||
const membershipVisible = ref(false)
|
||||
const membershipUser = ref(null)
|
||||
const membershipSelection = ref([])
|
||||
const membershipLimit = computed(() => membershipUser.value?.Role === 'admin' ? 100 : 1)
|
||||
let generation = 0
|
||||
let alive = true
|
||||
onBeforeUnmount(() => { alive = false; generation++ })
|
||||
|
||||
const loadGroup = async group => {
|
||||
const requestID = ++group.requestID
|
||||
group.loading = true
|
||||
group.error = ''
|
||||
// 切页先清空旧成员,失败时不能继续显示上一页数据。
|
||||
group.rows = []
|
||||
try {
|
||||
const response = await http.post('/api/v1/user/list', {
|
||||
current_page: group.page,
|
||||
page_size: 6,
|
||||
group_id: group.id
|
||||
})
|
||||
if (!alive || requestID !== group.requestID || !accountGroups.value.includes(group)) return
|
||||
if (response?.errorNo !== 0) throw new Error(response?.errorMsg || '分组成员加载失败')
|
||||
group.totalPages = Math.max(1, Number(response.data.total_page) || 1)
|
||||
if (group.page > group.totalPages) {
|
||||
group.page = group.totalPages
|
||||
return await loadGroup(group)
|
||||
}
|
||||
group.rows = response.data.list || []
|
||||
} catch (error) {
|
||||
if (alive && requestID === group.requestID) group.error = error?.message || '分组成员加载失败'
|
||||
} finally {
|
||||
if (requestID === group.requestID) group.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshGroups = async () => {
|
||||
const requestID = ++generation
|
||||
groupsLoading.value = true
|
||||
groupsError.value = ''
|
||||
if (!['admin', 'super_admin'].includes(globalStatus.role)) {
|
||||
accountGroups.value = []
|
||||
groupsLoading.value = false
|
||||
return
|
||||
}
|
||||
const previous = new Map(accountGroups.value.map(group => [group.id, { page: group.page, expanded: group.expanded }]))
|
||||
accountGroups.value = []
|
||||
try {
|
||||
const response = await http.get('/api/v1/user/groups')
|
||||
if (!alive || requestID !== generation) return
|
||||
if (response?.errorNo !== 0) throw new Error(response?.errorMsg || '账号分组加载失败')
|
||||
accountGroups.value = (response.data.groups || []).map(group => ({
|
||||
...group,
|
||||
expanded: group.id === 0 || previous.get(group.id)?.expanded || false,
|
||||
page: previous.get(group.id)?.page || 1,
|
||||
totalPages: 1,
|
||||
rows: [],
|
||||
error: '',
|
||||
loading: false,
|
||||
requestID: 0
|
||||
}))
|
||||
await Promise.all(accountGroups.value.filter(group => group.expanded).map(loadGroup))
|
||||
} catch (error) {
|
||||
if (alive && requestID === generation) groupsError.value = error?.message || '账号分组加载失败'
|
||||
} finally {
|
||||
if (requestID === generation) groupsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGroup = group => {
|
||||
if (group.id === 0) return
|
||||
group.expanded = !group.expanded
|
||||
if (group.expanded) loadGroup(group)
|
||||
}
|
||||
|
||||
const saveGroup = async (group = null) => {
|
||||
if (!canManageGroups.value || groupSaving.value || group?.id === 0) return
|
||||
groupSaving.value = true
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('请输入分组名称(1–32 个字符)', group ? '重命名账号分组' : '新建账号分组', {
|
||||
inputValue: group?.name || '',
|
||||
confirmButtonText: '保存',
|
||||
cancelButtonText: '取消',
|
||||
inputValidator: value => {
|
||||
const name = (value || '').trim()
|
||||
return (name.length > 0 && [...name].length <= 32 && name !== '默认分组') || '名称不能为空、过长或使用“默认分组”'
|
||||
}
|
||||
})
|
||||
const response = await requestWithRecentAuthentication(() => http.post('/api/v1/user/groups/save', { id: group?.id || 0, name: value.trim() }))
|
||||
if (response?.errorNo !== 0) throw new Error(response?.errorMsg || '保存分组失败')
|
||||
ElMessage.success('账号分组已保存')
|
||||
await refreshGroups()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close' && !isRecentAuthenticationCancelled(error)) ElMessage.error(error?.message || '保存分组失败')
|
||||
} finally {
|
||||
groupSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const editMembership = user => {
|
||||
if (!canManageGroups.value || groupSaving.value) return
|
||||
membershipUser.value = user
|
||||
membershipSelection.value = [...(user.GroupIDs || [0])]
|
||||
membershipVisible.value = true
|
||||
}
|
||||
|
||||
const saveMembership = async () => {
|
||||
if (!canManageGroups.value || groupSaving.value || !membershipUser.value) return
|
||||
if (membershipSelection.value.length === 0) membershipSelection.value = [0]
|
||||
groupSaving.value = true
|
||||
try {
|
||||
const response = await requestWithRecentAuthentication(() => http.post('/api/v1/user/groups/membership', {
|
||||
user_id: membershipUser.value.ID,
|
||||
version: membershipUser.value.MembershipVersion,
|
||||
group_ids: [...membershipSelection.value]
|
||||
}))
|
||||
if (response?.errorNo !== 0) throw new Error(response?.errorMsg || '保存成员分组失败')
|
||||
membershipVisible.value = false
|
||||
ElMessage.success('成员分组已更新,目标账号需重新登录')
|
||||
await refreshGroups()
|
||||
} catch (error) {
|
||||
if (!isRecentAuthenticationCancelled(error)) ElMessage.error(error?.message || '保存成员分组失败,请刷新后重试')
|
||||
} finally {
|
||||
groupSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accountGroups, groupsLoading, groupsError, canManageGroups, groupSaving,
|
||||
membershipVisible, membershipUser, membershipSelection, membershipLimit,
|
||||
refreshGroups, loadGroup, toggleGroup, saveGroup, editMembership, saveMembership
|
||||
}
|
||||
}
|
||||
@ -171,3 +171,49 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.group-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.account-group-panel {
|
||||
margin-top: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account-group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.account-group-header h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.account-group-toggle {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-group-toggle:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.membership-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import {Plus} from "@element-plus/icons-vue";
|
||||
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||
import {confirmRecentAuthentication, isRecentAuthenticationCancelled, requestWithRecentAuthentication} from '@/utils/recentAuthentication'
|
||||
import {roleMeta, assignableRoles, canManageUser} from '@/utils/userRoles.mjs'
|
||||
import {useAccountGroups} from './AccountGroups.js'
|
||||
|
||||
export function recoveryCodeMeta(configured) {
|
||||
return configured
|
||||
@ -47,11 +48,7 @@ export function canManageRecoveryCode(user) {
|
||||
export function usePage() {
|
||||
const globalStatus = useGlobalStatusStore()
|
||||
|
||||
const userList = reactive([])
|
||||
|
||||
const currentPage = ref(1)
|
||||
|
||||
const totalPage = ref(1)
|
||||
const groups = useAccountGroups(globalStatus)
|
||||
|
||||
const userInfoDialog = ref(false)
|
||||
|
||||
@ -95,17 +92,12 @@ export function usePage() {
|
||||
|
||||
const title = ref(lang.editUser)
|
||||
|
||||
const reflushList = function () {
|
||||
http.post('/api/v1/user/list', {"current_page": currentPage.value, "page_size": 6}).then(res => {
|
||||
userList.length = 0
|
||||
totalPage.value = res.data.total_page
|
||||
if (res.data["list"]) {
|
||||
userList.push(...res.data["list"])
|
||||
if (selectedUser.value?.ID) {
|
||||
selectedUser.value = userList.find(item => item.ID === selectedUser.value.ID) || selectedUser.value
|
||||
}
|
||||
}
|
||||
})
|
||||
const reflushList = async function () {
|
||||
await groups.refreshGroups()
|
||||
if (selectedUser.value?.ID) {
|
||||
const users = groups.accountGroups.value.flatMap(group => group.rows)
|
||||
selectedUser.value = users.find(item => item.ID === selectedUser.value.ID) || selectedUser.value
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = function (idx, row) {
|
||||
@ -213,9 +205,7 @@ export function usePage() {
|
||||
http,
|
||||
ElNotification,
|
||||
Plus,
|
||||
userList,
|
||||
currentPage,
|
||||
totalPage,
|
||||
...groups,
|
||||
userInfoDialog,
|
||||
editModel,
|
||||
editUserInfo,
|
||||
|
||||
@ -3,10 +3,31 @@
|
||||
<div class="settings-header">
|
||||
<h3>{{ lang.account_management }}</h3>
|
||||
<p class="settings-desc">{{ lang.account_management_desc }}</p>
|
||||
<p class="settings-desc">仅显示权限范围内的成员。新账号默认归入默认分组;分组及成员归属由超级管理员维护。</p>
|
||||
<div class="group-toolbar">
|
||||
<el-button v-if="canCreateUser" type="primary" :disabled="groupsLoading" @click="createUser">
|
||||
<el-icon><Plus /></el-icon> 新建账号
|
||||
</el-button>
|
||||
<el-button v-if="canManageGroups" :loading="groupSaving" @click="saveGroup()">新建分组</el-button>
|
||||
<el-button :loading="groupsLoading" @click="reflushList">刷新列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="groupsError" :title="groupsError" type="error" :closable="false" />
|
||||
<el-empty v-if="!groupsLoading && !groupsError && accountGroups.length === 0" description="暂无可查看的分组" />
|
||||
<section v-for="group in accountGroups" :key="group.id" class="account-group-panel">
|
||||
<header class="account-group-header">
|
||||
<h4 v-if="group.id === 0">{{ group.name }}</h4>
|
||||
<button v-else type="button" class="account-group-toggle" :aria-expanded="group.expanded" :aria-controls="`account-group-${group.id}`" @click="toggleGroup(group)">
|
||||
<span aria-hidden="true">{{ group.expanded ? '▾' : '▸' }}</span>
|
||||
{{ group.name }}
|
||||
</button>
|
||||
<el-button v-if="canManageGroups && group.id !== 0" text :disabled="groupSaving" @click="saveGroup(group)">重命名</el-button>
|
||||
</header>
|
||||
<div v-if="group.expanded" :id="`account-group-${group.id}`" v-loading="group.loading">
|
||||
<el-alert v-if="group.error" :title="group.error" type="error" :closable="false" />
|
||||
<div class="table-container">
|
||||
<el-table :data="userList" class="modern-table" style="width: 100%" empty-text="暂无用户">
|
||||
<el-table :data="group.rows" class="modern-table" style="width: 100%" empty-text="本组暂无成员">
|
||||
<el-table-column label="ID" prop="ID" width="44" />
|
||||
<el-table-column :label="lang.account" prop="Account" width="88" show-overflow-tooltip />
|
||||
<el-table-column :label="lang.user_name" prop="Name" width="76" show-overflow-tooltip />
|
||||
@ -58,12 +79,7 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="right" width="68">
|
||||
<template #header>
|
||||
<el-button v-if="canCreateUser" type="primary" size="small" @click="createUser" class="new-btn" plain>
|
||||
<el-icon><Plus /></el-icon> 新建
|
||||
</el-button>
|
||||
</template>
|
||||
<el-table-column label="操作" align="right" :width="canManageGroups ? 126 : 68">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
size="small"
|
||||
@ -75,6 +91,7 @@
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button v-if="canManageGroups" size="small" text :disabled="groupSaving" @click="editMembership(scope.row)">分组</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -82,14 +99,34 @@
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:current-page="group.page"
|
||||
size="small"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:page-count="totalPage"
|
||||
@current-change="reflushList"
|
||||
:page-count="group.totalPages"
|
||||
:disabled="group.loading"
|
||||
@current-change="loadGroup(group)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="membershipVisible" title="设置账号所属分组" width="450px" :close-on-click-modal="!groupSaving" :close-on-press-escape="!groupSaving" :show-close="!groupSaving">
|
||||
<p>账号:{{ membershipUser?.Account }}</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="所属分组">
|
||||
<el-select v-model="membershipSelection" multiple :multiple-limit="membershipLimit" :disabled="groupSaving" placeholder="未选择时归入默认分组" class="membership-select">
|
||||
<el-option v-for="group in accountGroups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p class="settings-desc">管理员可以多选;普通用户只能选择一个分组。</p>
|
||||
<el-alert title="修改归属会使该账号的旧登录会话和通知发信凭据失效,需要重新登录并配置通知凭据。" type="warning" :closable="false" />
|
||||
<template #footer>
|
||||
<el-button :disabled="groupSaving" @click="membershipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="groupSaving" @click="saveMembership">保存分组</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="userInfoDialog"
|
||||
@ -226,11 +263,22 @@ import UserExternalEmailDialog from "./UserExternalEmailDialog/UserExternalEmail
|
||||
|
||||
const {
|
||||
lang,
|
||||
userList,
|
||||
accountGroups,
|
||||
groupsLoading,
|
||||
groupsError,
|
||||
canManageGroups,
|
||||
groupSaving,
|
||||
membershipVisible,
|
||||
membershipUser,
|
||||
membershipSelection,
|
||||
membershipLimit,
|
||||
loadGroup,
|
||||
toggleGroup,
|
||||
saveGroup,
|
||||
editMembership,
|
||||
saveMembership,
|
||||
createUser,
|
||||
handleEdit,
|
||||
currentPage,
|
||||
totalPage,
|
||||
reflushList,
|
||||
userInfoDialog,
|
||||
title,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user