diff --git a/backend/controllers/user.go b/backend/controllers/user.go index 9dbb25f..45defdd 100644 --- a/backend/controllers/user.go +++ b/backend/controllers/user.go @@ -106,6 +106,50 @@ type userListRequest struct { PageSize int `json:"page_size"` } +type userListItem struct { + ID int `json:"ID"` + Account string `json:"Account"` + Name string `json:"Name"` + Disabled int `json:"Disabled"` + Role access.Role `json:"Role"` + IsAdmin int `json:"IsAdmin"` + MustChangePassword int `json:"must_change_password"` + RecoveryCodeConfigured bool `json:"RecoveryCodeConfigured"` + ExternalEmailEnabled bool `json:"ExternalEmailEnabled"` + ExternalEmailConfigured bool `json:"ExternalEmailConfigured"` +} + +func loadUserSecurityStatus(users []models.User) (map[int]bool, map[int]bool, error) { + recoveryConfigured := make(map[int]bool, len(users)) + externalEmailConfigured := make(map[int]bool, len(users)) + if len(users) == 0 { + return recoveryConfigured, externalEmailConfigured, nil + } + + userIDs := make([]int, 0, len(users)) + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + + var recoveryCodes []models.RecoveryCode + if err := db.Instance.Cols("user_id").In("user_id", userIDs).Where("used_at IS NULL").Find(&recoveryCodes); err != nil { + return nil, nil, err + } + for _, recoveryCode := range recoveryCodes { + recoveryConfigured[recoveryCode.UserID] = true + } + + var externalEmails []models.ExternalEmail + if err := db.Instance.Cols("user_id").In("user_id", userIDs).Where("status = ?", models.ExternalEmailStatusVerified).Find(&externalEmails); err != nil { + return nil, nil, err + } + for _, externalEmail := range externalEmails { + externalEmailConfigured[externalEmail.UserID] = true + } + + return recoveryConfigured, externalEmailConfigured, nil +} + func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { if err := authorizeCapability(ctx, access.CapabilityUserRead, "", false); err != nil { response.NewErrorResponse(response.NoAccessPrivileges, "\u65e0\u6743\u8bbf\u95ee", "").FPrint(w) @@ -131,10 +175,31 @@ func UserList(ctx *context.Context, w http.ResponseWriter, req *http.Request) { response.NewErrorResponse(response.ServerError, "查询失败", "").FPrint(w) return } + recoveryConfigured, externalEmailConfigured, err := loadUserSecurityStatus(users) + if err != nil { + log.WithContext(ctx).WithError(err).Error("load user security status failed") + response.NewErrorResponse(response.ServerError, "读取用户安全状态失败", "").FPrint(w) + return + } + items := make([]userListItem, 0, len(users)) + for _, user := range users { + items = append(items, userListItem{ + ID: user.ID, + Account: user.Account, + Name: user.Name, + Disabled: user.Disabled, + Role: user.EffectiveRole(), + IsAdmin: user.IsAdmin, + MustChangePassword: user.MustChangePassword, + RecoveryCodeConfigured: recoveryConfigured[user.ID], + ExternalEmailEnabled: user.ExternalEmailEnabled == 1, + ExternalEmailConfigured: externalEmailConfigured[user.ID], + }) + } response.NewSuccessResponse(map[string]any{ "current_page": reqData.CurrentPage, "total_page": cast.ToInt(math.Ceil(cast.ToFloat64(totalNum) / cast.ToFloat64(reqData.PageSize))), - "list": users, + "list": items, }).FPrint(w) } diff --git a/backend/controllers/user_security_test.go b/backend/controllers/user_security_test.go index 65e5398..db56998 100644 --- a/backend/controllers/user_security_test.go +++ b/backend/controllers/user_security_test.go @@ -7,10 +7,12 @@ import ( "path/filepath" "strings" "testing" + "time" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/db" "git.pcux.cn/xuans/Smail/models" + "git.pcux.cn/xuans/Smail/services/access" "git.pcux.cn/xuans/Smail/services/auth" appcontext "git.pcux.cn/xuans/Smail/utils/context" "git.pcux.cn/xuans/Smail/utils/password" @@ -90,6 +92,81 @@ func TestEditUserProtectsAdministrativeCredentialsAndRevokesUserSessions(t *test } } +func TestUserListReturnsNormalizedRolesAndSecurityStatus(t *testing.T) { + oldConfig := config.Instance + oldDB := db.Instance + testRoot := t.TempDir() + t.Cleanup(func() { + if db.Instance != nil && db.Instance != oldDB { + _ = db.Instance.Close() + } + config.Instance = oldConfig + db.Instance = oldDB + }) + + config.Instance = &config.Config{DbType: config.DBTypeSQLite, DbDSN: filepath.Join(testRoot, "user-list.db")} + if err := db.Init(""); err != nil { + t.Fatal(err) + } + legacyAdmin := models.User{Account: "legacy-admin", Name: "Legacy", IsAdmin: 1, ExternalEmailEnabled: 1, SessionVersion: 1, SecurityVersion: 1} + regularUser := models.User{Account: "regular-list", Name: "Regular", Role: string(access.RoleUser), ExternalEmailEnabled: 0, SessionVersion: 1, SecurityVersion: 1} + if err := auth.SetPassword(&legacyAdmin, "AdminPass123"); err != nil { + t.Fatal(err) + } + if err := auth.SetPassword(®ularUser, "UserPass123"); err != nil { + t.Fatal(err) + } + if _, err := db.Instance.Insert(&legacyAdmin, ®ularUser); err != nil { + t.Fatal(err) + } + usedAt := time.Now().UTC() + fixtures := []any{ + &models.RecoveryCode{UserID: legacyAdmin.ID, CodeHash: "active", CodeVersion: 1}, + &models.RecoveryCode{UserID: regularUser.ID, CodeHash: "used", CodeVersion: 1, UsedAt: &usedAt}, + &models.ExternalEmail{UserID: legacyAdmin.ID, EmailCiphertext: "ciphertext-verified", EmailFingerprint: "verified-email", EmailType: models.ExternalEmailTypePrimary, Status: models.ExternalEmailStatusVerified}, + &models.ExternalEmail{UserID: regularUser.ID, EmailCiphertext: "ciphertext-pending", EmailFingerprint: "pending-email", EmailType: models.ExternalEmailTypePrimary, Status: models.ExternalEmailStatusPending}, + } + for _, fixture := range fixtures { + if _, err := db.Instance.Insert(fixture); err != nil { + t.Fatal(err) + } + } + + ctx := &appcontext.Context{UserID: legacyAdmin.ID, UserAccount: legacyAdmin.Account, IsAdmin: true, Lang: "zhCn"} + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/user/list", strings.NewReader(`{"current_page":1,"page_size":15}`)) + UserList(ctx, recorder, request) + var result struct { + ErrorNo int `json:"errorNo"` + Data struct { + List []userListItem `json:"list"` + } `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if result.ErrorNo != 0 || len(result.Data.List) != 2 { + t.Fatalf("unexpected user list response: %s", recorder.Body.String()) + } + items := make(map[string]userListItem, len(result.Data.List)) + for _, item := range result.Data.List { + items[item.Account] = item + } + adminItem := items[legacyAdmin.Account] + if adminItem.Role != access.RoleSuperAdmin || !adminItem.RecoveryCodeConfigured || !adminItem.ExternalEmailEnabled || !adminItem.ExternalEmailConfigured { + t.Fatalf("legacy administrator status was not normalized: %+v", adminItem) + } + regularItem := items[regularUser.Account] + if regularItem.Role != access.RoleUser || regularItem.RecoveryCodeConfigured || regularItem.ExternalEmailEnabled || regularItem.ExternalEmailConfigured { + t.Fatalf("regular user status was incorrect: %+v", regularItem) + } + for _, secret := range []string{"AdminPass123", "UserPass123", "ciphertext-verified", "ciphertext-pending", "active", "used"} { + if strings.Contains(recorder.Body.String(), secret) { + t.Fatalf("user list exposed sensitive value %q", secret) + } + } +} + func editUserRequest(t *testing.T, ctx *appcontext.Context, payload string) int { t.Helper() recorder := httptest.NewRecorder() diff --git a/frontend/src/views/components/UserManagement/UserManagement.css b/frontend/src/views/components/UserManagement/UserManagement.css index 2343599..0e02131 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.css +++ b/frontend/src/views/components/UserManagement/UserManagement.css @@ -23,7 +23,12 @@ border: 1px solid var(--pm-border-color); border-radius: var(--pm-radius-sm); overflow: hidden; - margin-bottom: 24px; + margin-bottom: 16px; +} + +.modern-table { + --el-table-header-bg-color: var(--pm-bg-secondary); + --el-table-row-hover-bg-color: color-mix(in srgb, var(--el-color-primary) 6%, transparent); } .modern-table :deep(th.el-table__cell) { @@ -32,25 +37,58 @@ font-weight: 600; } +.modern-table :deep(.el-table__cell) { + padding: 10px 0; +} + +.modern-table :deep(.cell) { + padding: 0 6px; + white-space: nowrap; +} + +.modern-table :deep(th.el-table__cell .cell) { + font-size: 12px; +} + .status-tag { border-radius: var(--pm-radius-sm); + min-width: 48px; + justify-content: center; + padding: 0 5px; + font-size: 11px; + font-weight: 500; +} + +.role-tag { + min-width: 72px; } .new-btn { border-radius: var(--pm-radius-sm); + padding: 5px 7px; } .action-btn { border-radius: var(--pm-radius-sm); - padding: 4px 12px; + padding: 4px 8px; } .pagination-wrapper { - margin-top: 20px; + margin-top: 16px; display: flex; justify-content: center; } +@container settings-content (max-width: 760px) { + .settings-header { + margin-bottom: 16px; + } + + .modern-table :deep(.el-table__cell) { + padding: 8px 0; + } +} + /* Dialog Styles */ .premium-dialog :deep(.el-dialog__header) { border-bottom: 1px solid var(--pm-border-color); diff --git a/frontend/src/views/components/UserManagement/UserManagement.js b/frontend/src/views/components/UserManagement/UserManagement.js index e4677ec..1db8b21 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.js +++ b/frontend/src/views/components/UserManagement/UserManagement.js @@ -8,6 +8,37 @@ import {ElNotification} from "element-plus"; import {Plus} from "@element-plus/icons-vue"; +const roleOptions = { + super_admin: { label: '超级管理员', type: 'danger' }, + admin: { label: '管理员', type: 'primary' }, + user: { label: '普通用户', type: 'info' } +} + +export function roleMeta(role) { + return roleOptions[role] || { label: '未知权限', type: 'warning' } +} + +export function recoveryCodeMeta(configured) { + return configured + ? { label: '已开启', type: 'success' } + : { label: '未开启', type: 'info' } +} + +export function externalEmailMeta(enabled, configured) { + if (!enabled) { + return { label: '已关闭', type: 'warning' } + } + return configured + ? { label: '已绑定', type: 'success' } + : { label: '未绑定', type: 'info' } +} + +export function accountStatusMeta(disabled) { + return disabled === 1 + ? { label: '已禁用', type: 'danger' } + : { label: '正常', type: 'success' } +} + export function usePage() { const userList = reactive([]) @@ -121,7 +152,11 @@ export function usePage() { reflushList, handleEdit, createUser, - submit + submit, + roleMeta, + recoveryCodeMeta, + externalEmailMeta, + accountStatusMeta } } diff --git a/frontend/src/views/components/UserManagement/UserManagement.vue b/frontend/src/views/components/UserManagement/UserManagement.vue index b3602e9..3597f3f 100644 --- a/frontend/src/views/components/UserManagement/UserManagement.vue +++ b/frontend/src/views/components/UserManagement/UserManagement.vue @@ -7,22 +7,58 @@