Files
oci-portal-dash/src/views/LoginView.vue
T
2026-07-30 12:23:16 +08:00

465 lines
18 KiB
Vue

<script setup lang="ts">
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 {
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'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
// 点击 logo 切换明暗主题,新主题自 logo 向外扩散
const { toggleDarkFrom } = useThemeRipple()
const username = ref('admin')
const password = ref('')
const totpCode = ref('')
// 密码通过但账号启用了两步验证:展示验证码输入
const totpRequired = ref(false)
const loading = ref(false)
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 = [
{ label: '实例测活', dot: 'var(--color-ok)', style: '--bd:5.7s;--bdel:-1.3s;--bamp:-7px' },
{ label: '自动抢机', dot: 'var(--color-accent)', style: '--bd:7.4s;--bdel:-4.1s;--bamp:-5px' },
{ label: 'AI 网关', dot: 'var(--color-info)', style: '--bd:6.2s;--bdel:-2.6s;--bamp:-9px' },
{ label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' },
]
onMounted(() => {
consumeOauthCallback()
void loadProviders()
})
/** 加载登录方式能力;失败进入错误态而非默认渲染密码表单(禁用状态未知) */
async function loadProviders() {
bootLoading.value = true
bootError.value = false
try {
const r = await getOauthProviders()
providers.value = r.providers
passkeyAvailable.value = r.passkeyLogin && browserSupportsWebAuthn()
walletAvailable.value = r.walletLogin
// 服务端已保证「存在任一免密方式才下发禁用」;浏览器能力只影响入口显示,
// 不得覆盖该事实(不支持 WebAuthn 时显示必然 403 的密码表单更误导)
passwordLoginDisabled.value = r.passwordLoginDisabled
} catch {
bootError.value = true
} finally {
bootLoading.value = false
}
}
/** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */
function consumeOauthCallback() {
const hash = window.location.hash
if (hash.startsWith('#oauthToken=')) {
const token = decodeURIComponent(hash.slice('#oauthToken='.length))
history.replaceState(null, '', window.location.pathname)
// OAuth 登录未返回过期时间,按后端 JWT 24h 计
auth.setSession(token, new Date(Date.now() + 24 * 3600e3).toISOString())
void router.push({ name: 'overview' })
return
}
const err = route.query.oauthError
if (typeof err === 'string' && err) {
errorMsg.value = err
void router.replace({ query: {} })
}
}
async function submit() {
if (!username.value || !password.value) {
errorMsg.value = '请输入用户名和密码'
return
}
if (totpRequired.value && !totpCode.value) {
errorMsg.value = '请输入两步验证码'
return
}
loading.value = true
errorMsg.value = ''
try {
const resp = await login({
username: username.value,
password: password.value,
...(totpCode.value ? { totpCode: totpCode.value } : {}),
})
auth.setSession(resp.token, resp.expiresAt)
const redirect = route.query.redirect
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
} catch (e) {
if (e instanceof ApiError && e.status === 428) {
totpRequired.value = true
return
}
errorMsg.value = e instanceof Error ? e.message : '登录失败'
} finally {
loading.value = false
}
}
async function oauthLogin(provider: string) {
loading.value = true
errorMsg.value = ''
try {
const { url } = await getOauthAuthorizeUrl(provider, 'login')
window.location.href = url
} catch (e) {
errorMsg.value = e instanceof Error ? e.message : '跳转失败'
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>
<div class="relative flex min-h-screen items-center overflow-hidden">
<FullPageBackdrop tint="accent" rings="none" />
<div
class="relative z-10 mx-auto flex w-full max-w-[1180px] items-center justify-between gap-12 px-12 max-lg:flex-col max-lg:justify-center max-lg:gap-9 max-lg:px-6 max-lg:py-10"
>
<!-- 品牌区(双轨道环以品牌内容为中心,与设计稿同心) -->
<div class="relative flex max-w-[480px] flex-col gap-7 max-lg:items-center max-lg:gap-5 max-lg:text-center">
<div class="pointer-events-none absolute inset-0 max-lg:hidden" aria-hidden="true">
<div class="fpb-ring fpb-spin" style="width: 580px; height: 580px; left: calc(50% - 290px); top: calc(50% - 290px)"></div>
<div class="fpb-ring fpb-ring-dash" style="width: 430px; height: 430px; left: calc(50% - 215px); top: calc(50% - 215px)"></div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
class="floaty logo-btn flex-none"
title="切换明暗主题"
aria-label="切换明暗主题"
@click="toggleDarkFrom"
>
<AppLogo :size="58" />
</button>
<span>
<span class="block font-serif text-2xl font-semibold">OCI Portal</span>
<span class="mt-px block text-[12.5px] text-ink-3">自托管 · Self-hosted</span>
</span>
</div>
<div class="font-serif text-[46px] leading-[1.24] font-semibold max-lg:text-[30px]">
批量管理你的<br />Oracle Cloud 租户
</div>
<div class="flex max-w-[420px] flex-wrap gap-2 max-lg:justify-center">
<span v-for="c in featureChips" :key="c.label" class="chip" :style="c.style">
<span class="dot" :style="{ background: c.dot }"></span>{{ c.label }}
</span>
</div>
</div>
<!-- 登录卡 -->
<div class="w-[392px] max-w-full flex-none">
<div class="rounded-lg border border-line bg-card px-[30px] pt-[30px] pb-7 shadow-overlay">
<div class="font-serif text-[19px] font-semibold">欢迎回来</div>
<div v-if="bootLoading" class="flex items-center justify-center py-14">
<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"
class="mt-4 flex items-start gap-2 rounded-md border border-err/30 bg-err/10 px-3 py-2 text-[12.5px] text-err"
role="alert"
>
{{ errorMsg }}
</div>
<form v-if="!passwordLoginDisabled" class="mt-5 flex flex-col gap-4" @submit.prevent="submit">
<label class="flex flex-col gap-1.5">
<span class="text-[13px] font-medium text-ink-2">用户名</span>
<NInput v-model:value="username" placeholder="admin" :disabled="loading" />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-[13px] font-medium text-ink-2">密码</span>
<NInput
v-model:value="password"
type="password"
show-password-on="click"
placeholder="请输入密码"
:disabled="loading"
@keyup.enter="submit"
/>
</label>
<label v-if="totpRequired" class="flex flex-col gap-1.5">
<span class="text-[13px] font-medium text-ink-2">两步验证码</span>
<NInput
v-model:value="totpCode"
placeholder="验证器 App 中的 6 位数字"
:maxlength="6"
:disabled="loading"
@keyup.enter="submit"
/>
<span class="text-xs text-ink-3">该账号已启用两步验证,请输入动态验证码完成登录</span>
</label>
<NButton type="primary" attr-type="submit" size="large" block :loading="loading">
{{ loading ? '验证中…' : '登 录' }}
</NButton>
</form>
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">
{{
providers.length || passkeyAvailable || walletAvailable
? '密码登录已禁用,请使用免密方式继续'
: '密码登录已禁用,当前浏览器没有可用的免密登录入口(通行密钥需浏览器支持);请更换浏览器后重试'
}}
</div>
<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 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"
:block="passwordLoginDisabled"
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
:size="passwordLoginDisabled ? 'large' : 'medium'"
:disabled="loading"
@click="oauthLogin(p.provider)"
>
<template #icon><OauthProviderIcon :provider="p.provider" /></template>
{{ passwordLoginDisabled ? `使用 ${p.displayName} 登录` : p.displayName }}
</NButton>
</div>
<div v-if="passwordLoginDisabled" class="mt-5 flex items-center justify-center gap-1.5 text-[11.5px] text-ink-3">
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<rect x="5" y="11" width="14" height="9" rx="2"></rect>
<path d="M8 11V7a4 4 0 0 1 8 0v4"></path>
</svg>
由管理员在 设置 · 账号安全 中管理
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
border: 1px solid var(--color-line);
border-radius: 999px;
background: var(--color-card);
padding: 7px 14px;
font-size: 12px;
color: var(--color-ink-2);
box-shadow: 0 2px 8px rgba(20, 20, 19, 0.07);
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
/* logo 即主题开关:去按钮底色,悬停轻微放大提示可点 */
.logo-btn {
cursor: pointer;
border: none;
background: none;
padding: 0;
line-height: 0;
transition: scale 0.25s ease;
}
.logo-btn:hover {
scale: 1.06;
}
.logo-btn:active {
scale: 0.96;
}
@media (prefers-reduced-motion: no-preference) {
.floaty {
animation: login-fl 7s ease-in-out infinite;
}
.chip {
animation: login-bob var(--bd, 6.5s) ease-in-out var(--bdel, 0s) infinite;
}
@keyframes login-fl {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes login-bob {
0%,
100% {
transform: translateY(0);
}
32% {
transform: translateY(var(--bamp, -6px));
}
68% {
transform: translateY(calc(var(--bamp, -6px) * -0.4));
}
}
}
</style>