This commit is contained in:
parent
de8bcc682a
commit
920a2103cd
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
60
test/frontend/user-management.test.mjs
Normal file
60
test/frontend/user-management.test.mjs
Normal 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\)/)
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user