Add multi-admin account management

Schema:
- New AdminUser model with bcrypt-hashed password (cost 10)
- Roles: admin (full) / operator (read-only ops)
- Status: active / disabled
- MustChangePassword flag forces first-login password change

Backend:
- store: Add admins [] + CRUD methods (ListAdmins strips PasswordHash)
- service: SeedDefaultAdminIfEmpty (uses env credentials on first run),
  CreateAdmin, ChangePassword, ResetPassword, SetAdminStatus, DeleteAdmin
- middleware: JWT now carries user_id (UUID)
- api: login() uses bcrypt + updates last_login_at/ip, blocks disabled
- api: me() returns role + must_change_password
- api: new endpoints:
    POST /api/me/password       (self password change)
    GET  /api/admins
    POST /api/admins             (create)
    POST /api/admins/:id/password (reset by admin)
    POST /api/admins/:id/status   (enable/disable)
    DELETE /api/admins/:id        (with self/last-admin guard)

Frontend:
- Login: must_change_password=true triggers forced change-password dialog
- Layout: admin dropdown shows role tag + 修改密码 / 退出登录
- New /admins page (admin only) with table + create/reset/status/delete
- Router guard hides /admins from non-admin accounts
- API client: Auth.changePassword, Admins.{list,create,resetPassword,setStatus,delete}

Security:
- PasswordHash stored as bcrypt $2a$10$... in db.json
- ListAdmins always returns PasswordHash=''; never leaks via API
- Login returns 403 for disabled accounts

