检查点:保留账号分组隔离实现,继续权限复审
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-25 21:46:42 +08:00
parent 9be7a6f990
commit fcbcf91d66
23 changed files with 811 additions and 81 deletions

View 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, "分组操作失败,请检查是否重名并刷新后重试")
}
}

View File

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

View File

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

View File

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

View File

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

View 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)`,
},
}
}

View File

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

View File

@ -40,6 +40,7 @@ func DefaultMigrations() []Migration {
DialectPostgres: {configstate.Schema("postgres")},
}),
DefineForDialects(25, "create_notification_grants", notificationGrantStatements()),
DefineForDialects(26, "create_account_groups", accountGroupStatements()),
}
}

View File

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

View File

@ -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": "管理用户安全码",

View File

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

View 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" }

View File

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

View File

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

View 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()
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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