<template>
<section class="identity-page">
<div class="identity-page-head">
<div>
<div class="identity-kicker">TOTP CHANNEL</div>
<h1 class="identity-title">绑定双因素认证</h1>
<p class="identity-subtitle">
使用认证器 App 扫描二维码,再输入 6 位动态验证码。
</p>
</div>
<div class="identity-head-icon identity-head-icon--green">
<q-icon name="phonelink_lock" size="30px" />
</div>
</div>
<q-card flat bordered class="identity-card">
<q-card-section class="two-factor-grid">
<div class="two-factor-qr">
<q-inner-loading :showing="loading">
<q-spinner color="primary" size="34px" />
</q-inner-loading>
<img
v-if="setup?.qrCodeSetupImageUrl"
:src="setup.qrCodeSetupImageUrl"
alt="双因素认证二维码"
/>
<q-icon v-else name="qr_code_2" size="92px" />
</div>
<div class="two-factor-copy">
<div class="two-factor-step">
<span>01</span>
打开 Microsoft Authenticator、Google Authenticator 或兼容 TOTP
的应用。
</div>
<div class="two-factor-step">
<span>02</span>
扫描二维码;如果无法扫描,可以手动输入密钥。
</div>
<q-input
:model-value="setup?.manualEntryKey ?? ''"
outlined
readonly
label="手动密钥"
class="identity-input"
>
<template #prepend>
<q-icon name="key" />
</template>
<template #append>
<q-btn flat round icon="content_copy" @click="copyKey">
<q-tooltip>复制密钥</q-tooltip>
</q-btn>
</template>
</q-input>
<q-input
v-model="pinCode"
outlined
label="6 位验证码"
autocomplete="one-time-code"
inputmode="numeric"
maxlength="6"
:disable="binding || loading"
class="identity-input"
@update:model-value="updatePinCode"
>
<template #prepend>
<q-icon name="pin" />
</template>
<template #append>
<div class="identity-pin-slots" aria-hidden="true">
<span
v-for="index in 6"
:key="index"
:class="{ 'is-filled': pinCode.length >= index }"
/>
</div>
</template>
</q-input>
<q-btn
class="identity-primary-action"
color="primary"
icon="verified"
label="完成绑定"
:loading="binding"
:disable="loading || pinCode.length !== 6"
unelevated
@click="bind"
/>
<q-btn
flat
icon="refresh"
label="重新获取二维码"
:disable="binding || loading"
@click="loadSetup"
/>
</div>
</q-card-section>
</q-card>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useQuasar } from "quasar";
import type {
UserSecurityBindingStatus,
UserTwoFactorSetup,
} from "../../models/AuthModels";
import { UserSecurityService } from "../../services/UserSecurityService";
const emit = defineEmits<{
bound: [status: UserSecurityBindingStatus];
}>();
const $q = useQuasar();
const security = new UserSecurityService();
const loading = ref(false);
const binding = ref(false);
const setup = ref<UserTwoFactorSetup | null>(null);
const pinCode = ref("");
onMounted(loadSetup);
async function loadSetup() {
loading.value = true;
try {
const result = await security.twoFactorSetup();
if (!result.success || !result.data) {
$q.notify({ type: "warning", message: result.message ?? "获取密钥失败" });
return;
}
setup.value = result.data;
} finally {
loading.value = false;
}
}
async function bind() {
if (!setup.value?.setupToken) {
$q.notify({ type: "warning", message: "二维码已过期,请重新获取" });
return;
}
binding.value = true;
try {
const result = await security.bindTwoFactor(
pinCode.value,
setup.value.setupToken,
);
$q.notify({
type: result.success ? "positive" : "negative",
message: result.message ?? "操作完成",
});
if (result.success && result.data) {
emit("bound", result.data);
}
} finally {
binding.value = false;
}
}
async function copyKey() {
if (!setup.value?.manualEntryKey) {
return;
}
await navigator.clipboard.writeText(setup.value.manualEntryKey);
$q.notify({ type: "positive", message: "密钥已复制" });
}
function updatePinCode(value: string | number | null) {
pinCode.value = String(value ?? "")
.replace(/\D/g, "")
.slice(0, 6);
}
</script>
AI 正在分析代码…
评论加载中...