调整系统管理入口并实现用户分组与角色可见范围

This commit is contained in:
xuans 2026-09-25 22:15:52 +08:00
parent c91aab659b
commit 5bcada2ac8
28 changed files with 915 additions and 72 deletions

View File

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

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN groups_json TEXT;

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN groups_json TEXT;

View File

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN groups_json TEXT;

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

View File

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

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

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

View File

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

View File

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

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

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

View File

@ -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
View 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 提供迁移文件,但本次专项测试不代表这两种数据库已完成真实环境升级验收。

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -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'\)/);
});

View File

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

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

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

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