+165
-9
@@ -1,14 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NSpin } from 'naive-ui'
|
||||
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'
|
||||
import { NButton, NDropdown, NInput, NSpin } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getOauthAuthorizeUrl, getOauthProviders, login } from '@/api/auth'
|
||||
import {
|
||||
beginPasskeyLogin,
|
||||
finishPasskeyLogin,
|
||||
getOauthAuthorizeUrl,
|
||||
getOauthProviders,
|
||||
getWalletChallenge,
|
||||
login,
|
||||
verifyWallet,
|
||||
} from '@/api/auth'
|
||||
import { ApiError } from '@/api/request'
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
|
||||
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
|
||||
import { useThemeRipple } from '@/composables/useThemeRipple'
|
||||
import {
|
||||
discoverWallets,
|
||||
isUserRejected,
|
||||
personalSign,
|
||||
requestAccount,
|
||||
type WalletDetail,
|
||||
} from '@/composables/useWallet'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { OauthProviderInfo } from '@/types/api'
|
||||
|
||||
@@ -30,8 +46,17 @@ const errorMsg = ref('')
|
||||
const providers = ref<OauthProviderInfo[]>([])
|
||||
// 密码登录已禁用(设置里开启且存在可用外部身份):隐藏用户名密码表单
|
||||
const passwordLoginDisabled = ref(false)
|
||||
// 存在已注册通行密钥且浏览器支持 WebAuthn:显示一键登录入口
|
||||
const passkeyAvailable = ref(false)
|
||||
// 存在已绑定钱包身份:显示钱包登录入口(是否装有钱包扩展点击时才探测)
|
||||
const walletAvailable = ref(false)
|
||||
// 多个注入钱包并存时的选择项(EIP-6963)
|
||||
const walletChoices = ref<WalletDetail[]>([])
|
||||
const showWalletPick = ref(false)
|
||||
// 登录方式加载中:providers 返回前不渲染表单,避免「先见密码表单再消失」的闪烁与误提交
|
||||
const bootLoading = ref(true)
|
||||
// 能力请求失败:密码是否被禁用未知,不渲染任何表单(可能必然 403),给重试入口
|
||||
const bootError = ref(false)
|
||||
|
||||
// 左侧品牌区功能徽章:各自不同周期/幅度/相位上下浮动(不规律观感)
|
||||
const featureChips = [
|
||||
@@ -41,18 +66,29 @@ const featureChips = [
|
||||
{ label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
consumeOauthCallback()
|
||||
void loadProviders()
|
||||
})
|
||||
|
||||
/** 加载登录方式能力;失败进入错误态而非默认渲染密码表单(禁用状态未知) */
|
||||
async function loadProviders() {
|
||||
bootLoading.value = true
|
||||
bootError.value = false
|
||||
try {
|
||||
const r = await getOauthProviders()
|
||||
providers.value = r.providers
|
||||
passwordLoginDisabled.value = r.passwordLoginDisabled && r.providers.length > 0
|
||||
passkeyAvailable.value = r.passkeyLogin && browserSupportsWebAuthn()
|
||||
walletAvailable.value = r.walletLogin
|
||||
// 服务端已保证「存在任一免密方式才下发禁用」;浏览器能力只影响入口显示,
|
||||
// 不得覆盖该事实(不支持 WebAuthn 时显示必然 403 的密码表单更误导)
|
||||
passwordLoginDisabled.value = r.passwordLoginDisabled
|
||||
} catch {
|
||||
/* 登录页容忍 provider 列表加载失败 */
|
||||
bootError.value = true
|
||||
} finally {
|
||||
bootLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */
|
||||
function consumeOauthCallback() {
|
||||
@@ -114,6 +150,68 @@ async function oauthLogin(provider: string) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 钱包登录入口:发现注入钱包;多个时弹选择,单个直接进入签名流程;
|
||||
* 发现期(约 300ms)以 loading 互斥,防双击并发多组授权/挑战流程 */
|
||||
async function walletLogin() {
|
||||
if (loading.value) return
|
||||
errorMsg.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const wallets = await discoverWallets()
|
||||
if (!wallets.length) {
|
||||
errorMsg.value = '未检测到浏览器钱包扩展(MetaMask / OKX 等)'
|
||||
return
|
||||
}
|
||||
if (wallets.length === 1) {
|
||||
await walletLoginWith(wallets[0]!)
|
||||
return
|
||||
}
|
||||
walletChoices.value = wallets
|
||||
showWalletPick.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 钱包签名登录:连接取地址 → 后端挑战 → personal_sign → 校验签发会话 */
|
||||
async function walletLoginWith(w: WalletDetail) {
|
||||
showWalletPick.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const address = await requestAccount(w.provider)
|
||||
const { nonce, message } = await getWalletChallenge(address, 'login')
|
||||
const signature = await personalSign(w.provider, message, address)
|
||||
const resp = await verifyWallet(nonce, signature)
|
||||
auth.setSession(resp.token, resp.expiresAt)
|
||||
const redirect = route.query.redirect
|
||||
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
|
||||
} catch (e) {
|
||||
if (!isUserRejected(e)) errorMsg.value = e instanceof Error ? e.message : '钱包登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 通行密钥一键登录:断言 options → 浏览器验证器 → 后端校验签发会话 */
|
||||
async function passkeyLogin() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
try {
|
||||
const { sessionId, options } = await beginPasskeyLogin()
|
||||
const credential = await startAuthentication({ optionsJSON: options.publicKey })
|
||||
const resp = await finishPasskeyLogin(sessionId, credential)
|
||||
auth.setSession(resp.token, resp.expiresAt)
|
||||
const redirect = route.query.redirect
|
||||
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
|
||||
} catch (e) {
|
||||
// 用户在系统弹窗中取消:浏览器抛 NotAllowedError,静默返回
|
||||
if (e instanceof Error && e.name === 'NotAllowedError') return
|
||||
errorMsg.value = e instanceof Error ? e.message : '通行密钥登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,6 +261,13 @@ async function oauthLogin(provider: string) {
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="bootError"
|
||||
class="mt-5 flex flex-col items-start gap-3 rounded-md border border-line bg-wash px-4 py-3.5 text-[12.5px] text-ink-2"
|
||||
>
|
||||
<span>无法加载登录方式,请检查网络后重试。</span>
|
||||
<NButton size="small" @click="loadProviders">重试</NButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="errorMsg"
|
||||
@@ -206,15 +311,66 @@ async function oauthLogin(provider: string) {
|
||||
{{ loading ? '验证中…' : '登 录' }}
|
||||
</NButton>
|
||||
</form>
|
||||
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">密码登录已禁用,请使用外部身份继续</div>
|
||||
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">
|
||||
{{
|
||||
providers.length || passkeyAvailable || walletAvailable
|
||||
? '密码登录已禁用,请使用免密方式继续'
|
||||
: '密码登录已禁用,当前浏览器没有可用的免密登录入口(通行密钥需浏览器支持);请更换浏览器后重试'
|
||||
}}
|
||||
</div>
|
||||
|
||||
<template v-if="providers.length">
|
||||
<template v-if="providers.length || passkeyAvailable || walletAvailable">
|
||||
<div v-if="!passwordLoginDisabled" class="mt-5 flex items-center gap-2.5 text-[11.5px] text-ink-3">
|
||||
<span class="h-px flex-1 bg-line-soft"></span>
|
||||
或
|
||||
<span class="h-px flex-1 bg-line-soft"></span>
|
||||
</div>
|
||||
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 gap-2'">
|
||||
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 flex-wrap gap-2'">
|
||||
<NButton
|
||||
v-if="passkeyAvailable"
|
||||
:block="passwordLoginDisabled"
|
||||
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
|
||||
:size="passwordLoginDisabled ? 'large' : 'medium'"
|
||||
:disabled="loading"
|
||||
@click="passkeyLogin"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- 指纹图标:通行密钥入口 -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 11c0 3.5-.6 6.3-1.7 8.6" />
|
||||
<path d="M8.5 10.97a3.5 3.5 0 0 1 7 .03c0 2.2-.2 4.2-.7 6" />
|
||||
<path d="M5.4 9.1a7 7 0 0 1 13.2 1.9c0 1.4-.1 2.7-.3 4" />
|
||||
<path d="M4.8 14.5c.3-1.1.4-2.3.4-3.5" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ passwordLoginDisabled ? '使用通行密钥登录' : '通行密钥' }}
|
||||
</NButton>
|
||||
<NDropdown
|
||||
v-if="walletAvailable"
|
||||
trigger="manual"
|
||||
:show="showWalletPick"
|
||||
:options="walletChoices.map((w) => ({ key: w.info.uuid, label: w.info.name }))"
|
||||
@select="(key: string) => { const w = walletChoices.find((x) => x.info.uuid === key); if (w) void walletLoginWith(w) }"
|
||||
@clickoutside="showWalletPick = false"
|
||||
>
|
||||
<NButton
|
||||
:block="passwordLoginDisabled"
|
||||
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
|
||||
:size="passwordLoginDisabled ? 'large' : 'medium'"
|
||||
:disabled="loading"
|
||||
@click="walletLogin"
|
||||
>
|
||||
<template #icon>
|
||||
<!-- 钱包图标 -->
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 7H5a2 2 0 0 1-2-2v12a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1Z" />
|
||||
<path d="M3 5a2 2 0 0 1 2-2h12v4" />
|
||||
<circle cx="16.5" cy="13.5" r="0.6" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
{{ passwordLoginDisabled ? '使用钱包登录' : '钱包' }}
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
<NButton
|
||||
v-for="p in providers"
|
||||
:key="p.provider"
|
||||
|
||||
Reference in New Issue
Block a user