多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
This commit is contained in:
1 parent
e5dee4daf1
commit
4bb2ff4145
23 files changed
+917
-222
No files matched your search
@@ -18,13 +18,39 @@ const uploading = ref(false)
|
||||
const regBusy = ref(false)
|
||||
const fileInput = ref(null)
|
||||
|
||||
// 修改登录密码(owner / admin 都在这里改;密码存在自己的账号行上)
|
||||
const pw = ref({ old: '', next: '', confirm: '' })
|
||||
const pwBusy = ref(false)
|
||||
async function changePassword() {
|
||||
if (pw.value.next !== pw.value.confirm) {
|
||||
toastErr('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
if (pw.value.next.length < 6) {
|
||||
toastErr('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
pwBusy.value = true
|
||||
try {
|
||||
await adminApi.changePassword({ old_password: pw.value.old, new_password: pw.value.next })
|
||||
toastOk('密码已更新,下次登录用新密码')
|
||||
pw.value = { old: '', next: '', confirm: '' }
|
||||
} catch (e) {
|
||||
toastErr(e.message || '修改失败')
|
||||
} finally {
|
||||
pwBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function load() {
|
||||
console.warn('ACCT: load() start')
|
||||
loading.value = true
|
||||
try {
|
||||
acct.value = await adminApi.account()
|
||||
console.warn('ACCT: loaded', !!acct.value)
|
||||
} catch (e) {
|
||||
toastErr(e.message || '读取账户信息失败')
|
||||
} finally {
|
||||
@@ -185,8 +211,8 @@ onMounted(async () => {
|
||||
<label>用户名(handle)</label>
|
||||
<input :value="acct.handle" class="input" disabled />
|
||||
<p class="field-hint">
|
||||
登录用户名在「设置 → 登录与存储」里改;这里是站主在评论区的
|
||||
身份标识(handle),改它要连登录名一起换。
|
||||
后台账号的用户名即登录名:站主的在「设置 → 登录与存储」里改,
|
||||
管理员账号由站主在「用户」页创建。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -195,6 +221,26 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ---------- 登录密码 ---------- -->
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>登录密码</h2></div>
|
||||
<div class="field">
|
||||
<label>旧密码</label>
|
||||
<input v-model="pw.old" class="input" type="password" autocomplete="current-password" placeholder="从未设过密码可不填" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>新密码(6-72 位)</label>
|
||||
<input v-model="pw.next" class="input" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认新密码</label>
|
||||
<input v-model="pw.confirm" class="input" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
<button class="btn btn-primary" :disabled="pwBusy" @click="changePassword">
|
||||
{{ pwBusy ? '提交中…' : '更新密码' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ---------- 登录方式 ---------- -->
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
|
||||
@@ -63,6 +63,7 @@ const crumb = computed(() => {
|
||||
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
|
||||
if (p.endsWith('/files')) return '文件'
|
||||
if (p.endsWith('/comments')) return '评论'
|
||||
if (p.endsWith('/users')) return '用户'
|
||||
if (p.endsWith('/tags')) return '标签'
|
||||
if (p.endsWith('/settings')) return '设置'
|
||||
if (p === '/admin' || p === '/admin/') return '总览'
|
||||
@@ -150,6 +151,11 @@ watch(() => route.path, () => {
|
||||
<span class="ic">◉</span>
|
||||
<span>账户</span>
|
||||
</RouterLink>
|
||||
<!-- 用户管理是站主专属:内容管理员(admin)看不到这个入口 -->
|
||||
<RouterLink v-if="session.isOwner" to="/admin/users" class="item">
|
||||
<span class="ic">◍</span>
|
||||
<span>用户</span>
|
||||
</RouterLink>
|
||||
<div class="group">前台</div>
|
||||
<a href="/" target="_blank" class="item">
|
||||
<span class="ic">↗</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ async function submit() {
|
||||
try {
|
||||
const data = await adminApi.login(username.value, password.value)
|
||||
session.user = username.value
|
||||
session.role = data.role || 'admin'
|
||||
void data
|
||||
toastOk('登录成功')
|
||||
router.push('/admin')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi } from '../api'
|
||||
import { adminApi, session } from '../api'
|
||||
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
|
||||
import { SECTIONS } from '../ui/sections'
|
||||
|
||||
@@ -47,9 +47,6 @@ function secretPlaceholder(key) {
|
||||
return '未配置'
|
||||
}
|
||||
|
||||
// 管理员账号:新密码 + 确认,只在前端做一次一致性与长度预检
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
|
||||
// ---------- 社交链接(settings.social_links <-> 多行文本) ----------
|
||||
// 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。
|
||||
@@ -179,8 +176,6 @@ async function load() {
|
||||
const res = await adminApi.settings()
|
||||
settings.value = res.settings
|
||||
credMeta.value = res.credential_meta || {}
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
secrets.value = {}
|
||||
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
|
||||
settings.value.light_skin_id = 'paper'
|
||||
@@ -211,24 +206,12 @@ onMounted(load)
|
||||
|
||||
async function save() {
|
||||
if (!settings.value) return
|
||||
// 管理员改密码:两次输入一致才提交
|
||||
if (newPassword.value || confirmPassword.value) {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
toastErr('两次输入的新密码不一致')
|
||||
return
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
toastErr('新密码至少 6 位')
|
||||
return
|
||||
}
|
||||
}
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
settings.value.social_links = textToSocial(socialText.value)
|
||||
// 秘密项走 secrets 单独通道(留空的键不会提交,服务端按「保持现值」处理)
|
||||
const payload = { ...settings.value, secrets: {} }
|
||||
if (newPassword.value) payload.secrets.admin_password = newPassword.value
|
||||
for (const f of secretFields) {
|
||||
const v = (secrets.value[f.key] || '').trim()
|
||||
if (v) payload.secrets[f.key] = v
|
||||
@@ -247,8 +230,6 @@ async function save() {
|
||||
}
|
||||
}
|
||||
secrets.value = {}
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
toastOk('设置已保存')
|
||||
} catch (e) {
|
||||
@@ -272,7 +253,7 @@ async function save() {
|
||||
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
|
||||
<button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button>
|
||||
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
|
||||
<button :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
|
||||
<button v-if="session.isOwner" :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -493,8 +474,8 @@ async function save() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项) -->
|
||||
<div v-if="tab === 'system'" class="panel">
|
||||
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项)。站主专属 -->
|
||||
<div v-if="tab === 'system' && session.isOwner" class="panel">
|
||||
<div class="panel-title"><h2>登录方式</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
评论区读者的第三方登录。凭据齐全的登录方式自动开放,清空即关闭。
|
||||
@@ -569,25 +550,15 @@ async function save() {
|
||||
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
|
||||
<div class="panel-title"><h2>管理员账号</h2></div>
|
||||
<p class="field-hint" style="margin: 0 0 14px;">
|
||||
登录后台用的用户名与密码。在这里设置密码后,环境变量
|
||||
ONE_ADMIN_PASSWORD 只作解锁后路保留(仍可用);不填则维持现状。
|
||||
站主登录后台的用户名(改掉后环境变量 ONE_ADMIN_USER 不再生效)。
|
||||
密码在「账户」页修改;给其他人开账号去「用户」页。
|
||||
</p>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<div class="field" style="padding: 0; border: 0;">
|
||||
<label>用户名</label>
|
||||
<input v-model="settings.admin_username" class="input" spellcheck="false"
|
||||
:placeholder="srcOf('admin_username') === 'env' ? '当前来自环境变量 ONE_ADMIN_USER' : ''" />
|
||||
<p class="field-hint">{{ srcHint('admin_username') }}</p>
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
|
||||
<label>新密码</label>
|
||||
<input v-model="newPassword" class="input" type="password"
|
||||
autocomplete="new-password" placeholder="留空保持不变(6-72 位)" />
|
||||
</div>
|
||||
<div class="field" style="padding: 0; border: 0;">
|
||||
<label>确认新密码</label>
|
||||
<input v-model="confirmPassword" class="input" type="password"
|
||||
autocomplete="new-password" placeholder="再输入一遍" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { toastOk, toastErr } from './toast'
|
||||
import { adminApi, session } from '../api'
|
||||
import { formatDateShort } from '../utils'
|
||||
|
||||
// 用户管理(站主专属):创建内容管理员(admin 角色)、重置密码、停用 / 删除。
|
||||
// owner 行(站主本人)只读展示——它是「谁登录算站主」的锚点,密码走账户页改。
|
||||
|
||||
const users = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const creating = ref(false)
|
||||
const form = ref({ username: '', password: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await adminApi.users()
|
||||
users.value = data.users || []
|
||||
} catch (e) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
async function create() {
|
||||
if (creating.value) return
|
||||
creating.value = true
|
||||
try {
|
||||
await adminApi.createUser({ username: form.value.username.trim(), password: form.value.password })
|
||||
toastOk(`已创建 ${form.value.username.trim()}`)
|
||||
form.value = { username: '', password: '' }
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(u) {
|
||||
const pw = window.prompt(`给「${u.handle}」设置新密码(6-72 位):`)
|
||||
if (!pw) return
|
||||
try {
|
||||
await adminApi.patchUser(u.id, { password: pw })
|
||||
toastOk('密码已重置')
|
||||
} catch (e) {
|
||||
toastErr(e.message || '重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBan(u) {
|
||||
if (u.banned) {
|
||||
try {
|
||||
await adminApi.patchUser(u.id, { banned: false })
|
||||
toastOk(`已恢复「${u.handle}」`)
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '操作失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`停用「${u.handle}」?停用后其立即无法登录,存量会话同步失效。`)) return
|
||||
try {
|
||||
await adminApi.patchUser(u.id, { banned: true })
|
||||
toastOk('已停用')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(u) {
|
||||
if (!window.confirm(`删除「${u.handle}」?其发布的评论保留,但无法再登录。`)) return
|
||||
try {
|
||||
await adminApi.deleteUser(u.id)
|
||||
toastOk('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toastErr(e.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const roleLabel = (r) => (r === 'owner' ? '站主' : '管理员')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="loading" class="loading">载入中…</section>
|
||||
<section v-else-if="error" class="empty">{{ error }}</section>
|
||||
<section v-else>
|
||||
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">用户</h1>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-title"><h2>添加内容管理员</h2></div>
|
||||
<form style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;" @submit.prevent="create">
|
||||
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
|
||||
<label>用户名(2-32 位,字母 / 数字 / _ - . @)</label>
|
||||
<input v-model="form.username" class="input" spellcheck="false" autocomplete="off" placeholder="editor" />
|
||||
</div>
|
||||
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
|
||||
<label>初始密码(6-72 位)</label>
|
||||
<input v-model="form.password" class="input" type="password" autocomplete="new-password" placeholder="至少 6 位" />
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit" :disabled="creating">添加</button>
|
||||
</form>
|
||||
<p class="field-hint" style="margin-top: 10px;">
|
||||
管理员可以写文章、管理评论与文件;系统设置、用户管理与第三方凭据只有你能动。
|
||||
他们自己的密码在「账户」页修改。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-top: 18px;">
|
||||
<div class="panel-title"><h2>后台账号</h2></div>
|
||||
<div class="recent-list">
|
||||
<div v-for="u in users" :key="u.id" class="recent-item" style="gap: 14px;">
|
||||
<span class="title" style="font-weight: 700;">{{ u.handle }}</span>
|
||||
<span class="meta" style="font-size: 12px;">{{ roleLabel(u.role) }}</span>
|
||||
<span v-if="u.banned" class="meta" style="color: #b4553f;">已停用</span>
|
||||
<span class="meta" style="margin-left: auto; white-space: normal; text-align: right;">
|
||||
{{ formatDateShort(u.created_at) }}
|
||||
</span>
|
||||
<span v-if="u.role !== 'owner'" style="display: flex; gap: 4px; flex: none;">
|
||||
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="resetPassword(u)">重置密码</button>
|
||||
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="toggleBan(u)">
|
||||
{{ u.banned ? '恢复' : '停用' }}
|
||||
</button>
|
||||
<button v-if="u.id !== 0" class="btn danger" style="font-size: 12px; padding: 4px 10px;" type="button" @click="remove(u)">删除</button>
|
||||
</span>
|
||||
<span v-else class="meta" style="flex: none;">站主本人 · 密码在账户页修改</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-hint" style="margin-top: 12px;">
|
||||
当前登录:{{ session.user }}({{ roleLabel(session.role) }})。
|
||||
停用或删除后,对方的存量登录会话立即失效。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn.danger {color: #b4553f;}
|
||||
</style>
|
||||
@@ -1,9 +1,12 @@
|
||||
import { adminApi, session } from '../api'
|
||||
|
||||
// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效
|
||||
// 后台的登录态在服务端(httpOnly cookie);这里探测一次是否还有效,
|
||||
// 顺带把用户名与角色(owner / admin)存下来,供导航显隐用
|
||||
export async function checkAuth() {
|
||||
try {
|
||||
await adminApi.me()
|
||||
const me = await adminApi.me()
|
||||
session.user = me.user || ''
|
||||
session.role = me.role || 'admin'
|
||||
return true
|
||||
} catch (e) {
|
||||
session.clear()
|
||||
|
||||
+19
-1
@@ -83,6 +83,11 @@ export const adminApi = {
|
||||
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
|
||||
settings: () => request('/api/admin/settings'),
|
||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
|
||||
users: () => request('/api/admin/users'),
|
||||
createUser: (body) => request('/api/admin/users', { method: 'POST', body }),
|
||||
patchUser: (id, body) => request('/api/admin/users/' + id, { method: 'PATCH', body }),
|
||||
deleteUser: (id) => request('/api/admin/users/' + id, { method: 'DELETE' }),
|
||||
changePassword: (body) => request('/api/admin/account/password', { method: 'PATCH', body }),
|
||||
// ---------- 账户(资料 / 身份绑定 / passkey) ----------
|
||||
account: () => request('/api/admin/account'),
|
||||
saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }),
|
||||
@@ -186,8 +191,10 @@ export const readerApi = {
|
||||
remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
|
||||
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存展示用的用户名与角色
|
||||
// (owner / admin,来自 /api/admin/me,控制「用户管理」等入口的显隐)
|
||||
const USER_KEY = 'one.admin.user'
|
||||
const ROLE_KEY = 'one.admin.role'
|
||||
|
||||
export const session = {
|
||||
get user() {
|
||||
@@ -197,7 +204,18 @@ export const session = {
|
||||
if (v) localStorage.setItem(USER_KEY, v)
|
||||
else localStorage.removeItem(USER_KEY)
|
||||
},
|
||||
get role() {
|
||||
return localStorage.getItem(ROLE_KEY) || 'admin'
|
||||
},
|
||||
set role(v) {
|
||||
if (v) localStorage.setItem(ROLE_KEY, v)
|
||||
else localStorage.removeItem(ROLE_KEY)
|
||||
},
|
||||
get isOwner() {
|
||||
return this.role === 'owner'
|
||||
},
|
||||
clear() {
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ const routes = [
|
||||
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
|
||||
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
|
||||
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') },
|
||||
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }
|
||||
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') },
|
||||
{ path: 'users', name: 'admin-users', component: () => import('./admin/UsersView.vue') }
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user