调整系统管理入口并实现用户分组与角色可见范围
This commit is contained in:
parent
c91aab659b
commit
5bcada2ac8
@ -10,6 +10,7 @@ import (
|
||||
)
|
||||
|
||||
//go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql
|
||||
//go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
||||
@ -38,7 +39,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const latestMigrationVersion = 7
|
||||
const latestMigrationVersion = 8
|
||||
|
||||
func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error {
|
||||
path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version))
|
||||
@ -133,6 +134,8 @@ func migrationName(version int) string {
|
||||
return "server_validation"
|
||||
case 7:
|
||||
return "server_password_auth"
|
||||
case 8:
|
||||
return "user_groups"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN groups_json TEXT;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN groups_json TEXT;
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN groups_json TEXT;
|
||||
56
backend/internal/database/user_groups_test.go
Normal file
56
backend/internal/database/user_groups_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
)
|
||||
|
||||
func TestSQLiteUpgradePreservesUngroupedUsers(t *testing.T) {
|
||||
cfg := config.Default(t.TempDir()).Database
|
||||
cfg.DSN = filepath.Join(t.TempDir(), "upgrade.db")
|
||||
db, err := Open(t.Context(), cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL("sqlite")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 7; version++ {
|
||||
if err := applyMigration(t.Context(), db.DB, "sqlite", version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO users
|
||||
(id, username, email, password_hash, role, status)
|
||||
VALUES ('legacy', 'legacy', '', 'existing-hash', 'admin', 'active')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var username, passwordHash, role, status string
|
||||
var groups sql.NullString
|
||||
if err := db.DB.QueryRowContext(t.Context(), `SELECT username, password_hash, role, status, groups_json
|
||||
FROM users WHERE id = 'legacy'`).Scan(&username, &passwordHash, &role, &status, &groups); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if username != "legacy" || passwordHash != "existing-hash" || role != "admin" || status != "active" {
|
||||
t.Fatalf("unexpected legacy user: %q %q %q", username, role, status)
|
||||
}
|
||||
if groups.Valid && groups.String != "[]" {
|
||||
t.Fatalf("unexpected legacy groups: %v", groups)
|
||||
}
|
||||
var version int
|
||||
if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if version != latestMigrationVersion {
|
||||
t.Fatalf("unexpected version: %d", version)
|
||||
}
|
||||
}
|
||||
@ -177,12 +177,12 @@ func (s *Server) registerRoutes() {
|
||||
s.router.POST("/api/v1/auth/password", s.requireAuthenticated, s.changePassword)
|
||||
s.router.POST("/api/v1/password-reset/resolve", s.resolvePasswordResetAccount)
|
||||
adminAPI := s.router.Group("/api/v1", s.requireAdmin)
|
||||
userAPI := s.router.Group("/api/v1/users", s.requireSuperAdmin)
|
||||
userAPI := s.router.Group("/api/v1/users", s.requireAuthenticated)
|
||||
userAPI.GET("", s.listUsers)
|
||||
userAPI.POST("", s.createUser)
|
||||
userAPI.POST("", s.requireSuperAdmin, s.createUser)
|
||||
userAPI.GET("/:id", s.getUser)
|
||||
userAPI.PUT("/:id", s.updateUser)
|
||||
userAPI.DELETE("/:id", s.deleteUser)
|
||||
userAPI.PUT("/:id", s.requireSuperAdmin, s.updateUser)
|
||||
userAPI.DELETE("/:id", s.requireSuperAdmin, s.deleteUser)
|
||||
adminAPI.GET("/domains", s.listDomains)
|
||||
adminAPI.POST("/domains", s.dnsOperation, s.createDomain)
|
||||
adminAPI.PUT("/domains/:id", s.dnsOperation, s.updateDomain)
|
||||
|
||||
80
backend/internal/httpapi/user_groups.go
Normal file
80
backend/internal/httpapi/user_groups.go
Normal file
@ -0,0 +1,80 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// userListViewer 每次从仓储读取当前分组,避免调组后沿用会话中的旧权限。
|
||||
func (s *Server) userListViewer(c *gin.Context) (repository.User, bool) {
|
||||
session, ok := s.currentAuthSession(c)
|
||||
if !ok {
|
||||
s.writeError(c, http.StatusUnauthorized, "请先登录管理后台", nil)
|
||||
return repository.User{}, false
|
||||
}
|
||||
if session.Role == repository.UserRoleSuperAdmin {
|
||||
return repository.User{ID: session.UserID, Role: session.Role}, true
|
||||
}
|
||||
viewer, err := s.users.GetUserByID(c.Request.Context(), session.UserID)
|
||||
if err != nil || viewer.Status != repository.UserStatusActive || viewer.Role != session.Role {
|
||||
s.writeError(c, http.StatusForbidden, "当前账号无法读取用户列表", nil)
|
||||
return repository.User{}, false
|
||||
}
|
||||
return viewer, true
|
||||
}
|
||||
|
||||
func userVisibleTo(viewer, item repository.User) bool {
|
||||
if viewer.Role == repository.UserRoleSuperAdmin || viewer.ID == item.ID {
|
||||
return true
|
||||
}
|
||||
if viewer.Role != repository.UserRoleAdmin || item.Role == repository.UserRoleSuperAdmin {
|
||||
return false
|
||||
}
|
||||
if len(viewer.Groups) == 0 && len(item.Groups) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, group := range viewer.Groups {
|
||||
for _, memberGroup := range item.Groups {
|
||||
if group == memberGroup {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// scopedUserGroups 不向普通用户输出分组,也不向管理员泄露其他分组名称。
|
||||
func scopedUserGroups(viewer, item repository.User) repository.User {
|
||||
if viewer.Role == repository.UserRoleSuperAdmin {
|
||||
return item
|
||||
}
|
||||
groups := make([]string, 0)
|
||||
if viewer.Role == repository.UserRoleAdmin {
|
||||
for _, group := range item.Groups {
|
||||
for _, allowed := range viewer.Groups {
|
||||
if group == allowed {
|
||||
groups = append(groups, group)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item.Groups = groups
|
||||
return item
|
||||
}
|
||||
|
||||
func (s *Server) applyRequestedUserGroups(c *gin.Context, item *repository.User, requested *[]string) bool {
|
||||
groups := item.Groups
|
||||
if requested != nil {
|
||||
groups = *requested
|
||||
}
|
||||
var err error
|
||||
item.Groups, err = repository.NormalizeUserGroups(item.Role, groups)
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
139
backend/internal/httpapi/user_groups_test.go
Normal file
139
backend/internal/httpapi/user_groups_test.go
Normal file
@ -0,0 +1,139 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
)
|
||||
|
||||
func TestUserGroupVisibilityAndWriteProtection(t *testing.T) {
|
||||
server, users, _ := protectedUserServer(t)
|
||||
password := "Member123a"
|
||||
hash := httpUserPasswordHash(t, password)
|
||||
for _, item := range []repository.User{
|
||||
{ID: "leader", Username: "leader", Role: "admin", Groups: []string{"甲组", "乙组"}},
|
||||
{ID: "peer", Username: "peer", Role: "admin", Groups: []string{"乙组", "隐藏组"}},
|
||||
{ID: "first", Username: "first", Role: "user", Groups: []string{"甲组"}},
|
||||
{ID: "second", Username: "second", Role: "user", Groups: []string{"乙组"}},
|
||||
{ID: "other", Username: "other", Role: "user", Groups: []string{"隐藏组"}},
|
||||
{ID: "default-user", Username: "default-user", Role: "user"},
|
||||
{ID: "default-admin", Username: "default-admin", Role: "admin"},
|
||||
} {
|
||||
item.PasswordHash, item.Status = hash, "active"
|
||||
if err := users.CreateUser(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
want []string
|
||||
}{
|
||||
{"leader", []string{"first", "leader", "peer", "second"}},
|
||||
{"first", []string{"first"}},
|
||||
{"default-admin", []string{"default-admin", "default-user"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cookie := loginAuthTest(t, server, tc.name, password, false).Result().Cookies()[0]
|
||||
response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users", "")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("读取分组失败:%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Items []repository.User `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := make([]string, 0, len(body.Items))
|
||||
for _, item := range body.Items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
if !reflect.DeepEqual(ids, tc.want) {
|
||||
t.Fatalf("用户范围错误:%v", ids)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "隐藏组") {
|
||||
t.Fatal("泄露组外分组名称")
|
||||
}
|
||||
if tc.name == "first" && strings.Contains(response.Body.String(), `"groups"`) {
|
||||
t.Fatal("普通用户不应收到分组信息")
|
||||
}
|
||||
outside := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users/other", "")
|
||||
if outside.Code != http.StatusNotFound {
|
||||
t.Fatalf("组外读取应返回 404:%d", outside.Code)
|
||||
}
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete} {
|
||||
path := "/api/v1/users/first"
|
||||
if method == http.MethodPost {
|
||||
path = "/api/v1/users"
|
||||
}
|
||||
result := serveUserRequest(server, cookie, method, path, `{"username":"first","role":"admin","groups":["隐藏组"]}`)
|
||||
if result.Code != http.StatusForbidden {
|
||||
t.Fatalf("越权写入应返回 403:%s %d", method, result.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
superCookie := loginAuthTest(t, server, "admin", "AdminPass123", false).Result().Cookies()[0]
|
||||
all := serveUserRequest(server, superCookie, http.MethodGet, "/api/v1/users", "")
|
||||
if all.Code != http.StatusOK || !strings.Contains(all.Body.String(), "隐藏组") || !strings.Contains(all.Body.String(), "default-user") {
|
||||
t.Fatal("超级管理员应读取所有分组")
|
||||
}
|
||||
for _, payload := range []string{
|
||||
`{"username":"new-user","password":"Member123a","role":"user","groups":["甲组","乙组"]}`,
|
||||
`{"username":"new-user","password":"Member123a","role":"user","groups":["默认分组"]}`,
|
||||
`{"username":"new-user","password":"Member123a","role":"user","groups":[""]}`,
|
||||
} {
|
||||
response := serveUserRequest(server, superCookie, http.MethodPost, "/api/v1/users", payload)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法分组未拒绝:%d", response.Code)
|
||||
}
|
||||
}
|
||||
// 降级时不得静默丢弃管理员的多组成员关系。
|
||||
badRole := serveUserRequest(server, superCookie, http.MethodPut, "/api/v1/users/leader", `{"username":"leader","role":"user"}`)
|
||||
if badRole.Code != http.StatusBadRequest {
|
||||
t.Fatal("多组管理员降级应要求明确选择单组")
|
||||
}
|
||||
updated := serveUserRequest(server, superCookie, http.MethodPut, "/api/v1/users/leader", `{"username":"leader","role":"user","groups":["乙组"]}`)
|
||||
if updated.Code != http.StatusOK {
|
||||
t.Fatalf("单组降级失败:%s", updated.Body.String())
|
||||
}
|
||||
cleared := serveUserRequest(server, superCookie, http.MethodPut, "/api/v1/users/leader", `{"username":"leader","groups":[]}`)
|
||||
if cleared.Code != http.StatusOK {
|
||||
t.Fatalf("回到默认分组失败:%s", cleared.Body.String())
|
||||
}
|
||||
item, err := users.GetUserByID(t.Context(), "leader")
|
||||
if err != nil || len(item.Groups) != 0 {
|
||||
t.Fatalf("分组清空未保存:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserGroupsAreReadFromCurrentRepository(t *testing.T) {
|
||||
server, users, _ := protectedUserServer(t)
|
||||
hash := httpUserPasswordHash(t, "Member123a")
|
||||
for _, item := range []repository.User{
|
||||
{ID: "leader", Username: "leader", Role: "admin", Groups: []string{"甲组"}},
|
||||
{ID: "first", Username: "first", Role: "user", Groups: []string{"甲组"}},
|
||||
{ID: "second", Username: "second", Role: "user", Groups: []string{"乙组"}},
|
||||
} {
|
||||
item.PasswordHash, item.Status = hash, "active"
|
||||
if err := users.CreateUser(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cookie := loginAuthTest(t, server, "leader", "Member123a", false).Result().Cookies()[0]
|
||||
item, _ := users.GetUserByID(t.Context(), "leader")
|
||||
item.Groups = []string{"乙组"}
|
||||
if err := users.UpdateUser(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := serveUserRequest(server, cookie, http.MethodGet, "/api/v1/users", "")
|
||||
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), `"id":"first"`) || !strings.Contains(response.Body.String(), `"id":"second"`) {
|
||||
t.Fatalf("旧会话未应用新分组范围:%s", response.Body.String())
|
||||
}
|
||||
}
|
||||
@ -14,11 +14,12 @@ import (
|
||||
)
|
||||
|
||||
type userRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Groups *[]string `json:"groups"`
|
||||
}
|
||||
|
||||
func (s *Server) requireSuperAdmin(c *gin.Context) {
|
||||
@ -53,12 +54,22 @@ func (s *Server) listUsers(c *gin.Context) {
|
||||
s.writeError(c, http.StatusNotImplemented, "用户管理接口尚未配置", errors.New("用户仓储未配置"))
|
||||
return
|
||||
}
|
||||
viewer, ok := s.userListViewer(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.users.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
s.writeError(c, http.StatusInternalServerError, "查询用户失败", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
visible := make([]repository.User, 0, len(items))
|
||||
for _, item := range items {
|
||||
if userVisibleTo(viewer, item) {
|
||||
visible = append(visible, scopedUserGroups(viewer, item))
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": visible, "current_user_id": viewer.ID, "current_role": viewer.Role})
|
||||
}
|
||||
|
||||
func (s *Server) getUser(c *gin.Context) {
|
||||
@ -66,6 +77,10 @@ func (s *Server) getUser(c *gin.Context) {
|
||||
s.writeError(c, http.StatusNotImplemented, "用户管理接口尚未配置", errors.New("用户仓储未配置"))
|
||||
return
|
||||
}
|
||||
viewer, ok := s.userListViewer(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := s.users.GetUserByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
if repository.IsNotFound(err) {
|
||||
@ -75,7 +90,11 @@ func (s *Server) getUser(c *gin.Context) {
|
||||
s.writeError(c, http.StatusInternalServerError, "读取用户失败", err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, item)
|
||||
if !userVisibleTo(viewer, item) {
|
||||
s.writeError(c, http.StatusNotFound, "用户不存在", nil)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, scopedUserGroups(viewer, item))
|
||||
}
|
||||
|
||||
func (s *Server) createUser(c *gin.Context) {
|
||||
@ -97,6 +116,9 @@ func (s *Server) createUser(c *gin.Context) {
|
||||
s.writeError(c, http.StatusBadRequest, "系统只允许初始化时创建一个超级管理员", nil)
|
||||
return
|
||||
}
|
||||
if !s.applyRequestedUserGroups(c, &item, request.Groups) {
|
||||
return
|
||||
}
|
||||
if err := s.users.CreateUser(c.Request.Context(), item); err != nil {
|
||||
s.writeError(c, userErrorStatus(err), "创建用户失败", err)
|
||||
return
|
||||
@ -143,6 +165,10 @@ func (s *Server) updateUser(c *gin.Context) {
|
||||
if request.Status == "" {
|
||||
item.Status = existing.Status
|
||||
}
|
||||
item.Groups = existing.Groups
|
||||
if !s.applyRequestedUserGroups(c, &item, request.Groups) {
|
||||
return
|
||||
}
|
||||
if existing.Role != repository.UserRoleSuperAdmin && item.Role == repository.UserRoleSuperAdmin {
|
||||
s.writeError(c, http.StatusBadRequest, "不能将用户提升为超级管理员", nil)
|
||||
return
|
||||
|
||||
@ -54,8 +54,8 @@ func TestUserManagementAuthorizationAndCRUD(t *testing.T) {
|
||||
}
|
||||
regularCookie := httpUserSessionCookie(server, "user-1", repository.UserRoleUser)
|
||||
forbidden := serveUserRequest(server, regularCookie, http.MethodGet, "/api/v1/users", "")
|
||||
if forbidden.Code != http.StatusForbidden {
|
||||
t.Fatalf("普通用户请求应返回 403:%d %s", forbidden.Code, forbidden.Body.String())
|
||||
if forbidden.Code != http.StatusOK || strings.Contains(forbidden.Body.String(), "primary-super-admin") || strings.Contains(forbidden.Body.String(), `"groups"`) {
|
||||
t.Fatalf("普通用户只能读取自己且不显示分组:%d %s", forbidden.Code, forbidden.Body.String())
|
||||
}
|
||||
|
||||
adminCookie := httpUserSessionCookie(server, "primary-super-admin", repository.UserRoleSuperAdmin)
|
||||
|
||||
47
backend/internal/repository/user_groups.go
Normal file
47
backend/internal/repository/user_groups.go
Normal file
@ -0,0 +1,47 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// NormalizeUserGroups 将未分类表示为空列表,并统一校验分组数量及名称。
|
||||
func NormalizeUserGroups(role string, groups []string) ([]string, error) {
|
||||
if len(groups) > 32 {
|
||||
return nil, errors.New("分组数量不能超过 32 个")
|
||||
}
|
||||
result := make([]string, 0, len(groups))
|
||||
seen := make(map[string]bool)
|
||||
for _, name := range groups {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == "默认分组" || len([]rune(name)) > 32 {
|
||||
return nil, errors.New("分组名称无效:请输入 1 到 32 个字符,默认分组请保持未分组")
|
||||
}
|
||||
for _, char := range name {
|
||||
if unicode.IsControl(char) {
|
||||
return nil, errors.New("分组名称不能包含控制字符")
|
||||
}
|
||||
}
|
||||
if !seen[name] {
|
||||
result = append(result, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(role), UserRoleUser) && len(result) > 1 {
|
||||
return nil, errors.New("普通用户只能属于一个分组")
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func encodeUserGroups(item User) (string, error) {
|
||||
groups, err := NormalizeUserGroups(item.Role, item.Groups)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := json.Marshal(groups)
|
||||
return string(data), err
|
||||
}
|
||||
41
backend/internal/repository/user_groups_test.go
Normal file
41
backend/internal/repository/user_groups_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserGroupsValidationAndPersistence(t *testing.T) {
|
||||
db := openUserTestDatabase(t)
|
||||
defer db.Close()
|
||||
repo := NewSQLUserRepository(db.DB)
|
||||
item := User{ID: "group-admin", Username: "group-admin", Role: UserRoleAdmin, Status: UserStatusActive, PasswordHash: userTestPasswordHash(t, "Member123a"), Groups: []string{" beta ", "alpha", "alpha"}}
|
||||
if err := repo.CreateUser(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repo.GetUserByID(t.Context(), item.ID)
|
||||
if err != nil || !reflect.DeepEqual(got.Groups, []string{"alpha", "beta"}) {
|
||||
t.Fatalf("分组未规范化保存:%+v %v", got.Groups, err)
|
||||
}
|
||||
got.Role = UserRoleUser
|
||||
if err := repo.UpdateUser(t.Context(), got); err == nil {
|
||||
t.Fatal("仓储层应拒绝普通用户多组")
|
||||
}
|
||||
got.Groups = []string{"alpha"}
|
||||
if err := repo.UpdateUser(t.Context(), got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.DB.Exec("UPDATE users SET groups_json = NULL WHERE id = ?", item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy, err := repo.GetUserByID(t.Context(), item.ID)
|
||||
if err != nil || len(legacy.Groups) != 0 {
|
||||
t.Fatal("旧用户应归入默认分组")
|
||||
}
|
||||
for _, groups := range [][]string{{""}, {"默认分组"}, {"bad\nname"}, {strings.Repeat("组", 33)}, make([]string, 33)} {
|
||||
if _, err := NormalizeUserGroups(UserRoleAdmin, groups); err == nil {
|
||||
t.Fatalf("非法分组未拒绝:%v", groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
@ -30,6 +31,7 @@ type User struct {
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@ -131,16 +133,20 @@ func (r *SQLUserRepository) CreateUser(ctx context.Context, item User) error {
|
||||
if err := validateUser(item, false); err != nil {
|
||||
return err
|
||||
}
|
||||
groups, err := encodeUserGroups(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.CreatedAt.IsZero() {
|
||||
item.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if item.UpdatedAt.IsZero() {
|
||||
item.UpdatedAt = item.CreatedAt
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, r.query(`INSERT INTO users
|
||||
(id, username, email, password_hash, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Username, r.emailValue(item.Email), item.PasswordHash,
|
||||
item.Role, item.Status, item.CreatedAt, item.UpdatedAt)
|
||||
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO users
|
||||
(id, username, email, password_hash, role, status, created_at, updated_at, groups_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Username, r.emailValue(item.Email), item.PasswordHash,
|
||||
item.Role, item.Status, item.CreatedAt, item.UpdatedAt, groups)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存用户失败:%w", err)
|
||||
}
|
||||
@ -155,8 +161,12 @@ func (r *SQLUserRepository) UpdateUser(ctx context.Context, item User) error {
|
||||
if err := validateUser(item, false); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.query(`UPDATE users SET username = ?, email = ?, password_hash = ?, role = ?, status = ?, updated_at = ? WHERE id = ?`),
|
||||
item.Username, r.emailValue(item.Email), item.PasswordHash, item.Role, item.Status, time.Now().UTC(), item.ID)
|
||||
groups, err := encodeUserGroups(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.query(`UPDATE users SET username = ?, email = ?, password_hash = ?, role = ?, status = ?, updated_at = ?, groups_json = ? WHERE id = ?`),
|
||||
item.Username, r.emailValue(item.Email), item.PasswordHash, item.Role, item.Status, time.Now().UTC(), groups, item.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新用户失败:%w", err)
|
||||
}
|
||||
@ -283,15 +293,17 @@ func validateUser(item User, allowEmptyID bool) error {
|
||||
if item.Status != UserStatusActive && item.Status != UserStatusDisabled {
|
||||
return fmt.Errorf("用户状态无效:%s", item.Status)
|
||||
}
|
||||
return nil
|
||||
_, err := NormalizeUserGroups(item.Role, item.Groups)
|
||||
return err
|
||||
}
|
||||
|
||||
const userSelect = `SELECT id, username, email, password_hash, role, status, created_at, updated_at FROM users`
|
||||
const userSelect = `SELECT id, username, email, password_hash, role, status, created_at, updated_at, COALESCE(groups_json, '[]') FROM users`
|
||||
|
||||
func scanUser(row interface{ Scan(...any) error }) (User, error) {
|
||||
var item User
|
||||
var email sql.NullString
|
||||
if err := row.Scan(&item.ID, &item.Username, &email, &item.PasswordHash, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
var groups string
|
||||
if err := row.Scan(&item.ID, &item.Username, &email, &item.PasswordHash, &item.Role, &item.Status, &item.CreatedAt, &item.UpdatedAt, &groups); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return User{}, sql.ErrNoRows
|
||||
}
|
||||
@ -300,5 +312,13 @@ func scanUser(row interface{ Scan(...any) error }) (User, error) {
|
||||
if email.Valid {
|
||||
item.Email = email.String
|
||||
}
|
||||
if err := json.Unmarshal([]byte(groups), &item.Groups); err != nil {
|
||||
return User{}, fmt.Errorf("读取用户分组失败:%w", err)
|
||||
}
|
||||
var err error
|
||||
item.Groups, err = NormalizeUserGroups(item.Role, item.Groups)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return normalizeUser(item), nil
|
||||
}
|
||||
|
||||
44
doc/user-groups.md
Normal file
44
doc/user-groups.md
Normal file
@ -0,0 +1,44 @@
|
||||
# 用户分组接口与权限说明
|
||||
|
||||
适用项目:Scert。更新日期:2026-09-25。
|
||||
|
||||
## 页面入口
|
||||
|
||||
- 系统设置位于侧栏的系统管理区域,打开已有设置面板。
|
||||
- 修改密码位于用户管理页面,修改当前登录账号的密码。
|
||||
- 默认分组置顶并始终展开;命名分组初始折叠。
|
||||
- 普通用户进入用户中心,只显示本人资料及修改密码,不显示分组。
|
||||
|
||||
## 可见范围
|
||||
|
||||
| 角色 | 用户读取范围 | 分组关系 | 用户新增、编辑、删除 |
|
||||
|---|---|---|---|
|
||||
| 超级管理员 | 全部用户和分组 | 主账号保持受保护 | 允许,主账号保护规则不变 |
|
||||
| 管理员 | 自己及所属组成员的并集 | 可加入多个命名分组 | 不允许 |
|
||||
| 普通用户 | 仅自己 | 最多一个命名分组 | 不允许 |
|
||||
|
||||
没有命名分组的账号属于默认分组。未分组管理员可见默认组成员,但不可见其他超级管理员。管理员读取多组成员时,只返回双方共同的分组,不泄露对方的其他组名。这里限定的是用户列表可见范围,不代表域名、证书、服务器资源已经实现分组隔离。
|
||||
|
||||
## 接口
|
||||
|
||||
沿用现有会话认证与同源校验。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/users` | 按角色过滤的用户列表 |
|
||||
| GET | `/api/v1/users/:id` | 查询可见用户;越权与不存在均返回 404 |
|
||||
| POST | `/api/v1/users` | 超级管理员创建用户 |
|
||||
| PUT | `/api/v1/users/:id` | 超级管理员更新用户 |
|
||||
| DELETE | `/api/v1/users/:id` | 超级管理员删除用户 |
|
||||
|
||||
列表响应增加 `current_role`、`current_user_id`。用户对象的 `groups` 为字符串数组;默认组和普通用户读取时省略该字段。密码摘要不返回。
|
||||
|
||||
新增和更新请求支持 `groups`:省略或传 `null` 时,新增使用默认组,更新保留原关系;传 `[]` 清空命名分组、回到默认组。组名去首尾空白、去重并排序,区分大小写,长度为 1 到 32 个字符,不允许控制字符或保留名称“默认分组”。最多 32 个组,普通用户最多 1 个组。多组管理员降级为普通用户时,必须明确保留不超过一个组,否则返回 400,不会静默丢失分组。
|
||||
|
||||
分组名称由保存用户时创建;没有独立空组、组重命名或组删除接口。删除最后一个组内成员后,该组不再显示。
|
||||
|
||||
## 数据升级与验证边界
|
||||
|
||||
迁移版本 8 为用户表增加可空的 `groups_json` 文本字段。旧记录的空值解释为默认分组,保留原账号、角色和密码摘要。每次读取用户列表时重新读取当前账号分组,调组不依赖重新登录。
|
||||
|
||||
SQLite 的旧版本升级与重复迁移由专项测试覆盖;MySQL 和 PostgreSQL 提供迁移文件,但本次专项测试不代表这两种数据库已完成真实环境升级验收。
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { useAppShell } from "./App.js";
|
||||
import AccessDenied from "./views/AccessDenied/AccessDenied.vue";
|
||||
import UserWorkspace from "./views/UserManagement/UserWorkspace.vue";
|
||||
import Dashboard from "./views/Dashboard/Dashboard.vue";
|
||||
import Login from "./views/Login/Login.vue";
|
||||
import PasswordReset from "./views/PasswordReset/PasswordReset.vue";
|
||||
@ -46,7 +46,7 @@ const {
|
||||
|
||||
<Login v-else-if="setupState && authenticated === false" />
|
||||
|
||||
<AccessDenied
|
||||
<UserWorkspace
|
||||
v-else-if="setupState && authenticated === true && isAdmin === false"
|
||||
/>
|
||||
|
||||
|
||||
@ -29,6 +29,9 @@ test("用户管理导航按权限显示,权限变化后回到基本信息", ()
|
||||
navigation.selectSection("users");
|
||||
assert.equal(navigation.activeSection.value, "users");
|
||||
user.value = { role: "admin" };
|
||||
assert.equal(navigation.activeSection.value, "users");
|
||||
assert.equal(navigation.sections.value.length, 6);
|
||||
user.value = null;
|
||||
assert.equal(navigation.activeSection.value, "overview");
|
||||
assert.equal(navigation.sections.value.length, 5);
|
||||
});
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import ChangePassword from "../components/ChangePassword/ChangePassword.vue";
|
||||
import UserManagement from "../UserManagement/UserManagement.vue";
|
||||
import SettingsModal from "./SettingsModal.vue";
|
||||
import DomainModal from "./DomainModal.vue";
|
||||
@ -145,16 +144,12 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
<h1>证书管理总览</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<ChangePassword />
|
||||
<button class="secondary-button" type="button" @click="loadData">
|
||||
刷新状态
|
||||
</button>
|
||||
<button v-if="activeSection === 'activity'" class="secondary-button" type="button" @click="runRenewalNow">
|
||||
立即检查续期
|
||||
</button>
|
||||
<button class="secondary-button" type="button" @click="openSettings">
|
||||
系统设置
|
||||
</button>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
@ -504,6 +499,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
|
||||
:active-section="activeSection"
|
||||
:sections="sections"
|
||||
@select="selectSection"
|
||||
@settings="openSettings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ export const dashboardSections = [
|
||||
export function useDashboardNavigation(currentUser) {
|
||||
const selectedSection = ref("overview");
|
||||
const sections = computed(() => dashboardSections.filter(
|
||||
(section) => section.id !== "users" || currentUser.value?.role === "super_admin",
|
||||
(section) => section.id !== "users" || ["super_admin", "admin"].includes(currentUser.value?.role),
|
||||
));
|
||||
const activeSection = computed(() => sections.value.some(
|
||||
(section) => section.id === selectedSection.value,
|
||||
|
||||
@ -4,7 +4,7 @@ defineProps({
|
||||
sections: { type: Array, required: true },
|
||||
});
|
||||
|
||||
defineEmits(["select"]);
|
||||
defineEmits(["select", "settings"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -55,6 +55,18 @@ defineEmits(["select"]);
|
||||
<i v-if="activeSection === section.id" class="navigation-current" aria-hidden="true"></i>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="navigation-item"
|
||||
type="button"
|
||||
title="配置服务、证书签发和邮件通知"
|
||||
aria-haspopup="dialog"
|
||||
@click="$emit('settings')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M4 7h16M4 17h16M8 4v6M16 14v6" />
|
||||
</svg>
|
||||
<span>系统设置</span>
|
||||
</button>
|
||||
<p class="navigation-footer">SSL 证书管理</p>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
30
frontend/src/views/UserManagement/UserGroupEditor.js
Normal file
30
frontend/src/views/UserManagement/UserGroupEditor.js
Normal file
@ -0,0 +1,30 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function useUserGroupEditor(props, emit) {
|
||||
const groupName = ref("");
|
||||
const groupError = ref("");
|
||||
function addGroup() {
|
||||
const name = groupName.value.trim();
|
||||
groupError.value = "";
|
||||
if (!name || name === "默认分组" || [...name].length > 32 || /[\u0000-\u001f\u007f-\u009f]/u.test(name)) {
|
||||
groupError.value = "请输入 1 到 32 个字符的分组名称;默认分组无需添加";
|
||||
return;
|
||||
}
|
||||
const groups = [...new Set([...props.modelValue, name])];
|
||||
if (props.role === "user" && groups.length > 1) {
|
||||
groupError.value = "普通用户只能属于一个分组,请先移除原分组";
|
||||
return;
|
||||
}
|
||||
if (groups.length > 32) {
|
||||
groupError.value = "分组数量不能超过 32 个";
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", groups);
|
||||
groupName.value = "";
|
||||
}
|
||||
function removeGroup(name) {
|
||||
emit("update:modelValue", props.modelValue.filter((item) => item !== name));
|
||||
groupError.value = "";
|
||||
}
|
||||
return { groupName, groupError, addGroup, removeGroup };
|
||||
}
|
||||
46
frontend/src/views/UserManagement/UserGroupEditor.vue
Normal file
46
frontend/src/views/UserManagement/UserGroupEditor.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
import { useUserGroupEditor } from "./UserGroupEditor.js";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, required: true },
|
||||
availableGroups: { type: Array, default: () => [] },
|
||||
role: { type: String, required: true },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
const { groupName, groupError, addGroup, removeGroup } = useUserGroupEditor(props, emit);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="group-editor">
|
||||
<legend>
|
||||
所属分组
|
||||
<HelpTip>未选择时归入默认分组。管理员可属于多个分组,普通用户最多一个分组。分组名称区分大小写。</HelpTip>
|
||||
</legend>
|
||||
<div class="group-selection">
|
||||
<span v-if="!modelValue.length" class="user-badge">默认分组</span>
|
||||
<button v-for="name in modelValue" :key="name" type="button" :aria-label="`移除分组 ${name}`" @click="removeGroup(name)">
|
||||
{{ name }} ×
|
||||
</button>
|
||||
</div>
|
||||
<div class="group-input-row">
|
||||
<input
|
||||
v-model="groupName"
|
||||
type="text"
|
||||
list="user-group-options"
|
||||
aria-label="选择或新建分组"
|
||||
placeholder="选择或输入分组名称"
|
||||
maxlength="32"
|
||||
@keydown.enter.prevent="addGroup"
|
||||
/>
|
||||
<datalist id="user-group-options">
|
||||
<option v-for="name in availableGroups" :key="name" :value="name" />
|
||||
</datalist>
|
||||
<button type="button" class="secondary-button" @click="addGroup">添加到分组</button>
|
||||
</div>
|
||||
<p v-if="groupError" class="form-error" role="alert">{{ groupError }}</p>
|
||||
<p v-else-if="role === 'user' && modelValue.length > 1" class="form-error" role="alert">普通用户只能属于一个分组,请移除多余分组后保存。</p>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped src="./UserManagement.css"></style>
|
||||
@ -1,5 +1,79 @@
|
||||
.user-management {
|
||||
margin-top: 18px;
|
||||
padding: 24px;
|
||||
border: 1px solid #e2e8f2;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
.user-groups {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.user-group {
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f2;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.user-group-heading {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
color: #32405a;
|
||||
background: #f7faff;
|
||||
font-size: 14px;
|
||||
}
|
||||
summary.user-group-heading {
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-group-heading span {
|
||||
margin-left: 12px;
|
||||
color: #71809a;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.user-group-empty {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
color: #71809a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.group-editor {
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #d5ddea;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.group-editor legend {
|
||||
color: #394963;
|
||||
font-size: 13px;
|
||||
}
|
||||
.group-selection,
|
||||
.group-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.group-input-row input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d5ddea;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.group-selection button {
|
||||
border: 1px solid #d5ddea;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
color: #315aaa;
|
||||
background: #edf3ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.user-group-heading:focus-visible,
|
||||
.user-table-wrap:focus-visible {
|
||||
outline: 2px solid #315aaa;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.user-management-heading {
|
||||
display: flex;
|
||||
|
||||
@ -11,8 +11,27 @@ const statusLabels = {
|
||||
disabled: "已禁用",
|
||||
};
|
||||
|
||||
export function groupUsers(users) {
|
||||
const groups = new Map();
|
||||
const defaults = { name: "默认分组", isDefault: true, users: [] };
|
||||
for (const user of users) {
|
||||
const names = [...new Set(user.groups || [])];
|
||||
if (!names.length) defaults.users.push(user);
|
||||
for (const name of names) {
|
||||
if (!groups.has(name)) groups.set(name, { name, isDefault: false, users: [] });
|
||||
groups.get(name).users.push(user);
|
||||
}
|
||||
}
|
||||
return [defaults, ...[...groups.values()].sort((a, b) => a.name.localeCompare(b.name, "zh-CN"))];
|
||||
}
|
||||
|
||||
export function useUserManagement() {
|
||||
const visible = ref(false);
|
||||
const visible = ref(true);
|
||||
const currentRole = ref("");
|
||||
const canManage = computed(() => currentRole.value === "super_admin");
|
||||
const showGroups = computed(() => ["super_admin", "admin"].includes(currentRole.value));
|
||||
const groupedUsers = computed(() => groupUsers(users.value));
|
||||
const availableGroups = computed(() => groupedUsers.value.filter((group) => !group.isDefault).map((group) => group.name));
|
||||
const users = ref([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
@ -54,6 +73,7 @@ export function useUserManagement() {
|
||||
const result = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(result.error || "读取用户列表失败");
|
||||
users.value = result.items || [];
|
||||
currentRole.value = result.current_role || "";
|
||||
visible.value = true;
|
||||
} catch (err) {
|
||||
if (err.message !== "登录会话已过期,请重新登录") {
|
||||
@ -71,10 +91,12 @@ export function useUserManagement() {
|
||||
password: "",
|
||||
role: "user",
|
||||
status: "active",
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
if (!canManage.value) return;
|
||||
editingId.value = "";
|
||||
editingRole.value = "";
|
||||
form.value = createEmptyForm();
|
||||
@ -83,7 +105,7 @@ export function useUserManagement() {
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
if (isPrimaryAccount(item)) return;
|
||||
if (!canManage.value || isPrimaryAccount(item)) return;
|
||||
editingId.value = item.id;
|
||||
editingRole.value = item.role;
|
||||
form.value = {
|
||||
@ -92,6 +114,7 @@ export function useUserManagement() {
|
||||
password: "",
|
||||
role: item.role || "user",
|
||||
status: item.status || "active",
|
||||
groups: [...(item.groups || [])],
|
||||
};
|
||||
formError.value = "";
|
||||
showForm.value = true;
|
||||
@ -103,11 +126,19 @@ export function useUserManagement() {
|
||||
|
||||
async function saveUser() {
|
||||
formError.value = "";
|
||||
if (!canManage.value) {
|
||||
formError.value = "只有超级管理员可以修改用户";
|
||||
return;
|
||||
}
|
||||
if (editingId.value === "primary-super-admin") {
|
||||
formError.value = "主超级管理员不能通过用户管理修改";
|
||||
return;
|
||||
}
|
||||
const payload = { ...form.value };
|
||||
if (payload.role.trim().toLowerCase() === "user" && payload.groups.length > 1) {
|
||||
formError.value = "普通用户只能属于一个分组,请保留一个分组或清空为默认分组";
|
||||
return;
|
||||
}
|
||||
if (payload.role.trim().toLowerCase() === "super_admin" && editingRole.value !== "super_admin") {
|
||||
formError.value = "不能新增或提升为超级管理员";
|
||||
return;
|
||||
@ -143,7 +174,7 @@ export function useUserManagement() {
|
||||
}
|
||||
|
||||
async function toggleStatus(item) {
|
||||
if (isPrimaryAccount(item)) return;
|
||||
if (!canManage.value || isPrimaryAccount(item)) return;
|
||||
const nextStatus = item.status === "active" ? "disabled" : "active";
|
||||
if (item.role === "super_admin" && nextStatus === "disabled") {
|
||||
notice.value = "最后一个超级管理员不能被禁用";
|
||||
@ -169,6 +200,7 @@ export function useUserManagement() {
|
||||
}
|
||||
|
||||
async function deleteUser(item) {
|
||||
if (!canManage.value) return;
|
||||
if (isPrimaryAccount(item)) return;
|
||||
if (!window.confirm(`确定删除用户“${item.username}”吗?`)) return;
|
||||
try {
|
||||
@ -191,6 +223,11 @@ export function useUserManagement() {
|
||||
onMounted(loadUsers);
|
||||
|
||||
return {
|
||||
currentRole,
|
||||
canManage,
|
||||
showGroups,
|
||||
groupedUsers,
|
||||
availableGroups,
|
||||
visible,
|
||||
users,
|
||||
loading,
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { useUserManagement } from "./UserManagement.js";
|
||||
import { groupUsers, useUserManagement } from "./UserManagement.js";
|
||||
import { useUserGroupEditor } from "./UserGroupEditor.js";
|
||||
import { mountComposable } from "../../test-support/mount.js";
|
||||
|
||||
async function setup(t) {
|
||||
const calls = [];
|
||||
const mounted = await mountComposable(t, useUserManagement, async (path, options) => {
|
||||
calls.push({ path, options });
|
||||
return Response.json({ items: [] });
|
||||
return Response.json({ items: [], current_role: "super_admin" });
|
||||
});
|
||||
calls.length = 0;
|
||||
return { ...mounted, calls };
|
||||
@ -51,3 +52,89 @@ test("管理员和普通用户仍能正常创建及编辑", async (t) => {
|
||||
assert.deepEqual(writes.map(({ options }) => options.method), ["POST", "PUT", "POST", "PUT"]);
|
||||
assert.equal(state.formError.value, "");
|
||||
});
|
||||
|
||||
test("默认分组置顶,多组管理员同时出现在所属分组且不重复", () => {
|
||||
const members = [
|
||||
{ id: "multi", groups: ["乙组", "甲组", "甲组"] },
|
||||
{ id: "single", groups: ["甲组"] },
|
||||
{ id: "default" },
|
||||
];
|
||||
const result = groupUsers(members);
|
||||
assert.equal(result[0].isDefault, true);
|
||||
assert.deepEqual(result[0].users.map((user) => user.id), ["default"]);
|
||||
assert.equal(result.find((group) => group.name === "甲组").users.length, 2);
|
||||
assert.equal(result.find((group) => group.name === "乙组").users.length, 1);
|
||||
});
|
||||
|
||||
test("管理员只能读取成员,普通用户不显示分组,未知权限默认关闭写入", async (t) => {
|
||||
for (const role of ["admin", "user", ""]) {
|
||||
const writes = [];
|
||||
const { state } = await mountComposable(t, useUserManagement, async (path, options) => {
|
||||
if (options.method) writes.push(path);
|
||||
return Response.json({ items: [], current_role: role });
|
||||
});
|
||||
assert.equal(state.canManage.value, false);
|
||||
assert.equal(state.showGroups.value, role === "admin");
|
||||
state.openCreate();
|
||||
state.openEdit({ id: "member", role: "user" });
|
||||
assert.equal(state.showForm.value, false);
|
||||
await state.saveUser();
|
||||
await state.toggleStatus({ id: "member", status: "active" });
|
||||
await state.deleteUser({ id: "member" });
|
||||
assert.deepEqual(writes, []);
|
||||
}
|
||||
});
|
||||
|
||||
test("普通用户不允许多组,降级时保留选择供用户修正", async (t) => {
|
||||
const { state, calls } = await setup(t);
|
||||
state.openEdit({ id: "leader", username: "leader", role: "admin", groups: ["甲组", "乙组"] });
|
||||
state.form.value.role = "user";
|
||||
await state.saveUser();
|
||||
assert.match(state.formError.value, /只能属于一个分组/);
|
||||
assert.deepEqual(state.form.value.groups, ["甲组", "乙组"]);
|
||||
assert.equal(calls.length, 0);
|
||||
state.form.value.groups = ["乙组"];
|
||||
await state.saveUser();
|
||||
assert.deepEqual(JSON.parse(calls.find((call) => call.options.method === "PUT").options.body).groups, ["乙组"]);
|
||||
});
|
||||
|
||||
test("分组编辑器支持管理员多组并拒绝普通用户多组及保留名称", () => {
|
||||
const props = { modelValue: [], role: "admin" };
|
||||
const editor = useUserGroupEditor(props, (_, value) => { props.modelValue = value; });
|
||||
for (const name of [" 甲组 ", "乙组", "甲组"]) {
|
||||
editor.groupName.value = name;
|
||||
editor.addGroup();
|
||||
}
|
||||
assert.deepEqual(props.modelValue, ["甲组", "乙组"]);
|
||||
props.role = "user";
|
||||
editor.groupName.value = "丙组";
|
||||
editor.addGroup();
|
||||
assert.match(editor.groupError.value, /只能属于一个分组/);
|
||||
editor.removeGroup("乙组");
|
||||
assert.deepEqual(props.modelValue, ["甲组"]);
|
||||
editor.groupName.value = "默认分组";
|
||||
editor.addGroup();
|
||||
assert.match(editor.groupError.value, /默认分组无需添加/);
|
||||
});
|
||||
|
||||
test("分组页面结构可编译,默认组不折叠,命名组默认关闭,密码入口仅在用户管理", async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { parse, compileScript, compileTemplate } = await import("@vue/compiler-sfc");
|
||||
for (const file of ["UserManagement.vue", "UserTable.vue", "UserGroupEditor.vue", "UserWorkspace.vue"]) {
|
||||
const source = await readFile(new URL(file, import.meta.url), "utf8");
|
||||
const { descriptor, errors } = parse(source);
|
||||
assert.deepEqual(errors, []);
|
||||
assert.ok(compileScript(descriptor, { id: file }));
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename: file, id: file }).errors, []);
|
||||
}
|
||||
const source = await readFile(new URL("UserManagement.vue", import.meta.url), "utf8");
|
||||
assert.match(source, /section v-if="group.isDefault"/);
|
||||
assert.match(source, /<details v-else class="user-group">/);
|
||||
assert.match(source, /v-else-if="showGroups"/);
|
||||
assert.match(source, /<ChangePassword/);
|
||||
const dashboard = await readFile(new URL("../Dashboard/Dashboard.vue", import.meta.url), "utf8");
|
||||
const navigation = await readFile(new URL("../Dashboard/DashboardNavigation.vue", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(dashboard, /<ChangePassword/);
|
||||
assert.doesNotMatch(dashboard, /@click="openSettings"/);
|
||||
assert.match(navigation, /\$emit\('settings'\)/);
|
||||
});
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
<script setup>
|
||||
import HelpTip from "../components/HelpTip/HelpTip.vue";
|
||||
import { useUserManagement } from "./UserManagement.js";
|
||||
import ChangePassword from "../components/ChangePassword/ChangePassword.vue";
|
||||
import UserTable from "./UserTable.vue";
|
||||
import UserGroupEditor from "./UserGroupEditor.vue";
|
||||
|
||||
const {
|
||||
canManage,
|
||||
showGroups,
|
||||
groupedUsers,
|
||||
availableGroups,
|
||||
visible,
|
||||
users,
|
||||
loading,
|
||||
@ -38,10 +45,11 @@ const {
|
||||
</HelpTip>
|
||||
</div>
|
||||
<div class="user-management-actions">
|
||||
<ChangePassword />
|
||||
<button class="secondary-button" type="button" :disabled="loading" @click="loadUsers">
|
||||
{{ loading ? "读取中……" : "刷新列表" }}
|
||||
</button>
|
||||
<button class="primary-button" type="button" @click="openCreate">添加用户</button>
|
||||
<button v-if="canManage" class="primary-button" type="button" @click="openCreate">添加用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -49,42 +57,37 @@ const {
|
||||
<p v-if="notice" class="user-message notice-message">{{ notice }}</p>
|
||||
|
||||
<div v-if="loading && users.length === 0" class="user-empty">正在读取用户列表……</div>
|
||||
<div v-else-if="users.length === 0" class="user-empty">暂无数据库用户,配置中的超级管理员仍可登录。</div>
|
||||
<div v-else class="user-table-wrap">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>账号</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in users" :key="item.id">
|
||||
<td><strong>{{ item.username }}</strong></td>
|
||||
<td>{{ item.email || "未绑定" }}</td>
|
||||
<td><span class="user-badge">{{ roleLabels[item.role] || item.role }}</span></td>
|
||||
<td><span class="user-status" :class="item.status">{{ statusLabels[item.status] || item.status }}</span></td>
|
||||
<td>{{ new Date(item.created_at).toLocaleString("zh-CN") }}</td>
|
||||
<td>
|
||||
<span v-if="isPrimaryAccount(item)" class="user-badge">受保护账号</span>
|
||||
<div v-else class="user-row-actions">
|
||||
<button class="text-button" type="button" @click="openEdit(item)">编辑</button>
|
||||
<button class="text-button" type="button" @click="toggleStatus(item)">
|
||||
{{ item.status === "active" ? "禁用" : "启用" }}
|
||||
</button>
|
||||
<button v-if="item.role !== 'super_admin'" class="text-button danger-text" type="button" @click="deleteUser(item)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else-if="showGroups" class="user-groups">
|
||||
<template v-for="group in groupedUsers" :key="group.name">
|
||||
<section v-if="group.isDefault" class="user-group default-group" aria-label="默认分组">
|
||||
<h3 class="user-group-heading">默认分组 <span>{{ group.users.length }} 人</span></h3>
|
||||
<UserTable
|
||||
:users="group.users"
|
||||
:can-manage="canManage"
|
||||
:role-labels="roleLabels"
|
||||
:status-labels="statusLabels"
|
||||
@edit="openEdit"
|
||||
@toggle="toggleStatus"
|
||||
@delete="deleteUser"
|
||||
/>
|
||||
</section>
|
||||
<details v-else class="user-group">
|
||||
<summary class="user-group-heading">{{ group.name }} <span>{{ group.users.length }} 人</span></summary>
|
||||
<UserTable
|
||||
:users="group.users"
|
||||
:can-manage="canManage"
|
||||
:role-labels="roleLabels"
|
||||
:status-labels="statusLabels"
|
||||
@edit="openEdit"
|
||||
@toggle="toggleStatus"
|
||||
@delete="deleteUser"
|
||||
/>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
<UserTable v-else :users="users" :role-labels="roleLabels" :status-labels="statusLabels" />
|
||||
|
||||
<div v-if="showForm" class="modal-backdrop" @click.self="closeForm">
|
||||
<div v-if="showForm && canManage" class="modal-backdrop" @click.self="closeForm">
|
||||
<section class="modal-card user-modal" role="dialog" aria-modal="true" :aria-label="formTitle">
|
||||
<div class="modal-heading">
|
||||
<div>
|
||||
@ -125,6 +128,7 @@ const {
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<UserGroupEditor v-model="form.groups" :role="form.role" :available-groups="availableGroups" />
|
||||
<p v-if="formError" class="form-error">{{ formError }}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-button" type="button" :disabled="saving" @click="closeForm">取消</button>
|
||||
|
||||
48
frontend/src/views/UserManagement/UserTable.vue
Normal file
48
frontend/src/views/UserManagement/UserTable.vue
Normal file
@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
users: { type: Array, required: true },
|
||||
canManage: { type: Boolean, default: false },
|
||||
roleLabels: { type: Object, required: true },
|
||||
statusLabels: { type: Object, required: true },
|
||||
});
|
||||
defineEmits(["edit", "toggle", "delete"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p v-if="!users.length" class="user-group-empty">暂无成员</p>
|
||||
<div v-else class="user-table-wrap" tabindex="0" aria-label="用户列表">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">账号</th>
|
||||
<th scope="col">邮箱</th>
|
||||
<th scope="col">角色</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">创建时间</th>
|
||||
<th v-if="canManage" scope="col">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in users" :key="item.id">
|
||||
<td><strong>{{ item.username }}</strong></td>
|
||||
<td>{{ item.email || "未绑定" }}</td>
|
||||
<td><span class="user-badge">{{ roleLabels[item.role] || item.role }}</span></td>
|
||||
<td><span class="user-status" :class="item.status">{{ statusLabels[item.status] || item.status }}</span></td>
|
||||
<td>{{ new Date(item.created_at).toLocaleString("zh-CN") }}</td>
|
||||
<td v-if="canManage">
|
||||
<span v-if="item.id === 'primary-super-admin'" class="user-badge">受保护账号</span>
|
||||
<div v-else class="user-row-actions">
|
||||
<button class="text-button" type="button" @click="$emit('edit', item)">编辑</button>
|
||||
<button class="text-button" type="button" @click="$emit('toggle', item)">
|
||||
{{ item.status === "active" ? "禁用" : "启用" }}
|
||||
</button>
|
||||
<button v-if="item.role !== 'super_admin'" class="text-button danger-text" type="button" @click="$emit('delete', item)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./UserManagement.css"></style>
|
||||
26
frontend/src/views/UserManagement/UserWorkspace.css
Normal file
26
frontend/src/views/UserManagement/UserWorkspace.css
Normal file
@ -0,0 +1,26 @@
|
||||
.user-workspace {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.user-workspace header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-workspace h1 {
|
||||
color: #25344d;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.user-workspace button {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #d5ddea;
|
||||
border-radius: 10px;
|
||||
color: #32405a;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
21
frontend/src/views/UserManagement/UserWorkspace.vue
Normal file
21
frontend/src/views/UserManagement/UserWorkspace.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import UserManagement from "./UserManagement.vue";
|
||||
import { useAccessDenied } from "../AccessDenied/AccessDenied.js";
|
||||
|
||||
const { loggingOut, error, logout } = useAccessDenied();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="user-workspace">
|
||||
<header>
|
||||
<h1>Scert 用户中心</h1>
|
||||
<button type="button" :disabled="loggingOut" @click="logout">
|
||||
{{ loggingOut ? "正在退出……" : "退出登录" }}
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<UserManagement />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped src="./UserWorkspace.css"></style>
|
||||
Loading…
Reference in New Issue
Block a user