From fcbcf91d66795e6322fffe478a07c1f101349320 Mon Sep 17 00:00:00 2001 From: smail Date: Fri, 25 Sep 2026 21:46:42 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A3=80=E6=9F=A5=E7=82=B9=EF=BC=9A=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E8=B4=A6=E5=8F=B7=E5=88=86=E7=BB=84=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=EF=BC=8C=E7=BB=A7=E7=BB=AD=E6=9D=83=E9=99=90?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/controllers/account_group.go | 132 +++++++++++ backend/controllers/log_permissions.go | 6 +- backend/controllers/system_notification.go | 71 ++++-- backend/controllers/user.go | 62 ++++- backend/controllers/user_security.go | 3 + backend/db/migration/account_group.go | 25 ++ backend/db/migration/notification_grant.go | 4 +- backend/db/migration/registry.go | 1 + backend/listen/http_server/api_v1_compat.go | 3 + backend/listen/http_server/operation_log.go | 6 +- backend/listen/smtp_server/action.go | 2 +- backend/models/account_group.go | 16 ++ backend/services/access/capability.go | 3 + backend/services/access/catalog.go | 6 + backend/services/accountgroup/service.go | 216 ++++++++++++++++++ backend/services/auth/protocol_session.go | 6 +- backend/services/logstore/store.go | 9 +- backend/services/notification/service.go | 26 +-- backend/services/plugin/config.go | 4 + .../UserManagement/AccountGroups.js | 143 ++++++++++++ .../UserManagement/UserManagement.css | 46 ++++ .../UserManagement/UserManagement.js | 28 +-- .../UserManagement/UserManagement.vue | 74 ++++-- 23 files changed, 811 insertions(+), 81 deletions(-) create mode 100644 backend/controllers/account_group.go create mode 100644 backend/db/migration/account_group.go create mode 100644 backend/models/account_group.go create mode 100644 backend/services/accountgroup/service.go create mode 100644 frontend/src/views/components/UserManagement/AccountGroups.js diff --git a/backend/controllers/account_group.go b/backend/controllers/account_group.go new file mode 100644 index 0000000..34b3057 --- /dev/null +++ b/backend/controllers/account_group.go @@ -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, "分组操作失败,请检查是否重名并刷新后重试") + } +} diff --git a/backend/controllers/log_permissions.go b/backend/controllers/log_permissions.go index 2821cb7..8d1471e 100644 --- a/backend/controllers/log_permissions.go +++ b/backend/controllers/log_permissions.go @@ -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 { diff --git a/backend/controllers/system_notification.go b/backend/controllers/system_notification.go index 01bd347..591d388 100644 --- a/backend/controllers/system_notification.go +++ b/backend/controllers/system_notification.go @@ -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}) diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 976d1ed..5ad17d0 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -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) diff --git a/backend/controllers/user_security.go b/backend/controllers/user_security.go index f584619..5587547 100644 --- a/backend/controllers/user_security.go +++ b/backend/controllers/user_security.go @@ -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 diff --git a/backend/db/migration/account_group.go b/backend/db/migration/account_group.go new file mode 100644 index 0000000..03375d3 --- /dev/null +++ b/backend/db/migration/account_group.go @@ -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)`, + }, + } +} diff --git a/backend/db/migration/notification_grant.go b/backend/db/migration/notification_grant.go index 0e7f701..5c48885 100644 --- a/backend/db/migration/notification_grant.go +++ b/backend/db/migration/notification_grant.go @@ -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}, } } diff --git a/backend/db/migration/registry.go b/backend/db/migration/registry.go index 067554b..58e5874 100644 --- a/backend/db/migration/registry.go +++ b/backend/db/migration/registry.go @@ -40,6 +40,7 @@ func DefaultMigrations() []Migration { DialectPostgres: {configstate.Schema("postgres")}, }), DefineForDialects(25, "create_notification_grants", notificationGrantStatements()), + DefineForDialects(26, "create_account_groups", accountGroupStatements()), } } diff --git a/backend/listen/http_server/api_v1_compat.go b/backend/listen/http_server/api_v1_compat.go index a1aca81..ab0c6a9 100644 --- a/backend/listen/http_server/api_v1_compat.go +++ b/backend/listen/http_server/api_v1_compat.go @@ -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) diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index d28d4f5..12ea4d4 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -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": "管理用户安全码", diff --git a/backend/listen/smtp_server/action.go b/backend/listen/smtp_server/action.go index d0bf53c..3c6a3c7 100644 --- a/backend/listen/smtp_server/action.go +++ b/backend/listen/smtp_server/action.go @@ -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. diff --git a/backend/models/account_group.go b/backend/models/account_group.go new file mode 100644 index 0000000..ab56b43 --- /dev/null +++ b/backend/models/account_group.go @@ -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" } diff --git a/backend/services/access/capability.go b/backend/services/access/capability.go index 3672ffa..73009a0 100644 --- a/backend/services/access/capability.go +++ b/backend/services/access/capability.go @@ -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, diff --git a/backend/services/access/catalog.go b/backend/services/access/catalog.go index 20aa539..535867e 100644 --- a/backend/services/access/catalog.go +++ b/backend/services/access/catalog.go @@ -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}, diff --git a/backend/services/accountgroup/service.go b/backend/services/accountgroup/service.go new file mode 100644 index 0000000..2e11881 --- /dev/null +++ b/backend/services/accountgroup/service.go @@ -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() +} diff --git a/backend/services/auth/protocol_session.go b/backend/services/auth/protocol_session.go index ba76302..d07b024 100644 --- a/backend/services/auth/protocol_session.go +++ b/backend/services/auth/protocol_session.go @@ -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) { diff --git a/backend/services/logstore/store.go b/backend/services/logstore/store.go index 9675284..679ad19 100644 --- a/backend/services/logstore/store.go +++ b/backend/services/logstore/store.go @@ -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) { diff --git a/backend/services/notification/service.go b/backend/services/notification/service.go index bac129f..b46dd0f 100644 --- a/backend/services/notification/service.go +++ b/backend/services/notification/service.go @@ -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 } diff --git a/backend/services/plugin/config.go b/backend/services/plugin/config.go index 67a6856..007defb 100644 --- a/backend/services/plugin/config.go +++ b/backend/services/plugin/config.go @@ -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 } diff --git a/frontend/src/views/components/UserManagement/AccountGroups.js b/frontend/src/views/components/UserManagement/AccountGroups.js new file mode 100644 index 0000000..c8c7983 --- /dev/null +++ b/frontend/src/views/components/UserManagement/AccountGroups.js @@ -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 + } +} diff --git a/frontend/src/views/components/UserManagement/UserManagement.css b/frontend/src/views/components/UserManagement/UserManagement.css index 0847f3a..f007855 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.css +++ b/frontend/src/views/components/UserManagement/UserManagement.css @@ -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%; +} diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index ba1ea43..8ae9156 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -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, diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue index 6ff01e1..467fb2c 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -3,10 +3,31 @@

{{ lang.account_management }}

{{ lang.account_management_desc }}

+

仅显示权限范围内的成员。新账号默认归入默认分组;分组及成员归属由超级管理员维护。

+
+ + 新建账号 + + 新建分组 + 刷新列表 +
+ + + + + +

账号:{{ membershipUser?.Account }}

+ + + + + + + +

管理员可以多选;普通用户只能选择一个分组。

+ + +