账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。 - schema:users 加 role(默认 reader),新表 user_identities、passkeys。 user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份, (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。 extern_uid 存平台稳定 ID,不存用户名(用户名可改)。 - 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入, 避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页, 一个字段只留一个编辑入口。 - OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后 直接发 one_session,读者身份仍发 one_reader。 - passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。 必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。 签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。 - 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey 也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置 入口的坑,从设计上绕开。 已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的 7 天后台会话失效 —— 要修得加一张吊销表。
This commit is contained in:
1 parent
7e302c51a6
commit
f1e639e0ba
30 files changed
+2523
-105
No files matched your search
@@ -0,0 +1,362 @@
|
||||
<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)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
acct.value = await adminApi.account()
|
||||
} 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">
|
||||
由环境变量 <code>ONE_ADMIN_USER</code> 决定,不在这里改 ——
|
||||
它是账号的身份锚点,改了要连密码一起换。
|
||||
</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="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>
|
||||
Reference in new issue
Block a user