Files
openteam/web/src/views/console/SettingsView.vue
T
SakurasanandClaude 9324a782d5 Passkey: 账户设置绑定 + 免密登录(WebAuthn)
- 引入 go-webauthn, Passkey 表存凭据, challenge 会话内存存储(带过期)
- API: /webauthn/register|login begin/complete, /webauthn/passkeys 列表/删除
- 配置 OT_WEBAUTHN_RP_ID/RP_ORIGIN/RP_NAME;登录成功发 JWT+refresh cookie
- 前端 lib/webauthn(编解码+凭据序列化+安全上下文检测), 账户设置绑定区, 登录页免密按钮
- 需 HTTPS 或 localhost(安全上下文)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 02:00:08 +08:00

156 lines
5.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { PhFingerprint } from '@phosphor-icons/vue'
import { http, errMsg } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { fmtMoney, fmtTime } from '@/lib/format'
import { registerPasskey, isWebAuthnSupported } from '@/lib/webauthn'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue'
const auth = useAuthStore()
const toast = useToastStore()
const passkeys = ref<{ id: number; name: string; created_at: string }[]>([])
const binding = ref(false)
async function loadPasskeys() {
try {
const { data } = await http.get('/webauthn/passkeys')
passkeys.value = data.data.items
} catch {
/* 忽略 */
}
}
async function bindPasskey() {
if (!isWebAuthnSupported()) {
toast.err('当前环境不支持 Passkey(需 HTTPS 或 localhost)')
return
}
binding.value = true
try {
const { data } = await http.post('/webauthn/register/begin')
const credential = await registerPasskey(data.data.creation)
await http.post('/webauthn/register/complete', {
challenge: data.data.challenge,
name: 'passkey',
credential,
})
toast.ok('Passkey 已绑定')
await loadPasskeys()
} catch (e) {
toast.err(errMsg(e))
} finally {
binding.value = false
}
}
async function removePasskey(id: number) {
if (!confirm('解除该 Passkey?解除后需重新绑定才能免密登录。')) return
try {
await http.delete(`/webauthn/passkeys/${id}`)
toast.ok('已解除')
await loadPasskeys()
} catch (e) {
toast.err(errMsg(e))
}
}
onMounted(loadPasskeys)
const oldPwd = ref('')
const newPwd = ref('')
const confirmPwd = ref('')
const saving = ref(false)
async function changePassword() {
if (newPwd.value.length < 8) {
toast.err('新密码至少 8 位')
return
}
if (newPwd.value !== confirmPwd.value) {
toast.err('两次输入的新密码不一致')
return
}
saving.value = true
try {
await http.post('/auth/password', { old_password: oldPwd.value, new_password: newPwd.value })
toast.ok('密码已更新')
oldPwd.value = newPwd.value = confirmPwd.value = ''
} catch (e) {
toast.err(errMsg(e))
} finally {
saving.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<div class="mb-2">
<h1 class="text-lg font-semibold">账户设置</h1>
<p class="text-sm text-muted">个人资料与安全</p>
</div>
<div class="card p-5">
<h2 class="mb-4 text-sm font-semibold">个人资料</h2>
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 text-sm">
<div>
<dt class="text-xs text-muted">用户名</dt>
<dd class="mt-1 text-ink">{{ auth.user?.username }}</dd>
</div>
<div>
<dt class="text-xs text-muted">邮箱</dt>
<dd class="mt-1 font-mono text-xs text-ink">{{ auth.user?.email }}</dd>
</div>
<div>
<dt class="text-xs text-muted">角色</dt>
<dd class="mt-1"><Badge :variant="auth.isAdmin ? 'accent' : 'neutral'">{{ auth.user?.role }}</Badge></dd>
</div>
<div>
<dt class="text-xs text-muted">余额</dt>
<dd class="mono-num mt-1 text-accent">{{ fmtMoney(auth.user?.balance ?? 0) }}</dd>
</div>
<div>
<dt class="text-xs text-muted">注册时间</dt>
<dd class="mono-num mt-1 text-xs text-muted">{{ fmtTime(auth.user?.created_at) }}</dd>
</div>
</dl>
</div>
<div class="card p-5">
<h2 class="mb-4 text-sm font-semibold">修改密码</h2>
<div class="max-w-sm space-y-4">
<Input v-model="oldPwd" label="当前密码" type="password" autocomplete="current-password" />
<Input v-model="newPwd" label="新密码" type="password" autocomplete="new-password" hint="至少 8 位" />
<Input v-model="confirmPwd" label="确认新密码" type="password" autocomplete="new-password" />
<Button :loading="saving" @click="changePassword">更新密码</Button>
</div>
</div>
<div class="card p-5">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold">Passkey 登录</h2>
<Button size="sm" :loading="binding" @click="bindPasskey">
<PhFingerprint :size="14" />
绑定 Passkey
</Button>
</div>
<p class="mb-3 text-xs text-muted">用生物识别或系统 PIN 免密登录。需要 HTTPS 或 localhost 环境。</p>
<ul v-if="passkeys.length" class="divide-y divide-edge">
<li v-for="pk in passkeys" :key="pk.id" class="flex items-center justify-between py-2">
<div>
<p class="text-sm text-ink">{{ pk.name }}</p>
<p class="font-mono text-xs text-muted">{{ fmtTime(pk.created_at) }}</p>
</div>
<button class="text-xs text-muted transition hover:text-err" @click="removePasskey(pk.id)">解除</button>
</li>
</ul>
<p v-else class="text-xs text-muted">尚未绑定 Passkey</p>
</div>
</div>
</template>