feat: 添加 passkey 登录功能
This commit is contained in:
@@ -33,7 +33,8 @@
|
||||
"vue3-recaptcha2": "^1.8.0",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-web-links": "^0.9.0"
|
||||
"xterm-addon-web-links": "^0.9.0",
|
||||
"@simplewebauthn/browser": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
|
||||
@@ -100,10 +100,14 @@
|
||||
"twoFactorPrompt": "Enter your two-factor authentication code:",
|
||||
"verifyButton": "Verify",
|
||||
"rememberMe": "Remember Me",
|
||||
"loginWithPasskey": "Login with Passkey",
|
||||
"captchaPrompt": "Please complete the verification below:",
|
||||
"error": {
|
||||
"captchaLoadFailed": "Failed to load CAPTCHA. Please try refreshing.",
|
||||
"captchaRequired": "Please complete the CAPTCHA verification."
|
||||
"captchaRequired": "Please complete the CAPTCHA verification.",
|
||||
"usernameRequiredForPasskey": "Username is required to use a passkey.",
|
||||
"passkeyAuthOptionsFailed": "Failed to get passkey authentication options from the server.",
|
||||
"passkeyAuthFailed": "Passkey authentication failed. Please try again or use your password."
|
||||
},
|
||||
"recaptchaV3Notice": "This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply."
|
||||
},
|
||||
@@ -527,19 +531,30 @@
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
"title": "Passkey Settings",
|
||||
"title": "Passkey Management",
|
||||
"description": "Use Passkeys (biometrics or security keys) for passwordless authentication to enhance security and convenience.",
|
||||
"nameLabel": "Passkey Name",
|
||||
"namePlaceholder": "e.g., My Laptop",
|
||||
"registerButton": "Register New Passkey",
|
||||
"registerNewButton": "Register New Passkey",
|
||||
"registeredKeysTitle": "Registered Passkeys",
|
||||
"unnamedKey": "Unnamed Passkey",
|
||||
"createdDate": "Created",
|
||||
"lastUsedDate": "Last Used",
|
||||
"noKeysRegistered": "No Passkeys registered yet.",
|
||||
"confirmDelete": "Are you sure you want to delete this Passkey? This action cannot be undone.",
|
||||
"error": {
|
||||
"nameRequired": "Please enter a Passkey name.",
|
||||
"cancelled": "Passkey registration was cancelled by the user.",
|
||||
"genericRegistration": "Could not register Passkey: {message}",
|
||||
"verificationFailed": "Registration failed: {message}"
|
||||
"verificationFailed": "Registration failed: {message}",
|
||||
"userNotLoggedIn": "User not logged in or username unavailable.",
|
||||
"registrationCancelled": "Passkey registration was cancelled.",
|
||||
"registrationFailed": "Passkey registration failed.",
|
||||
"deleteFailedGeneral": "Failed to delete Passkey. Please try again."
|
||||
},
|
||||
"success": {
|
||||
"registered": "Passkey registered successfully!"
|
||||
"registered": "New Passkey registered successfully!",
|
||||
"deleted": "Passkey deleted successfully."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
|
||||
@@ -458,11 +458,14 @@
|
||||
"captchaPrompt": "以下の認証を完了してください:",
|
||||
"error": {
|
||||
"captchaLoadFailed": "CAPTCHA の読み込みに失敗しました。ページをリロードしてください。",
|
||||
"captchaRequired": "CAPTCHA を完了してください。"
|
||||
"captchaRequired": "CAPTCHA を完了してください。",
|
||||
"usernameRequiredForPasskey": "Passkey を使用するにはユーザー名が必要です。",
|
||||
"passkeyAuthOptionsFailed": "サーバーから Passkey 認証オプションを取得できませんでした。",
|
||||
"passkeyAuthFailed": "Passkey 認証に失敗しました。もう一度試すか、パスワードを使用してください。"
|
||||
},
|
||||
"loggingIn": "ログイン中...",
|
||||
"loginButton": "ログイン",
|
||||
"passkeyLoginButton": "Passkeyでログイン",
|
||||
"loginWithPasskey": "Passkeyでログイン",
|
||||
"password": "パスワード",
|
||||
"recaptchaV3Notice": "このサイトは reCAPTCHA によって保護されており、Google のプライバシーポリシーと利用規約が適用されます。",
|
||||
"rememberMe": "ログイン状態を保持",
|
||||
@@ -818,20 +821,31 @@
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
"title": "Passkey 管理",
|
||||
"description": "Passkey (生体認証またはセキュリティキー) を使用してパスワードなし認証を行い、アカウントのセキュリティとログインの利便性を向上させます。",
|
||||
"error": {
|
||||
"cancelled": "Passkey の登録がキャンセルされました。",
|
||||
"genericRegistration": "Passkey を登録できません: {message}",
|
||||
"nameRequired": "Passkey 名を入力してください。",
|
||||
"verificationFailed": "登録に失敗しました: {message}"
|
||||
},
|
||||
"nameLabel": "Passkey 名",
|
||||
"namePlaceholder": "例: マイノートパソコン",
|
||||
"registerButton": "新しい Passkey を登録",
|
||||
"success": {
|
||||
"registered": "Passkey の登録に成功しました!"
|
||||
"registerNewButton": "新しい Passkey を登録",
|
||||
"registeredKeysTitle": "登録済みの Passkey",
|
||||
"unnamedKey": "名前のない Passkey",
|
||||
"createdDate": "作成日",
|
||||
"lastUsedDate": "最終使用日",
|
||||
"noKeysRegistered": "Passkey はまだ登録されていません。",
|
||||
"confirmDelete": "この Passkey を削除しますか?この操作は元に戻せません。",
|
||||
"error": {
|
||||
"nameRequired": "Passkey 名を入力してください。",
|
||||
"cancelled": "Passkey の登録がキャンセルされました。",
|
||||
"genericRegistration": "Passkey を登録できません: {message}",
|
||||
"verificationFailed": "登録に失敗しました: {message}",
|
||||
"userNotLoggedIn": "ユーザーがログインしていないか、ユーザー名が利用できません。",
|
||||
"registrationCancelled": "Passkey の登録がキャンセルされました。",
|
||||
"registrationFailed": "Passkey の登録に失敗しました。",
|
||||
"deleteFailedGeneral": "Passkey の削除に失敗しました。もう一度お試しください。"
|
||||
},
|
||||
"title": "Passkey 設定"
|
||||
"success": {
|
||||
"registered": "新しい Passkey が正常に登録されました!",
|
||||
"deleted": "Passkey が正常に削除されました。"
|
||||
}
|
||||
},
|
||||
"popupEditor": {
|
||||
"enableLabel": "ファイルを開くときにポップアップエディターを表示する",
|
||||
|
||||
@@ -100,12 +100,15 @@
|
||||
"verifyButton": "验证",
|
||||
"rememberMe": "记住我",
|
||||
"captchaPrompt": "请完成下方的验证:",
|
||||
"loginWithPasskey": "使用 Passkey 登录",
|
||||
"error": {
|
||||
"captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。",
|
||||
"captchaRequired": "请完成 CAPTCHA 验证。"
|
||||
"captchaRequired": "请完成 CAPTCHA 验证。",
|
||||
"usernameRequiredForPasskey": "使用 Passkey 需要输入用户名。",
|
||||
"passkeyAuthOptionsFailed": "从服务器获取 Passkey 认证选项失败。",
|
||||
"passkeyAuthFailed": "Passkey 认证失败。请重试或使用密码登录。"
|
||||
},
|
||||
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。",
|
||||
"passkeyLoginButton": "使用 Passkey 登录"
|
||||
"recaptchaV3Notice": "此网站受 reCAPTCHA 保护,并适用 Google 隐私政策和服务条款。"
|
||||
},
|
||||
"connections": {
|
||||
"addConnection": "添加新连接",
|
||||
@@ -526,19 +529,30 @@
|
||||
}
|
||||
},
|
||||
"passkey": {
|
||||
"title": "Passkey 设置",
|
||||
"title": "Passkey 管理",
|
||||
"description": "使用 Passkey(生物识别或安全密钥)进行无密码认证,提升账户安全性和登录便捷性。",
|
||||
"nameLabel": "Passkey 名称",
|
||||
"namePlaceholder": "例如:我的笔记本电脑",
|
||||
"registerButton": "注册新 Passkey",
|
||||
"registerNewButton": "注册新 Passkey",
|
||||
"registeredKeysTitle": "已注册的 Passkey",
|
||||
"unnamedKey": "未命名 Passkey",
|
||||
"createdDate": "创建于",
|
||||
"lastUsedDate": "上次使用",
|
||||
"noKeysRegistered": "尚未注册任何 Passkey。",
|
||||
"confirmDelete": "确定要删除此 Passkey 吗?此操作无法撤销。",
|
||||
"error": {
|
||||
"nameRequired": "请输入 Passkey 名称。",
|
||||
"cancelled": "Passkey 注册已被用户取消。",
|
||||
"genericRegistration": "无法注册 Passkey: {message}",
|
||||
"verificationFailed": "注册失败: {message}"
|
||||
"verificationFailed": "注册失败: {message}",
|
||||
"userNotLoggedIn": "用户未登录或用户名不可用。",
|
||||
"registrationCancelled": "Passkey 注册已取消。",
|
||||
"registrationFailed": "Passkey 注册失败。",
|
||||
"deleteFailedGeneral": "删除 Passkey 失败。请重试。"
|
||||
},
|
||||
"success": {
|
||||
"registered": "Passkey 注册成功!"
|
||||
"registered": "新的 Passkey 已成功注册!",
|
||||
"deleted": "Passkey 已成功删除。"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
|
||||
@@ -11,6 +11,18 @@ interface UserInfo {
|
||||
language?: 'en' | 'zh'; // 新增:用户偏好语言
|
||||
}
|
||||
|
||||
// Passkey Information Interface
|
||||
interface PasskeyInfo {
|
||||
credentialID: string;
|
||||
publicKey: string; // Or a more specific type if available
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransport[]; // e.g., "usb", "nfc", "ble", "internal"
|
||||
creationDate: string; // ISO date string
|
||||
lastUsedDate: string; // ISO date string
|
||||
name?: string; // User-friendly name for the passkey
|
||||
// Add other relevant fields from your backend response
|
||||
}
|
||||
|
||||
// 新增:登录请求的载荷接口
|
||||
interface LoginPayload {
|
||||
username: string;
|
||||
@@ -36,8 +48,6 @@ interface FullCaptchaSettings {
|
||||
recaptchaSecretKey?: string; // We won't use this in authStore
|
||||
}
|
||||
|
||||
// Removed PasskeyInfo interface
|
||||
|
||||
|
||||
// Auth Store State 接口
|
||||
interface AuthState {
|
||||
@@ -53,7 +63,8 @@ interface AuthState {
|
||||
};
|
||||
needsSetup: boolean; // 新增:是否需要初始设置
|
||||
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
|
||||
// Removed Passkey state properties
|
||||
passkeys: PasskeyInfo[] | null; // NEW: Store for user's passkeys
|
||||
passkeysLoading: boolean; // NEW: Loading state for passkeys
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
@@ -66,7 +77,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
|
||||
needsSetup: false, // 初始假设不需要设置
|
||||
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
|
||||
// Removed Passkey state initialization
|
||||
passkeys: null, // Initialize passkeys as null
|
||||
passkeysLoading: false, // Initialize passkeysLoading as false
|
||||
}),
|
||||
getters: {
|
||||
// 可以添加一些 getter,例如获取用户名
|
||||
@@ -343,7 +355,115 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
|
||||
// --- Passkey Actions Removed ---
|
||||
// --- Passkey Actions ---
|
||||
async loginWithPasskey(username: string, assertionResponse: any) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.loginRequires2FA = false; // Passkey login bypasses traditional 2FA
|
||||
try {
|
||||
const response = await apiClient.post<{ message: string; user: UserInfo }>('/auth/passkey/authenticate', {
|
||||
username,
|
||||
assertionResponse,
|
||||
});
|
||||
|
||||
this.isAuthenticated = true;
|
||||
this.user = response.data.user;
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
async getPasskeyRegistrationOptions(username: string) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await apiClient.post('/auth/passkey/registration-options', { username });
|
||||
return response.data; // Returns FIDO2 creation options
|
||||
} catch (err: any) {
|
||||
console.error('获取 Passkey 注册选项失败:', err);
|
||||
this.error = err.response?.data?.message || err.message || '获取 Passkey 注册选项失败。';
|
||||
throw new Error(this.error ?? '获取 Passkey 注册选项失败。');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async registerPasskey(username: string, registrationResponse: any) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await apiClient.post('/auth/passkey/register', {
|
||||
username,
|
||||
registrationResponse,
|
||||
});
|
||||
console.log('Passkey 注册成功');
|
||||
// Optionally, refresh user data or passkeys list if applicable
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Passkey 注册失败:', err);
|
||||
this.error = err.response?.data?.message || err.message || 'Passkey 注册失败。';
|
||||
throw new Error(this.error ?? 'Passkey 注册失败。');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Action to fetch user's passkeys
|
||||
async fetchPasskeys() {
|
||||
if (!this.isAuthenticated) {
|
||||
console.warn('User not authenticated. Cannot fetch passkeys.');
|
||||
this.passkeys = null;
|
||||
return;
|
||||
}
|
||||
this.passkeysLoading = true;
|
||||
this.error = null; // Clear previous errors
|
||||
try {
|
||||
const response = await apiClient.get<PasskeyInfo[]>('/auth/user/passkeys');
|
||||
this.passkeys = response.data;
|
||||
console.log('Passkeys fetched successfully:', this.passkeys);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch passkeys:', err);
|
||||
this.error = err.response?.data?.message || err.message || 'Failed to load passkeys.';
|
||||
this.passkeys = null; // Clear passkeys on error
|
||||
} finally {
|
||||
this.passkeysLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Action to delete a passkey
|
||||
async deletePasskey(credentialID: string) {
|
||||
if (!this.isAuthenticated) {
|
||||
throw new Error('User not authenticated. Cannot delete passkey.');
|
||||
}
|
||||
this.isLoading = true; // Use general isLoading or a specific one for this action
|
||||
this.error = null;
|
||||
try {
|
||||
await apiClient.delete(`/auth/user/passkeys/${credentialID}`);
|
||||
console.log(`Passkey ${credentialID} deleted successfully.`);
|
||||
// Refresh the passkey list
|
||||
await this.fetchPasskeys();
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to delete passkey ${credentialID}:`, err);
|
||||
this.error = err.response?.data?.message || err.message || 'Failed to delete passkey.';
|
||||
throw new Error(this.error ?? 'Failed to delete passkey.');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
persist: true, // Revert to simple persistence to fix TS error for now
|
||||
});
|
||||
|
||||
@@ -85,4 +85,12 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
// Passkey Management
|
||||
export const fetchPasskeys = () => {
|
||||
return apiClient.get('/auth/user/passkeys');
|
||||
};
|
||||
|
||||
export const deletePasskey = (credentialID: string) => {
|
||||
return apiClient.delete(`/auth/user/passkeys/${credentialID}`);
|
||||
};
|
||||
export default apiClient;
|
||||
@@ -2,7 +2,7 @@
|
||||
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// Removed Passkey import: import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
|
||||
@@ -98,7 +98,48 @@ onMounted(() => {
|
||||
authStore.fetchCaptchaConfig();
|
||||
});
|
||||
|
||||
// --- Passkey Login Handler Removed ---
|
||||
// --- Passkey Login Handler ---
|
||||
const handlePasskeyLogin = async () => {
|
||||
// TODO: Implement Passkey login logic
|
||||
// 1. Get username (assume it's available in credentials.username for now)
|
||||
if (!credentials.username) {
|
||||
// TODO: Handle missing username, maybe show an error
|
||||
alert(t('login.error.usernameRequiredForPasskey'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null; // Clear previous errors
|
||||
|
||||
// Step 1: Get authentication options from the server
|
||||
const optionsResponse = await fetch('/api/v1/auth/passkey/authentication-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: credentials.username }),
|
||||
});
|
||||
if (!optionsResponse.ok) {
|
||||
const errData = await optionsResponse.json();
|
||||
throw new Error(errData.message || t('login.error.passkeyAuthOptionsFailed'));
|
||||
}
|
||||
const authOptions = await optionsResponse.json();
|
||||
|
||||
// Step 2: Use WebAuthn API to authenticate
|
||||
const authenticationResult = await startAuthentication(authOptions);
|
||||
|
||||
// Step 3: Send authentication result to the server
|
||||
await authStore.loginWithPasskey(credentials.username, authenticationResult);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Passkey login error:', err);
|
||||
error.value = err.message || t('login.error.passkeyAuthFailed');
|
||||
// Potentially reset CAPTCHA if it was involved, though typically not for passkey flows directly
|
||||
// if (publicCaptchaConfig.value?.enabled) {
|
||||
// resetCaptchaWidget();
|
||||
// }
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
@@ -198,8 +239,13 @@ onMounted(() => {
|
||||
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
|
||||
</button>
|
||||
|
||||
<!-- Passkey Login Button Removed -->
|
||||
|
||||
<!-- Passkey Login Button -->
|
||||
<div class="mt-4 text-center">
|
||||
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-secondary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
{{ isLoading ? t('login.loggingIn') : t('login.loginWithPasskey') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,49 @@
|
||||
</form>
|
||||
</div>
|
||||
<hr class="border-border/50">
|
||||
<!-- Passkey Section Removed -->
|
||||
<!-- Passkey Management -->
|
||||
<div class="settings-section-content">
|
||||
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.passkey.title') }}</h3>
|
||||
<p class="text-sm text-text-secondary mb-4">{{ $t('settings.passkey.description') }}</p>
|
||||
<button @click="handleRegisterNewPasskey" :disabled="passkeyLoading"
|
||||
class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed transition duration-150 ease-in-out text-sm font-medium">
|
||||
{{ passkeyLoading ? $t('common.loading') : $t('settings.passkey.registerNewButton') }}
|
||||
</button>
|
||||
<p v-if="passkeyMessage" :class="['mt-3 text-sm', passkeySuccess ? 'text-success' : 'text-error']">{{ passkeyMessage }}</p>
|
||||
|
||||
<!-- Display list of registered passkeys -->
|
||||
<div class="mt-6">
|
||||
<h4 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.passkey.registeredKeysTitle') }}</h4>
|
||||
<div v-if="authStore.passkeysLoading" class="p-4 text-center text-text-secondary italic">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="authStore.passkeys && authStore.passkeys.length > 0">
|
||||
<ul class="space-y-3">
|
||||
<li v-for="key in authStore.passkeys" :key="key.credentialID" class="flex flex-col sm:flex-row justify-between items-start sm:items-center p-3 border border-border rounded-md bg-header/20 hover:bg-header/40 transition-colors duration-150">
|
||||
<div class="flex-grow mb-2 sm:mb-0">
|
||||
<span class="block font-medium text-foreground text-sm">
|
||||
{{ key.name || $t('settings.passkey.unnamedKey') }}
|
||||
<span class="text-xs text-text-tertiary ml-1">(ID: ...{{ key.credentialID.slice(-8) }})</span>
|
||||
</span>
|
||||
<div class="text-xs text-text-secondary mt-1 space-x-2">
|
||||
<span>{{ $t('settings.passkey.createdDate') }}: {{ formatDate(key.creationDate) }}</span>
|
||||
<span v-if="key.lastUsedDate">{{ $t('settings.passkey.lastUsedDate') }}: {{ formatDate(key.lastUsedDate) }}</span>
|
||||
<span v-if="key.transports && key.transports.length > 0" class="capitalize">({{ key.transports.join(', ') }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="handleDeletePasskey(key.credentialID)"
|
||||
:disabled="passkeyDeleteLoadingStates[key.credentialID]"
|
||||
class="px-3 py-1.5 bg-error text-error-text rounded-md 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 self-start sm:self-center">
|
||||
{{ passkeyDeleteLoadingStates[key.credentialID] ? $t('common.loading') : $t('common.delete') }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p v-else class="text-sm text-text-secondary italic">{{ $t('settings.passkey.noKeysRegistered') }}</p>
|
||||
<p v-if="passkeyDeleteError" class="mt-3 text-sm text-error">{{ passkeyDeleteError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="border-border/50">
|
||||
<!-- 2FA -->
|
||||
<div class="settings-section-content">
|
||||
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.twoFactor.title') }}</h3>
|
||||
@@ -670,7 +712,7 @@ import { storeToRefs } from 'pinia';
|
||||
import { availableLocales } from '../i18n'; // 导入可用语言列表
|
||||
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
||||
import { isAxiosError } from 'axios'; // 单独导入 isAxiosError
|
||||
// Removed Passkey import: import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -708,8 +750,8 @@ const {
|
||||
terminalScrollbackLimitNumber, // NEW: Import terminal scrollback limit getter
|
||||
fileManagerShowDeleteConfirmationBoolean, // NEW: Import file manager delete confirmation getter
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
// Removed Passkey state import from authStore
|
||||
|
||||
const { passkeys, passkeysLoading } = storeToRefs(authStore); // Import passkey state
|
||||
|
||||
|
||||
// --- Local state for forms ---
|
||||
@@ -803,8 +845,13 @@ const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the
|
||||
const captchaLoading = ref(false);
|
||||
const captchaMessage = ref('');
|
||||
const captchaSuccess = ref(false);
|
||||
// Removed Passkey Deletion State
|
||||
|
||||
// --- Passkey State ---
|
||||
const passkeyLoading = ref(false);
|
||||
const passkeyMessage = ref('');
|
||||
const passkeySuccess = ref(false);
|
||||
const passkeyDeleteLoadingStates = reactive<Record<string, boolean>>({});
|
||||
const passkeyDeleteError = ref<string | null>(null);
|
||||
|
||||
// 提供一些常用的时区供选择
|
||||
const commonTimezones = ref([
|
||||
@@ -1128,13 +1175,86 @@ const openStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(true);
|
||||
};
|
||||
|
||||
// --- Passkey state & methods Removed ---
|
||||
// --- Passkey Methods ---
|
||||
const handleRegisterNewPasskey = async () => {
|
||||
passkeyLoading.value = true;
|
||||
passkeyMessage.value = '';
|
||||
passkeySuccess.value = false;
|
||||
|
||||
const username = authStore.user?.username;
|
||||
if (!username) {
|
||||
passkeyMessage.value = t('settings.passkey.error.userNotLoggedIn');
|
||||
passkeyLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Get registration options from the server
|
||||
const registrationOptions = await authStore.getPasskeyRegistrationOptions(username);
|
||||
|
||||
// 2. Start WebAuthn registration ceremony
|
||||
const registrationResult = await startRegistration(registrationOptions);
|
||||
|
||||
// 3. Send registration result to the server
|
||||
await authStore.registerPasskey(username, registrationResult);
|
||||
|
||||
passkeyMessage.value = t('settings.passkey.success.registered');
|
||||
passkeySuccess.value = true;
|
||||
await authStore.fetchPasskeys(); // Refresh passkey list
|
||||
} catch (error: any) {
|
||||
console.error('Passkey 注册失败:', error);
|
||||
// Check if the error is from startRegistration (e.g., user cancellation)
|
||||
if (error.name === 'InvalidStateError' || error.message.includes('cancelled')) {
|
||||
passkeyMessage.value = t('settings.passkey.error.registrationCancelled');
|
||||
} else {
|
||||
passkeyMessage.value = error.response?.data?.message || error.message || t('settings.passkey.error.registrationFailed');
|
||||
}
|
||||
passkeySuccess.value = false;
|
||||
} finally {
|
||||
passkeyLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePasskey = async (credentialID: string) => {
|
||||
if (!confirm(t('settings.passkey.confirmDelete'))) return;
|
||||
|
||||
passkeyDeleteLoadingStates[credentialID] = true;
|
||||
passkeyDeleteError.value = null;
|
||||
passkeyMessage.value = ''; // Clear previous general passkey messages
|
||||
try {
|
||||
await authStore.deletePasskey(credentialID);
|
||||
// The authStore.deletePasskey action should internally call fetchPasskeys to refresh the list.
|
||||
// So, no need to call it explicitly here if the store handles it.
|
||||
// If not, uncomment the line below:
|
||||
// await authStore.fetchPasskeys();
|
||||
passkeyMessage.value = t('settings.passkey.success.deleted');
|
||||
passkeySuccess.value = true; // Use general success for feedback
|
||||
} catch (error: any) {
|
||||
console.error(`删除 Passkey ${credentialID} 失败:`, error);
|
||||
passkeyDeleteError.value = error.message || t('settings.passkey.error.deleteFailedGeneral');
|
||||
passkeySuccess.value = false;
|
||||
} finally {
|
||||
passkeyDeleteLoadingStates[credentialID] = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Formatting function (kept in case other parts need it, can be removed if unused) ---
|
||||
const formatDate = (timestamp: number | undefined) => {
|
||||
if (!timestamp) return t('statusMonitor.notAvailable');
|
||||
const formatDate = (dateInput: string | number | Date | undefined): string => {
|
||||
if (!dateInput) return t('statusMonitor.notAvailable');
|
||||
try {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
const date = new Date(dateInput);
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
// Try parsing as seconds if it's a number (common for Unix timestamps)
|
||||
if (typeof dateInput === 'number') {
|
||||
const dateFromSeconds = new Date(dateInput * 1000);
|
||||
if (!isNaN(dateFromSeconds.getTime())) {
|
||||
return dateFromSeconds.toLocaleString();
|
||||
}
|
||||
}
|
||||
return t('statusMonitor.notAvailable');
|
||||
}
|
||||
return date.toLocaleString();
|
||||
} catch (e) {
|
||||
console.error("Error formatting date:", e);
|
||||
return t('statusMonitor.notAvailable');
|
||||
@@ -1439,7 +1559,9 @@ onMounted(async () => {
|
||||
await fetchIpBlacklist(); // Fetch current blacklist entries
|
||||
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
|
||||
await checkLatestVersion(); // <-- Check for latest version on mount
|
||||
// Removed fetchPasskeys call: await authStore.fetchPasskeys();
|
||||
if (authStore.isAuthenticated) {
|
||||
await authStore.fetchPasskeys();
|
||||
}
|
||||
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user