优化用户权限与安全状态展示
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-08 11:03:08 +08:00
parent de8bcc682a
commit 920a2103cd
6 changed files with 329 additions and 14 deletions

View File

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

View File

@ -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(&regularUser, "UserPass123"); err != nil {
t.Fatal(err)
}
if _, err := db.Instance.Insert(&legacyAdmin, &regularUser); 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()

View File

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

View File

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

View File

@ -7,22 +7,58 @@
<div class="table-container">
<el-table :data="userList" class="modern-table" style="width: 100%" empty-text="暂无用户">
<el-table-column label="ID" prop="ID" width="80" />
<el-table-column :label="lang.account" prop="Account" min-width="150" show-overflow-tooltip />
<el-table-column :label="lang.user_name" prop="Name" min-width="120" show-overflow-tooltip />
<el-table-column :label="lang.disabled" prop="Disabled" width="120">
<el-table-column label="ID" prop="ID" width="44" />
<el-table-column :label="lang.account" prop="Account" width="88" show-overflow-tooltip />
<el-table-column :label="lang.user_name" prop="Name" width="76" show-overflow-tooltip />
<el-table-column label="权限级别" width="100">
<template #default="scope">
<el-tag
:type="scope.row.Disabled === 1 ? 'info' : 'success'"
:type="roleMeta(scope.row.Role).type"
size="small"
effect="light"
class="status-tag role-tag"
>
{{ roleMeta(scope.row.Role).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="安全码" width="72">
<template #default="scope">
<el-tag
:type="recoveryCodeMeta(scope.row.RecoveryCodeConfigured).type"
size="small"
effect="plain"
class="status-tag"
>
{{ scope.row.Disabled === 1 ? lang.disabled : lang.enabled }}
{{ recoveryCodeMeta(scope.row.RecoveryCodeConfigured).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="right" width="100">
<el-table-column label="外部邮箱" width="76">
<template #default="scope">
<el-tag
:type="externalEmailMeta(scope.row.ExternalEmailEnabled, scope.row.ExternalEmailConfigured).type"
size="small"
effect="plain"
class="status-tag"
>
{{ externalEmailMeta(scope.row.ExternalEmailEnabled, scope.row.ExternalEmailConfigured).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="账号状态" width="72">
<template #default="scope">
<el-tag
:type="accountStatusMeta(scope.row.Disabled).type"
size="small"
effect="plain"
class="status-tag"
>
{{ accountStatusMeta(scope.row.Disabled).label }}
</el-tag>
</template>
</el-table-column>
<el-table-column align="right" width="68">
<template #header>
<el-button type="primary" size="small" @click="createUser" class="new-btn" plain>
<el-icon><Plus /></el-icon> 新建
@ -47,7 +83,7 @@
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
small
size="small"
background
layout="prev, pager, next"
:page-count="totalPage"
@ -115,7 +151,11 @@ const {
title,
submit,
editModel,
editUserInfo
editUserInfo,
roleMeta,
recoveryCodeMeta,
externalEmailMeta,
accountStatusMeta
} = usePage();
</script>

View File

@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { compileFunction } from 'node:vm'
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
const root = new URL('../../frontend/src/views/components/UserManagement/', import.meta.url)
const read = path => readFileSync(new URL(path, root), 'utf8')
function loadStatusHelpers() {
const source = read('UserManagement.js')
.replace(/^import .*$/gm, '')
.replace(/export \{[\s\S]*?\}\s*$/, '')
.replaceAll('export ', '')
return compileFunction(`${source}\nreturn { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta }`)()
}
test('用户管理状态映射使用明确的中文文案', () => {
const { roleMeta, recoveryCodeMeta, externalEmailMeta, accountStatusMeta } = loadStatusHelpers()
assert.equal(roleMeta('super_admin').label, '超级管理员')
assert.equal(roleMeta('admin').label, '管理员')
assert.equal(roleMeta('user').label, '普通用户')
assert.equal(roleMeta('').label, '未知权限')
assert.equal(recoveryCodeMeta(true).label, '已开启')
assert.equal(recoveryCodeMeta(false).label, '未开启')
assert.equal(externalEmailMeta(true, true).label, '已绑定')
assert.equal(externalEmailMeta(true, false).label, '未绑定')
assert.equal(externalEmailMeta(false, true).label, '已关闭')
assert.equal(accountStatusMeta(0).label, '正常')
assert.equal(accountStatusMeta(1).label, '已禁用')
})
test('用户管理页面可编译并展示权限与安全状态列', () => {
const source = read('UserManagement.vue')
const { descriptor, errors } = parse(source)
assert.deepEqual(errors, [])
assert.doesNotThrow(() => compileScript(descriptor, { id: 'user-management-test', inlineTemplate: true }))
for (const label of ['权限级别', '安全码', '外部邮箱', '账号状态']) {
assert.match(source, new RegExp(`label="${label}"`))
}
assert.match(source, /scope\.row\.Role/)
assert.match(source, /scope\.row\.RecoveryCodeConfigured/)
assert.match(source, /scope\.row\.ExternalEmailEnabled/)
assert.match(source, /scope\.row\.ExternalEmailConfigured/)
})
test('用户管理样式可编译并保持紧凑表格布局', () => {
const { code, errors } = compileStyle({
source: read('UserManagement.css'),
id: 'data-v-user-management-test',
scoped: true
})
assert.deepEqual(errors, [])
assert.match(code, /\.status-tag/)
assert.match(code, /\.role-tag/)
assert.match(code, /white-space:\s*nowrap/)
assert.match(code, /@container settings-content \(max-width: 760px\)/)
})