This commit is contained in:
Baobhan Sith
2025-04-27 00:40:03 +08:00
parent 4043e297b0
commit 3fa03f260e
11 changed files with 553 additions and 32 deletions
+1
View File
@@ -37,6 +37,7 @@
"themeCreatedSuccess": "Theme created successfully.",
"themeSaveFailed": "Failed to save theme.",
"themeDeletedSuccess": "Theme deleted successfully.",
"passkeyLoginButton": "Login with Passkey",
"themeDeleteFailed": "Failed to delete theme: {message}",
"importSuccess": "Theme imported successfully.",
"importFailed": "Theme import failed.",
+2 -1
View File
@@ -104,7 +104,8 @@
"captchaLoadFailed": "CAPTCHA の読み込みに失敗しました。ページをリロードしてください。",
"captchaRequired": "CAPTCHA を完了してください。"
},
"recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。"
"recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。",
"passkeyLoginButton": "Passkeyでログイン"
},
"connections": {
"addConnection": "新しい接続を追加",
+2 -1
View File
@@ -104,7 +104,8 @@
"captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。",
"captchaRequired": "请完成 CAPTCHA 验证。"
},
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。"
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。",
"passkeyLoginButton": "使用 Passkey 登录"
},
"connections": {
"addConnection": "添加新连接",
+133
View File
@@ -36,6 +36,15 @@ interface FullCaptchaSettings {
recaptchaSecretKey?: string; // We won't use this in authStore
}
// 新增:Passkey 信息接口 (根据后端返回调整)
interface PasskeyInfo {
id: number; // 数据库中的 ID,用于删除
name?: string; // 用户设置的名称
transports?: string; // JSON string of transports like ["internal", "usb"]
created_at?: number; // Unix timestamp
}
// Auth Store State 接口
interface AuthState {
isAuthenticated: boolean;
@@ -50,6 +59,9 @@ interface AuthState {
};
needsSetup: boolean; // 新增:是否需要初始设置
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
passkeys: PasskeyInfo[]; // 新增:存储 Passkey 列表
passkeysLoading: boolean; // 新增:Passkey 列表加载状态
passkeysError: string | null; // 新增:Passkey 列表错误状态
}
export const useAuthStore = defineStore('auth', {
@@ -62,12 +74,24 @@ export const useAuthStore = defineStore('auth', {
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
needsSetup: false, // 初始假设不需要设置
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
passkeys: [], // 初始化 Passkey 列表为空
passkeysLoading: false,
passkeysError: null,
}),
getters: {
// 可以添加一些 getter,例如获取用户名
loggedInUser: (state) => state.user?.username,
},
actions: {
// 新增:清除错误状态
clearError() {
this.error = null;
},
// 新增:设置错误状态
setError(errorMessage: string) {
this.error = errorMessage;
},
// 登录 Action - 更新为接受 LoginPayload + optional captchaToken
async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload
this.isLoading = true;
@@ -156,6 +180,7 @@ export const useAuthStore = defineStore('auth', {
// 清除本地状态
this.isAuthenticated = false;
this.user = null;
this.passkeys = []; // 登出时清空 Passkey 列表
console.log('已登出');
// 登出后重定向到登录页
await router.push({ name: 'Login' });
@@ -185,6 +210,7 @@ export const useAuthStore = defineStore('auth', {
this.isAuthenticated = false;
this.user = null;
this.loginRequires2FA = false;
this.passkeys = []; // 未认证时清空 Passkey 列表
}
} catch (error: any) {
// 如果获取状态失败 (例如 session 过期),则认为未认证
@@ -192,6 +218,7 @@ export const useAuthStore = defineStore('auth', {
this.isAuthenticated = false;
this.user = null;
this.loginRequires2FA = false;
this.passkeys = []; // 失败时也清空 Passkey 列表
// 可选:如果不是 401 错误,可以记录更详细的日志
} finally {
this.isLoading = false;
@@ -325,6 +352,112 @@ export const useAuthStore = defineStore('auth', {
};
}
},
// --- Passkey Actions ---
/**
* 获取当前用户的 Passkey 列表
*/
async fetchPasskeys() {
if (!this.isAuthenticated) return; // 确保用户已登录
this.passkeysLoading = true;
this.passkeysError = null;
try {
const response = await apiClient.get<PasskeyInfo[]>('/auth/passkeys');
this.passkeys = response.data;
console.log('获取 Passkey 列表成功:', this.passkeys);
} catch (err: any) {
console.error('获取 Passkey 列表失败:', err);
this.passkeysError = err.response?.data?.message || err.message || '获取 Passkey 列表时发生未知错误。';
this.passkeys = []; // 出错时清空列表
} finally {
this.passkeysLoading = false;
}
},
/**
* 删除指定的 Passkey
* @param passkeyId 要删除的 Passkey 的 ID
*/
async deletePasskey(passkeyId: number) {
if (!this.isAuthenticated) throw new Error('用户未登录');
// 可以添加一个 loading 状态 specific to deletion if needed
this.passkeysError = null; // Clear previous errors
try {
await apiClient.delete(`/auth/passkeys/${passkeyId}`);
console.log(`Passkey ID ${passkeyId} 已删除`);
// 从本地状态中移除
this.passkeys = this.passkeys.filter(key => key.id !== passkeyId);
return true; // Indicate success
} catch (err: any) {
console.error(`删除 Passkey ID ${passkeyId} 失败:`, err);
this.passkeysError = err.response?.data?.message || err.message || '删除 Passkey 时发生未知错误。';
// 抛出错误以便 UI 显示
throw new Error(this.passkeysError ?? '删除 Passkey 时发生未知错误。');
}
},
// --- Passkey Authentication Actions ---
/**
* 从后端获取 Passkey 认证选项
*/
async getPasskeyAuthenticationOptions() {
this.isLoading = true;
this.error = null;
try {
// 调用后端 API 获取选项
const response = await apiClient.post('/auth/passkey/authenticate-options');
console.log('获取 Passkey 认证选项成功:', response.data);
return response.data; // 返回选项给调用者 (LoginView)
} catch (err: any) {
console.error('获取 Passkey 认证选项失败:', err);
this.error = err.response?.data?.message || err.message || '获取 Passkey 认证选项时发生未知错误。';
// 返回 null 或抛出错误,让调用者知道失败了
return null;
} finally {
this.isLoading = false;
}
},
/**
* 验证 Passkey 认证响应并登录
* @param authenticationResponse 从 @simplewebauthn/browser 获取的响应
* @param rememberMe 用户是否勾选了“记住我”
*/
async verifyPasskeyAuthentication(authenticationResponse: any, rememberMe: boolean) {
this.isLoading = true;
this.error = null;
try {
// 调用后端 API 验证响应
const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/passkey/verify-authentication', {
authenticationResponse,
rememberMe // 将 rememberMe 状态传递给后端
});
// Passkey 认证和登录成功
this.isAuthenticated = true;
this.user = response.data.user;
this.loginRequires2FA = false; // Passkey 登录通常不需要额外 2FA
console.log('Passkey 登录成功:', this.user);
// 设置语言
if (this.user?.language) {
setLocale(this.user.language);
}
// 跳转到工作区并刷新
window.location.href = '/';
return { success: true };
} catch (err: any) {
console.error('Passkey 认证验证失败:', err);
this.isAuthenticated = false;
this.user = null;
this.error = err.response?.data?.message || err.message || 'Passkey 登录时发生未知错误。';
return { success: false, error: this.error };
} finally {
this.isLoading = false;
}
},
},
persist: true, // Revert to simple persistence to fix TS error for now
});
+54 -2
View File
@@ -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 -1
View File
@@ -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()
});