Verified: 21/21 API tests + browser E2E (first-login forced change,
restart persistence, admin list without hash, role-based menu)
This commit is contained in:
cnbugs
2026-08-09 21:45:41 +08:00
parent 4c8b7b5188
commit 09f6918aeb
12 changed files with 778 additions and 23 deletions
+8
View File
@@ -29,6 +29,14 @@ export const Auth = {
login: (username, password) => api.post('/login', { username, password }).then(r => r.data),
me: () => api.get('/me').then(r => r.data),
logout: () => api.post('/logout').then(r => r.data),
changePassword: (old_password, new_password) => api.post('/me/password', { old_password, new_password }).then(r => r.data),
}
export const Admins = {
list: () => api.get('/admins').then(r => r.data),
create: (username, password, role) => api.post('/admins', { username, password, role }).then(r => r.data),
resetPassword: (id, new_password) => api.post(`/admins/${id}/password`, { new_password }).then(r => r.data),
setStatus: (id, status) => api.post(`/admins/${id}/status`, { status }).then(r => r.data),
delete: id => api.delete(`/admins/${id}`).then(r => r.data),
}
export const Dash = {
get: () => api.get('/dashboard').then(r => r.data),
+54 -3
View File
@@ -12,6 +12,7 @@
<el-menu-item index="/logs"><el-icon><Document /></el-icon><span>连接日志</span></el-menu-item>
<el-menu-item index="/backups"><el-icon><FolderOpened /></el-icon><span>备份与恢复</span></el-menu-item>
<el-menu-item index="/audits"><el-icon><Tickets /></el-icon><span>审计日志</span></el-menu-item>
<el-menu-item v-if="isAdmin" index="/admins"><el-icon><Avatar /></el-icon><span>管理员</span></el-menu-item>
</el-menu>
</el-aside>
<el-container>
@@ -22,11 +23,12 @@
<div>
<el-dropdown @command="cmd">
<span style="color:#fff;cursor:pointer">
<el-icon><UserFilled /></el-icon> {{ user }} <el-icon><ArrowDown /></el-icon>
<el-icon><UserFilled /></el-icon> {{ user }} <el-tag v-if="role" size="small" :type="role==='admin'?'danger':'info'" style="margin-left:6px;vertical-align:middle">{{ role }}</el-tag> <el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
<el-dropdown-item command="password"><el-icon><Lock /></el-icon>修改密码</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -36,10 +38,23 @@
<router-view />
</el-main>
</el-container>
<!-- 修改密码弹窗 -->
<el-dialog v-model="pwdDlg" title="修改密码" width="440px">
<el-form :model="form" label-width="100px">
<el-form-item label="当前密码"><el-input v-model="form.old_password" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.new_password" type="password" show-password placeholder="至少 6 位" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="form.confirm" type="password" show-password /></el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDlg=false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submitPwd">保存</el-button>
</template>
</el-dialog>
</el-container>
</template>
<script setup>
import { ref } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Auth } from '@/api'
import { ElMessage } from 'element-plus'
@@ -47,13 +62,49 @@ import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const user = ref(localStorage.getItem('username') || 'admin')
const role = ref(localStorage.getItem('role') || '')
const isAdmin = ref(role.value === 'admin')
// 启动时拉一次 me 以拿到最新 role/must_change_password
onMounted(async () => {
try {
const m = await Auth.me()
if (m.role) { role.value = m.role; localStorage.setItem('role', m.role); isAdmin.value = m.role === 'admin' }
if (m.username) { user.value = m.username; localStorage.setItem('username', m.username) }
} catch (e) {}
})
const pwdDlg = ref(false)
const loading = ref(false)
const form = reactive({ old_password: '', new_password: '', confirm: '' })
async function cmd(c) {
if (c === 'logout') {
await Auth.logout()
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('role')
ElMessage.success('已退出登录')
router.replace('/login')
} else if (c === 'password') {
form.old_password = ''
form.new_password = ''
form.confirm = ''
pwdDlg.value = true
}
}
async function submitPwd() {
if (!form.old_password || !form.new_password) return ElMessage.warning('请填写完整')
if (form.new_password !== form.confirm) return ElMessage.error('两次输入的新密码不一致')
if (form.new_password.length < 6) return ElMessage.warning('新密码至少 6 位')
loading.value = true
try {
await Auth.changePassword(form.old_password, form.new_password)
ElMessage.success('密码已修改')
pwdDlg.value = false
} finally {
loading.value = false
}
}
</script>
+8
View File
@@ -1,4 +1,5 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import Login from '@/views/Login.vue'
import Layout from '@/layout/Index.vue'
import Dashboard from '@/views/Dashboard.vue'
@@ -8,6 +9,7 @@ import Certificates from '@/views/Certificates.vue'
import Logs from '@/views/Logs.vue'
import Backups from '@/views/Backups.vue'
import Audits from '@/views/Audits.vue'
import Admins from '@/views/Admins.vue'
const router = createRouter({
history: createWebHashHistory(),
@@ -24,6 +26,7 @@ const router = createRouter({
{ path: 'logs', component: Logs, meta: { title: '连接日志' } },
{ path: 'backups', component: Backups, meta: { title: '备份与恢复' } },
{ path: 'audits', component: Audits, meta: { title: '审计日志' } },
{ path: 'admins', component: Admins, meta: { title: '管理员', adminOnly: true } },
]
}
]
@@ -32,6 +35,11 @@ const router = createRouter({
router.beforeEach((to, from, next) => {
const t = localStorage.getItem('token')
if (!t && to.path !== '/login') return next('/login')
// 限制 admin only 页面
if (to.meta?.adminOnly && localStorage.getItem('role') !== 'admin') {
ElMessage.warning('需要 admin 权限')
return next(from.path === '/login' ? '/dashboard' : from)
}
next()
})
+146
View File
@@ -0,0 +1,146 @@
<template>
<div>
<h2 style="margin-top:0">管理员账号</h2>
<p class="muted"> role=admin 的账号可以管理其他账号可创建 operator(只读运维) admin(全部权限)</p>
<div style="margin-bottom:12px">
<el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新建账号</el-button>
<el-button @click="load"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<el-table :data="list" stripe>
<el-table-column prop="username" label="用户名" />
<el-table-column label="角色" width="100">
<template #default="{row}">
<el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{row}">
<el-tag :type="row.status==='active'?'success':'info'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="强制改密" width="100">
<template #default="{row}">
<el-tag v-if="row.must_change_password" type="warning" size="small"></el-tag>
<span v-else class="muted"></span>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最近登录" width="180">
<template #default="{row}">{{ row.last_login_at?.slice(0,19) || '-' }}</template>
</el-table-column>
<el-table-column prop="last_login_ip" label="登录 IP" width="140" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="{row}">{{ row.created_at?.slice(0,19) }}</template>
</el-table-column>
<el-table-column label="操作" width="280">
<template #default="{row}">
<el-button size="small" @click="openResetPwd(row)">重置密码</el-button>
<el-button v-if="row.status==='active'" size="small" type="warning" @click="setStatus(row,'disabled')">禁用</el-button>
<el-button v-else size="small" type="success" @click="setStatus(row,'active')">启用</el-button>
<el-popconfirm title="确定删除此账号?删除后该账号无法再登录。" @confirm="del(row)">
<template #reference><el-button size="small" type="danger">删除</el-button></template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 新建账号 -->
<el-dialog v-model="createDlg" title="新建管理员账号" width="480px">
<el-form :model="cform" label-width="100px">
<el-form-item label="用户名"><el-input v-model="cform.username" placeholder="≥3 字符"/></el-form-item>
<el-form-item label="密码"><el-input v-model="cform.password" type="password" show-password placeholder="≥6 位"/></el-form-item>
<el-form-item label="角色">
<el-select v-model="cform.role" style="width:200px">
<el-option label="admin(全部权限)" value="admin"/>
<el-option label="operator(运维)" value="operator"/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDlg=false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<!-- 重置密码 -->
<el-dialog v-model="resetDlg" title="重置密码" width="440px">
<p class="muted">为账号 <b>{{ target?.username }}</b> 设置新密码,新密码会立即生效</p>
<el-form :model="rform" label-width="100px">
<el-form-item label="新密码"><el-input v-model="rform.new_password" type="password" show-password placeholder="≥6 位"/></el-form-item>
<el-form-item label="确认"><el-input v-model="rform.confirm" type="password" show-password /></el-form-item>
</el-form>
<template #footer>
<el-button @click="resetDlg=false">取消</el-button>
<el-button type="primary" :loading="loading" @click="submitReset">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Admins } from '@/api'
const list = ref([])
const loading = ref(false)
async function load() { list.value = await Admins.list() }
onMounted(load)
// 新建
const createDlg = ref(false)
const cform = reactive({ username:'', password:'', role:'operator' })
function openCreate() {
cform.username = ''; cform.password = ''; cform.role = 'operator'
createDlg.value = true
}
async function submitCreate() {
if (!cform.username || !cform.password) return ElMessage.warning('请填写完整')
loading.value = true
try {
await Admins.create(cform.username, cform.password, cform.role)
ElMessage.success('已创建')
createDlg.value = false
load()
} finally { loading.value = false }
}
// 重置密码
const resetDlg = ref(false)
const target = ref(null)
const rform = reactive({ new_password:'', confirm:'' })
function openResetPwd(row) {
target.value = row
rform.new_password = ''; rform.confirm = ''
resetDlg.value = true
}
async function submitReset() {
if (!rform.new_password) return ElMessage.warning('请填写新密码')
if (rform.new_password !== rform.confirm) return ElMessage.error('两次输入不一致')
if (rform.new_password.length < 6) return ElMessage.warning('至少 6 位')
loading.value = true
try {
await Admins.resetPassword(target.value.id, rform.new_password)
ElMessage.success('已重置')
resetDlg.value = false
load()
} finally { loading.value = false }
}
// 启停
async function setStatus(row, status) {
await Admins.setStatus(row.id, status)
ElMessage.success(status === 'active' ? '已启用' : '已禁用')
load()
}
// 删除
async function del(row) {
try {
await Admins.delete(row.id)
ElMessage.success('已删除')
load()
} catch (e) {}
}
</script>
+41 -1
View File
@@ -12,8 +12,21 @@
</el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form>
<p class="muted">首次部署默认账号 admin / admin123请在系统中修改</p>
<p class="muted">首次部署默认账号 admin / admin123,登录后会强制要求改密</p>
</div>
<!-- 首次登录强制改密 -->
<el-dialog v-model="mustChange" title="请修改初始密码" width="440px" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<p class="muted" style="margin-top:0">为安全起见,首次登录必须修改默认密码后才能使用其他功能</p>
<el-form :model="cp" label-width="100px">
<el-form-item label="当前密码"><el-input v-model="cp.old_password" type="password" show-password /></el-form-item>
<el-form-item label="新密码"><el-input v-model="cp.new_password" type="password" show-password placeholder="至少 6 位" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="cp.confirm" type="password" show-password /></el-form-item>
</el-form>
<template #footer>
<el-button type="primary" :loading="cpLoading" @click="submitChange">提交</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
@@ -26,17 +39,44 @@ const router = useRouter()
const form = reactive({ username: 'admin', password: 'admin123' })
const loading = ref(false)
const mustChange = ref(false)
const cpLoading = ref(false)
const cp = reactive({ old_password: '', new_password: '', confirm: '' })
async function submit() {
loading.value = true
try {
const r = await Auth.login(form.username, form.password)
localStorage.setItem('token', r.token)
if (r.must_change_password) {
// 把当前密码作为旧密码预填,方便用户
cp.old_password = form.password
cp.new_password = ''
cp.confirm = ''
mustChange.value = true
return
}
ElMessage.success('登录成功')
router.replace('/dashboard')
} finally {
loading.value = false
}
}
async function submitChange() {
if (!cp.old_password || !cp.new_password) return ElMessage.warning('请填写完整')
if (cp.new_password !== cp.confirm) return ElMessage.error('两次输入的新密码不一致')
if (cp.new_password.length < 6) return ElMessage.warning('新密码至少 6 位')
cpLoading.value = true
try {
await Auth.changePassword(cp.old_password, cp.new_password)
ElMessage.success('密码已修改')
mustChange.value = false
router.replace('/dashboard')
} finally {
cpLoading.value = false
}
}
</script>
<style scoped>
.login-page { min-height:100vh; background:linear-gradient(135deg,#1d4ed8,#0ea5e9); display:flex; align-items:center; justify-content:center; }