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:
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user