- 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)
409 lines
13 KiB
Vue
409 lines
13 KiB
Vue
<script setup>
|
||
import { onMounted, ref } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { adminApi } from '../api'
|
||
import { toastOk, toastErr } from './toast'
|
||
import { isSupported as waSupported, register as waRegister } from '../webauthn'
|
||
|
||
// 账户页:我是谁(头像 / 昵称 / 简介)+ 我能用什么方式登录
|
||
// (密码走环境变量、第三方身份绑定、passkey)。
|
||
//
|
||
// 和「设置」的分工是刻意的:站点的事在设置,账号的事在这里。
|
||
// 作者昵称/简介以前在设置里,已移过来 —— 同一字段两处编辑迟早漂移。
|
||
|
||
const acct = ref(null)
|
||
const loading = ref(true)
|
||
const saving = ref(false)
|
||
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 {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 绑定完成是从 provider 整页跳回来的,用 query 带平台名:
|
||
// 提示一次就把参数抹掉,免得刷新页面重复弹。
|
||
async function reportBindResult() {
|
||
const p = route.query.bound
|
||
if (typeof p !== 'string' || !p) return
|
||
toastOk(p === 'github' ? '已绑定 GitHub' : '已绑定 ' + p)
|
||
router.replace({ path: route.path })
|
||
}
|
||
|
||
async function save(patch) {
|
||
saving.value = true
|
||
try {
|
||
acct.value = await adminApi.saveAccount(patch)
|
||
toastOk('已保存')
|
||
} catch (e) {
|
||
toastErr(e.message || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function onPickAvatar(ev) {
|
||
const file = ev.target.files && ev.target.files[0]
|
||
ev.target.value = '' // 允许连续选同一个文件
|
||
if (!file) return
|
||
uploading.value = true
|
||
try {
|
||
const data = await adminApi.uploadFile(file)
|
||
const f = Array.isArray(data) ? data[0] : null
|
||
if (!f || !f.key) throw new Error('上传失败')
|
||
await save({ avatar_key: f.key })
|
||
} catch (e) {
|
||
toastErr(e.message || '头像上传失败')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
async function removeAvatar() {
|
||
await save({ avatar_key: '' })
|
||
}
|
||
|
||
function startBind(provider) {
|
||
// 整页跳转:OAuth 回来后由后端重定向回本页并带 ?bound=
|
||
window.location.assign('/api/auth/' + provider + '/bind')
|
||
}
|
||
|
||
async function unbind(provider) {
|
||
const label = provider === 'github' ? 'GitHub' : provider
|
||
if (!window.confirm('解绑 ' + label + '?之后就不能再用它登录后台了。')) return
|
||
try {
|
||
await adminApi.unbindIdentity(provider)
|
||
toastOk('已解绑')
|
||
await load()
|
||
} catch (e) {
|
||
toastErr(e.message || '解绑失败')
|
||
}
|
||
}
|
||
|
||
async function addPasskey() {
|
||
if (!waSupported()) {
|
||
toastErr('这个浏览器不支持 passkey')
|
||
return
|
||
}
|
||
const name = window.prompt('给这把凭据起个名字(如「MacBook 指纹」)', '')
|
||
if (name === null) return
|
||
regBusy.value = true
|
||
try {
|
||
const { options, token } = await adminApi.passkeyBegin()
|
||
const cred = await waRegister(options)
|
||
await adminApi.passkeyFinish({ token, name: name.trim(), credential: cred })
|
||
toastOk('passkey 已添加')
|
||
await load()
|
||
} catch (e) {
|
||
toastErr(e.message || 'passkey 注册失败')
|
||
} finally {
|
||
regBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function removePasskey(pk) {
|
||
if (!window.confirm('删除 passkey「' + (pk.name || '未命名') + '」?')) return
|
||
try {
|
||
await adminApi.deletePasskey(pk.id)
|
||
toastOk('已删除')
|
||
await load()
|
||
} catch (e) {
|
||
toastErr(e.message || '删除失败')
|
||
}
|
||
}
|
||
|
||
function fmtDate(s) {
|
||
if (!s) return ''
|
||
const d = new Date(s)
|
||
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
const providerLabel = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
|
||
|
||
onMounted(async () => {
|
||
await load()
|
||
reportBindResult()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section v-if="loading" class="loading">载入中…</section>
|
||
<section v-else-if="acct">
|
||
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 4px;">账户</h1>
|
||
<p class="field-hint" style="margin-bottom: 20px;">
|
||
头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
|
||
<router-link to="/admin/settings">设置</router-link>。
|
||
</p>
|
||
|
||
<!-- ---------- 资料 ---------- -->
|
||
<div class="panel">
|
||
<div class="panel-title"><h2>公开资料</h2></div>
|
||
|
||
<div class="field">
|
||
<label>头像</label>
|
||
<div class="avatar-row">
|
||
<span class="avatar-box">
|
||
<img v-if="acct.avatar_url" :src="acct.avatar_url" alt="" />
|
||
<span v-else class="avatar-ph">无</span>
|
||
</span>
|
||
<div class="avatar-acts">
|
||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif" hidden @change="onPickAvatar" />
|
||
<button class="btn" :disabled="uploading" @click="fileInput.click()">
|
||
{{ uploading ? '上传中…' : (acct.avatar_key ? '换一个' : '上传头像') }}
|
||
</button>
|
||
<button v-if="acct.avatar_key" class="btn" :disabled="saving" @click="removeAvatar">移除</button>
|
||
</div>
|
||
</div>
|
||
<p class="field-hint">
|
||
头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>昵称</label>
|
||
<input v-model="acct.name" class="input" maxlength="40" spellcheck="false" />
|
||
<p class="field-hint">评论区与文章署名用的名字。</p>
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>简介</label>
|
||
<textarea v-model="acct.bio" class="textarea" rows="2" maxlength="200" />
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label>用户名(handle)</label>
|
||
<input :value="acct.handle" class="input" disabled />
|
||
<p class="field-hint">
|
||
后台账号的用户名即登录名:站主的在「设置 → 登录与存储」里改,
|
||
管理员账号由站主在「用户」页创建。
|
||
</p>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" :disabled="saving" @click="save({ name: acct.name, bio: acct.bio })">
|
||
{{ saving ? '保存中…' : '保存资料' }}
|
||
</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>
|
||
|
||
<div class="method">
|
||
<div class="method-head">
|
||
<b>密码</b>
|
||
<span class="tag on">始终可用</span>
|
||
</div>
|
||
<p class="field-hint">
|
||
站主密码由环境变量 <code>ONE_ADMIN_PASSWORD</code> 管理,不存在数据库里,
|
||
因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
|
||
删光所有 passkey,你仍然能登录进来。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="method">
|
||
<div class="method-head">
|
||
<b>第三方账号</b>
|
||
<span v-if="acct.identities.length" class="tag on">已绑定 {{ acct.identities.length }}</span>
|
||
<span v-else class="tag">未绑定</span>
|
||
</div>
|
||
<ul v-if="acct.identities.length" class="list">
|
||
<li v-for="it in acct.identities" :key="it.id">
|
||
<span>{{ providerLabel[it.provider] || it.provider }}</span>
|
||
<code>{{ it.display }}</code>
|
||
<span class="when">绑于 {{ fmtDate(it.created_at) }}</span>
|
||
<button class="btn btn-sm" @click="unbind(it.provider)">解绑</button>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="field-hint">还没有绑定任何第三方账号。</p>
|
||
<div v-if="acct.providers.length" class="bind-acts">
|
||
<button
|
||
v-for="p in acct.providers"
|
||
:key="p"
|
||
class="btn btn-sm"
|
||
:disabled="acct.identities.some((i) => i.provider === p)"
|
||
@click="startBind(p)"
|
||
>
|
||
绑定 {{ providerLabel[p] || p }}
|
||
</button>
|
||
</div>
|
||
<p class="field-hint">
|
||
绑定后,用该账号 OAuth 登录会<b>直接进入后台</b>(等于站主身份)。
|
||
所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
|
||
</p>
|
||
</div>
|
||
|
||
<div class="method">
|
||
<div class="method-head">
|
||
<b>Passkey</b>
|
||
<span v-if="acct.passkeys.length" class="tag on">{{ acct.passkeys.length }} 把</span>
|
||
<span v-else class="tag">未添加</span>
|
||
</div>
|
||
<ul v-if="acct.passkeys.length" class="list">
|
||
<li v-for="pk in acct.passkeys" :key="pk.id">
|
||
<span>{{ pk.name || '未命名设备' }}</span>
|
||
<span class="when">
|
||
加于 {{ fmtDate(pk.created_at) }}<template v-if="pk.last_used_at"> · 上次用 {{ fmtDate(pk.last_used_at) }}</template>
|
||
</span>
|
||
<button class="btn btn-sm" @click="removePasskey(pk)">删除</button>
|
||
</li>
|
||
</ul>
|
||
<button class="btn btn-sm" :disabled="regBusy" @click="addPasskey">
|
||
{{ regBusy ? '等待浏览器…' : '添加 passkey' }}
|
||
</button>
|
||
<p class="field-hint">
|
||
用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
|
||
<template v-if="!acct.passkeys.length && !acct.providers.length">
|
||
服务端需配置 <code>ONE_WEBAUTHN_ORIGINS</code> 才会开放此入口。
|
||
</template>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.avatar-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.avatar-box {
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
background: var(--admin-sunken, #f3efe6);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: none;
|
||
}
|
||
.avatar-box img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.avatar-ph {
|
||
font-size: 12px;
|
||
color: var(--admin-muted);
|
||
}
|
||
.avatar-acts {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.method {
|
||
padding: 14px 0;
|
||
border-top: 1px solid var(--admin-line, #e6e0d4);
|
||
}
|
||
.method:first-of-type {
|
||
border-top: 0;
|
||
padding-top: 0;
|
||
}
|
||
.method-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.tag {
|
||
font-size: 11px;
|
||
padding: 1px 7px;
|
||
border-radius: 999px;
|
||
border: 1px solid var(--admin-line, #e6e0d4);
|
||
color: var(--admin-muted);
|
||
}
|
||
.tag.on {
|
||
border-color: var(--accent-line, rgba(61, 127, 156, 0.35));
|
||
color: var(--admin-accent, #3d7f9c);
|
||
}
|
||
.list {
|
||
list-style: none;
|
||
margin: 0 0 10px;
|
||
padding: 0;
|
||
}
|
||
.list li {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 0;
|
||
font-size: 13.5px;
|
||
}
|
||
.list code {
|
||
font-size: 12px;
|
||
color: var(--admin-muted);
|
||
}
|
||
.when {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--admin-muted);
|
||
}
|
||
.bind-acts {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin: 8px 0;
|
||
}
|
||
.btn-sm {
|
||
padding: 4px 12px;
|
||
font-size: 12.5px;
|
||
}
|
||
</style>
|