update
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, computed } from 'vue'; // computed 已导入
|
||||
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { startAuthentication } from '@simplewebauthn/browser'; // <-- 导入 Passkey 函数
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
|
||||
@@ -96,8 +97,49 @@ onMounted(() => {
|
||||
console.log('[LoginView] Component mounted, calling fetchCaptchaConfig...'); // 添加日志
|
||||
authStore.fetchCaptchaConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
// --- Passkey Login Handler ---
|
||||
const handlePasskeyLogin = async () => {
|
||||
authStore.clearError(); // 清除之前的错误
|
||||
captchaError.value = null; // 清除 CAPTCHA 错误
|
||||
try {
|
||||
// 1. 从后端获取认证选项 (包含 challenge)
|
||||
// 需要 authStore 中添加 getPasskeyAuthenticationOptions action
|
||||
const options = await authStore.getPasskeyAuthenticationOptions();
|
||||
if (!options) {
|
||||
// 错误已在 store action 中处理
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 使用浏览器 API 开始认证
|
||||
let authenticationResponse;
|
||||
try {
|
||||
authenticationResponse = await startAuthentication(options);
|
||||
} catch (err: any) {
|
||||
console.error('Passkey authentication failed (startAuthentication):', err);
|
||||
// 用户取消或浏览器不支持等情况
|
||||
if (err.name === 'NotAllowedError') {
|
||||
authStore.setError(t('login.error.passkeyCancelled'));
|
||||
} else {
|
||||
authStore.setError(t('login.error.passkeyFailed', { error: err.message || err.name || 'Unknown error' }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 将认证响应发送到后端进行验证
|
||||
// 需要 authStore 中添加 verifyPasskeyAuthentication action
|
||||
await authStore.verifyPasskeyAuthentication(authenticationResponse, rememberMe.value);
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
|
||||
} catch (err) {
|
||||
// Store action 中的错误已处理,这里无需额外操作
|
||||
console.error('Error during passkey login flow:', err);
|
||||
}
|
||||
};
|
||||
// --- End Passkey Login Handler ---
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<!-- Page Container -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-background p-4">
|
||||
@@ -194,6 +236,16 @@ onMounted(() => {
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
|
||||
</button>
|
||||
|
||||
<!-- Passkey Login Button -->
|
||||
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
|
||||
class="w-full mt-3 py-3 px-4 bg-secondary text-secondary-foreground border border-border/50 rounded-lg text-base font-semibold cursor-pointer shadow-sm transition-colors duration-200 ease-in-out hover:bg-secondary/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-300 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline-block mr-2 -mt-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 11c.828 0 1.5.672 1.5 1.5S12.828 14 12 14s-1.5-.672-1.5-1.5S11.172 11 12 11zm0-9a7 7 0 00-7 7c0 1.886.738 3.627 1.946 4.946.06.061.117.122.17.185l-.018.018-.002.002A9.5 9.5 0 0012 21.5a9.5 9.5 0 007.89-4.352l-.002-.002-.018-.018a6.965 6.965 0 001.946-4.946 7 7 0 00-7-7zm0 1.5a5.5 5.5 0 110 11 5.5 5.5 0 010-11z" />
|
||||
</svg>
|
||||
{{ t('login.passkeyLoginButton', '使用 Passkey 登录') }}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,6 +67,33 @@
|
||||
<p v-if="passkeyMessage" class="text-sm text-success">{{ passkeyMessage }}</p>
|
||||
<p v-if="passkeyError" class="text-sm text-error">{{ passkeyError }}</p>
|
||||
</div>
|
||||
<!-- Passkey List -->
|
||||
<div class="mt-6 pt-4 border-t border-border/50">
|
||||
<h4 class="text-sm font-semibold text-foreground mb-3">{{ $t('settings.passkey.registeredKeysTitle', '已注册的 Passkey') }}</h4>
|
||||
<!-- Loading State -->
|
||||
<div v-if="passkeysLoading" class="text-sm text-text-secondary italic">{{ $t('common.loading') }}</div>
|
||||
<!-- Error State -->
|
||||
<div v-else-if="passkeysError" class="text-sm text-error">{{ passkeysError }}</div>
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="passkeys.length === 0" class="text-sm text-text-secondary italic">{{ $t('settings.passkey.noKeysRegistered', '尚未注册任何 Passkey。') }}</div>
|
||||
<!-- List -->
|
||||
<ul v-else class="space-y-3">
|
||||
<li v-for="key in passkeys" :key="key.id" class="flex items-center justify-between p-3 border border-border rounded-md bg-header/30 text-sm">
|
||||
<div>
|
||||
<span class="font-medium text-foreground mr-2">{{ key.name || $t('settings.passkey.unnamedKey', '未命名密钥') }}</span>
|
||||
<span class="text-xs text-text-secondary">({{ $t('settings.passkey.registeredOn', '注册于') }}: {{ formatDate(key.created_at) }})</span>
|
||||
</div>
|
||||
<button
|
||||
@click="handleDeletePasskey(key.id)"
|
||||
:disabled="deletingPasskeyId === key.id"
|
||||
class="px-2 py-1 bg-error text-error-text rounded text-xs font-medium hover:bg-error/80 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-error disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out"
|
||||
>
|
||||
{{ deletingPasskeyId === key.id ? $t('common.deleting') : $t('common.delete') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="deletePasskeyError" class="mt-2 text-sm text-error">{{ deletePasskeyError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-border/50">
|
||||
<!-- 2FA -->
|
||||
@@ -561,6 +588,10 @@ const {
|
||||
commandInputSyncTarget, // NEW: Import command input sync target getter
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
// 从 authStore 获取 Passkey 相关状态
|
||||
const { passkeys, passkeysLoading, passkeysError } = storeToRefs(authStore);
|
||||
|
||||
|
||||
// --- Local state for forms ---
|
||||
const ipWhitelistInput = ref('');
|
||||
// 使用 store 的 language getter 初始化 selectedLanguage
|
||||
@@ -632,6 +663,10 @@ const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the
|
||||
const captchaLoading = ref(false);
|
||||
const captchaMessage = ref('');
|
||||
const captchaSuccess = ref(false);
|
||||
// Passkey Deletion State
|
||||
const deletingPasskeyId = ref<number | null>(null);
|
||||
const deletePasskeyError = ref<string | null>(null);
|
||||
|
||||
|
||||
// 提供一些常用的时区供选择
|
||||
const commonTimezones = ref([
|
||||
@@ -858,7 +893,7 @@ const openStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(true);
|
||||
};
|
||||
|
||||
// --- Passkey state & methods --- (Keep as is)
|
||||
// --- Passkey state & methods ---
|
||||
const passkeyName = ref('');
|
||||
const passkeyMessage = ref<string | null>(null);
|
||||
const passkeyError = ref<string | null>(null);
|
||||
@@ -876,6 +911,7 @@ const handleRegisterPasskey = async () => {
|
||||
await apiClient.post('/auth/passkey/verify-registration', { registrationResponse, name: passkeyName.value }); // 使用 apiClient
|
||||
passkeyMessage.value = t('settings.passkey.success.registered');
|
||||
passkeyName.value = '';
|
||||
await authStore.fetchPasskeys(); // 注册成功后刷新列表
|
||||
} catch (error: any) {
|
||||
console.error('Passkey 注册流程出错:', error);
|
||||
if (error.name === 'NotAllowedError') {
|
||||
@@ -887,6 +923,35 @@ const handleRegisterPasskey = async () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
// 新增:处理 Passkey 删除
|
||||
const handleDeletePasskey = async (id: number) => {
|
||||
deletingPasskeyId.value = id;
|
||||
deletePasskeyError.value = null;
|
||||
if (confirm(t('settings.passkey.confirmDelete', '确定要删除这个 Passkey 吗?'))) { // 需要添加翻译
|
||||
try {
|
||||
await authStore.deletePasskey(id);
|
||||
// 列表会自动更新,因为 store action 修改了 state
|
||||
} catch (error: any) {
|
||||
console.error('删除 Passkey 失败:', error);
|
||||
deletePasskeyError.value = error.message || t('settings.passkey.error.deleteFailed', '删除 Passkey 失败'); // 需要添加翻译
|
||||
} finally {
|
||||
deletingPasskeyId.value = null;
|
||||
}
|
||||
} else {
|
||||
deletingPasskeyId.value = null;
|
||||
}
|
||||
};
|
||||
// 新增:格式化日期函数
|
||||
const formatDate = (timestamp: number | undefined) => {
|
||||
if (!timestamp) return t('statusMonitor.notAvailable');
|
||||
try {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", e);
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Change Password state & methods --- (Keep as is)
|
||||
const currentPassword = ref('');
|
||||
@@ -1128,6 +1193,7 @@ onMounted(async () => {
|
||||
await checkTwoFactorStatus(); // Check 2FA status
|
||||
await fetchIpBlacklist(); // Fetch current blacklist entries
|
||||
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
|
||||
await authStore.fetchPasskeys(); // <-- 获取 Passkey 列表
|
||||
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user