This commit is contained in:
Baobhan Sith
2025-04-25 10:03:56 +08:00
parent 452922724d
commit 5c2a159792
18 changed files with 995 additions and 66 deletions
+4 -2
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.1.4",
"@xterm/addon-search": "^0.15.0",
@@ -22,6 +23,7 @@
"vite-plugin-monaco-editor": "^1.1.0",
"vue": "^3.3.0",
"vue-i18n": "^9.14.4",
"vue-recaptcha-v3": "^2.0.1",
"vue-router": "^4.5.0",
"vuedraggable": "^4.1.0",
"xterm": "^5.3.0",
@@ -29,11 +31,11 @@
"xterm-addon-web-links": "^0.9.0"
},
"devDependencies": {
"@types/node": "^20",
"@types/splitpanes": "^2.2.6",
"@vitejs/plugin-vue": "^4.2.0",
"typescript": "^5.0.0",
"vite": "^5.2.0",
"vue-tsc": "^2.2.8",
"@types/node": "^20"
"vue-tsc": "^2.2.8"
}
}
+25 -1
View File
@@ -98,7 +98,12 @@
"loggingIn": "Logging in...",
"twoFactorPrompt": "Enter your two-factor authentication code:",
"verifyButton": "Verify",
"rememberMe": "Remember Me (7 days)"
"rememberMe": "Remember Me (7 days)",
"captchaPrompt": "Please complete the verification below:",
"error": {
"captchaLoadFailed": "Failed to load CAPTCHA. Please try refreshing.",
"captchaRequired": "Please complete the CAPTCHA verification."
}
},
"connections": {
"addConnection": "Add New Connection",
@@ -602,6 +607,25 @@
"invalidBanDuration": "Ban duration must be a positive integer (seconds).",
"updateConfigFailed": "Failed to update blacklist configuration"
}
},
"captcha": {
"title": "CAPTCHA Settings",
"description": "Configure CAPTCHA verification for the login page to prevent automated attacks.",
"enableLabel": "Enable CAPTCHA on Login Page",
"providerLabel": "CAPTCHA Provider:",
"providerNone": "None (Disabled)",
"hcaptchaHint": "Get keys from",
"recaptchaHint": "Get keys from",
"siteKeyLabel": "Site Key (Public):",
"secretKeyLabel": "Secret Key (Private):",
"secretKeyHint": "Keep this secret. It is stored securely on the server.",
"saveButton": "Save CAPTCHA Settings",
"success": {
"saved": "CAPTCHA settings saved successfully."
},
"error": {
"saveFailed": "Failed to save CAPTCHA settings."
}
}
},
"common": {
+25 -1
View File
@@ -98,7 +98,12 @@
"loggingIn": "正在登录...",
"twoFactorPrompt": "请输入两步验证码:",
"verifyButton": "验证",
"rememberMe": "记住我 (7 天)"
"rememberMe": "记住我 (7 天)",
"captchaPrompt": "请完成下方的验证:",
"error": {
"captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。",
"captchaRequired": "请完成 CAPTCHA 验证。"
}
},
"connections": {
"addConnection": "添加新连接",
@@ -602,6 +607,25 @@
"invalidBanDuration": "封禁时长必须是正整数(秒)。",
"updateConfigFailed": "更新黑名单配置失败"
}
},
"captcha": {
"title": "CAPTCHA 设置",
"description": "为登录页面配置 CAPTCHA 验证,以防止自动化攻击。",
"enableLabel": "在登录页面启用 CAPTCHA",
"providerLabel": "CAPTCHA 提供商:",
"providerNone": "无 (禁用)",
"hcaptchaHint": "请从",
"recaptchaHint": "请从",
"siteKeyLabel": "站点密钥 (公开):",
"secretKeyLabel": "秘密密钥 (私有):",
"secretKeyHint": "请妥善保管此密钥,它将安全地存储在服务器上。",
"saveButton": "保存 CAPTCHA 设置",
"success": {
"saved": "CAPTCHA 设置已成功保存。"
},
"error": {
"saveFailed": "保存 CAPTCHA 设置失败。"
}
}
},
"common": {
+11
View File
@@ -12,6 +12,8 @@ import './style.css';
import '@fortawesome/fontawesome-free/css/all.min.css';
// 导入 splitpanes CSS
import 'splitpanes/dist/splitpanes.css';
// 导入 reCAPTCHA v3
import { VueReCaptcha } from 'vue-recaptcha-v3';
const pinia = createPinia(); // 创建 Pinia 实例
pinia.use(piniaPluginPersistedstate); // 使用持久化插件
@@ -22,6 +24,15 @@ app.use(pinia); // 使用配置好的 Pinia 实例
// 注意:在状态初始化完成前,暂时不 use(router)
app.use(i18n); // 使用 i18n
// 初始化 reCAPTCHA v3
// 重要提示:请将 'YOUR_RECAPTCHA_V3_SITE_KEY' 替换为您从 Google reCAPTCHA 获取的实际 Site Key
app.use(VueReCaptcha, {
siteKey: 'YOUR_RECAPTCHA_V3_SITE_KEY', // <-- 在此处替换您的 Site Key
loaderOptions: {
autoHideBadge: true // 可选:自动隐藏 reCAPTCHA 徽章
}
});
// --- 应用初始化逻辑 ---
// 使用 async IIFE 来允许顶层 await
(async () => {
+42 -38
View File
@@ -3,7 +3,6 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import router from '../router'; // 引入 router 用于重定向
import { setLocale } from '../i18n'; // 导入 setLocale
// 扩展的用户信息接口,包含 2FA 状态
// 扩展的用户信息接口,包含 2FA 状态和语言偏好
interface UserInfo {
id: number;
@@ -19,6 +18,14 @@ interface LoginPayload {
rememberMe?: boolean; // 可选的“记住我”标志
}
// Public CAPTCHA Config Interface (mirrors backend public config)
interface PublicCaptchaConfig {
enabled: boolean;
provider: 'hcaptcha' | 'recaptcha' | 'none';
hcaptchaSiteKey?: string;
recaptchaSiteKey?: string;
}
// Auth Store State 接口
interface AuthState {
isAuthenticated: boolean;
@@ -26,12 +33,13 @@ interface AuthState {
isLoading: boolean;
error: string | null;
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
// 新增:存储 IP 黑名单数据
// 新增:存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持)
ipBlacklist: {
entries: any[]; // TODO: Define a proper type for blacklist entries
total: number;
};
needsSetup: boolean; // 新增:是否需要初始设置
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
}
export const useAuthStore = defineStore('auth', {
@@ -43,20 +51,21 @@ export const useAuthStore = defineStore('auth', {
loginRequires2FA: false, // 初始为不需要
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
needsSetup: false, // 初始假设不需要设置
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
}),
getters: {
// 可以添加一些 getter,例如获取用户名
loggedInUser: (state) => state.user?.username,
},
actions: {
// 登录 Action - 更新为接受 LoginPayload
async login(payload: LoginPayload) {
// 登录 Action - 更新为接受 LoginPayload + optional captchaToken
async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload
this.isLoading = true;
this.error = null;
this.loginRequires2FA = false; // 重置 2FA 状态
try {
// 后端可能返回 user 或 requiresTwoFactor
// 将完整的 payload (包含 rememberMe) 发送给后端
// 将完整的 payload (包含 rememberMe 和 captchaToken) 发送给后端
const response = await apiClient.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/auth/login', payload); // 使用 apiClient
if (response.data.requiresTwoFactor) {
@@ -148,34 +157,6 @@ export const useAuthStore = defineStore('auth', {
}
},
// TODO: 添加检查登录状态的 Action (例如应用启动时调用)
// TODO: 添加检查登录状态的 Action (例如应用启动时调用)
// async checkAuthStatus() {
// const token = localStorage.getItem('authToken'); // 假设 token 存储在 localStorage
// if (token) {
// try {
// // 可选: 向后端发送请求验证 token 有效性
// // const response = await axios.get('/api/v1/auth/me', { headers: { Authorization: `Bearer ${token}` } });
// // this.isAuthenticated = true;
// // this.user = response.data.user;
//
// // 暂时只基于 localStorage 状态恢复
// const storedAuth = localStorage.getItem('auth'); // pinia-plugin-persistedstate 默认 key
// if (storedAuth) {
// const parsedAuth = JSON.parse(storedAuth);
// if (parsedAuth.isAuthenticated && parsedAuth.user) {
// this.isAuthenticated = true;
// this.user = parsedAuth.user;
// console.log('Auth status restored from localStorage');
// }
// }
// } catch (error) {
// console.error('Failed to restore auth status:', error);
// this.logout(); // 如果验证失败或出错,则登出
// }
// }
// }
// 新增:检查并更新认证状态 Action
async checkAuthStatus() {
this.isLoading = true;
@@ -245,9 +226,9 @@ export const useAuthStore = defineStore('auth', {
const response = await apiClient.get('/settings/ip-blacklist', { // 使用 apiClient
params: { limit, offset }
});
// 注意:这里需要将获取到的数据存储在 state 中,
// 但当前 AuthState 没有定义相关字段。
// 暂时只返回数据,需要在 state 中添加 ipBlacklist 字段。
// 更新本地状态
this.ipBlacklist.entries = response.data.entries;
this.ipBlacklist.total = response.data.total;
console.log('获取 IP 黑名单成功:', response.data);
return response.data; // { entries: [], total: number }
} catch (err: any) {
@@ -270,7 +251,9 @@ export const useAuthStore = defineStore('auth', {
try {
await apiClient.delete(`/settings/ip-blacklist/${encodeURIComponent(ip)}`); // 使用 apiClient
console.log(`IP ${ip} 已从黑名单删除`);
// 成功后需要重新获取列表或从本地 state 中移除
// 从本地 state 中移除 (或者重新获取列表)
this.ipBlacklist.entries = this.ipBlacklist.entries.filter(entry => entry.ip !== ip);
this.ipBlacklist.total = Math.max(0, this.ipBlacklist.total - 1);
return true;
} catch (err: any) {
console.error(`删除 IP ${ip} 失败:`, err);
@@ -297,6 +280,27 @@ export const useAuthStore = defineStore('auth', {
return false;
}
},
// NEW: 获取公共 CAPTCHA 配置
async fetchCaptchaConfig() {
// Avoid refetching if already loaded
if (this.publicCaptchaConfig !== null) return;
// Don't set isLoading for this, it should be quick background fetch
try {
console.log('[AuthStore] Fetching public CAPTCHA config...');
const response = await apiClient.get<PublicCaptchaConfig>('/auth/captcha/config');
this.publicCaptchaConfig = response.data;
console.log('[AuthStore] Public CAPTCHA config loaded:', this.publicCaptchaConfig);
} catch (error: any) {
console.error('获取公共 CAPTCHA 配置失败:', error.response?.data?.message || error.message);
// Set a default disabled config on error to prevent blocking login UI
this.publicCaptchaConfig = {
enabled: false,
provider: 'none',
};
}
},
},
persist: true, // 使用默认持久化配置 (localStorage, 持久化所有 state)
persist: true, // Revert to simple persistence to fix TS error for now
});
+96 -1
View File
@@ -3,6 +3,26 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import { ref, computed } from 'vue'; // 移除 watch
import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale
import type { PaneName } from './layout.store'; // +++ Import PaneName type +++
// Import CAPTCHA types from backend (adjust path if needed, assuming types are mirrored or shared)
// For now, let's assume they are available via a shared types definition or manually defined here
// Assuming manual definition for now if no shared types exist:
type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none';
interface CaptchaSettings {
enabled: boolean;
provider: CaptchaProvider;
hcaptchaSiteKey?: string;
hcaptchaSecretKey?: string; // Store locally but don't expose via getters easily
recaptchaSiteKey?: string;
recaptchaSecretKey?: string; // Store locally but don't expose via getters easily
}
interface UpdateCaptchaSettingsDto {
enabled?: boolean;
provider?: CaptchaProvider;
hcaptchaSiteKey?: string;
hcaptchaSecretKey?: string;
recaptchaSiteKey?: string;
recaptchaSecretKey?: string;
}
// 移除 ITheme 和默认主题定义,这些移到 appearance.store.ts
// 定义通用设置状态类型
@@ -32,6 +52,7 @@ export const useSettingsStore = defineStore('settings', () => {
const settings = ref<Partial<SettingsState>>({}); // 通用设置状态
const parsedSidebarPaneWidths = ref<Record<string, string>>({}); // NEW: 解析后的侧边栏宽度对象
const parsedFileManagerColWidths = ref<Record<string, number>>({}); // NEW: 解析后的文件管理器列宽对象
const captchaSettings = ref<CaptchaSettings | null>(null); // NEW: CAPTCHA 设置状态
const isLoading = ref(false);
const error = ref<string | null>(null);
// 移除外观相关状态: isStyleCustomizerVisible, currentUiTheme, currentXtermTheme
@@ -350,6 +371,65 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// --- CAPTCHA Settings Actions ---
/**
* Fetches CAPTCHA settings from the backend.
* Should be called when the settings component mounts.
*/
async function loadCaptchaSettings() {
// Avoid reloading if already loaded, unless forced
// if (captchaSettings.value !== null && !force) return;
isLoading.value = true;
error.value = null;
try {
console.log('[SettingsStore] 加载 CAPTCHA 设置...');
// Use the correct endpoint defined in the backend routes
const response = await apiClient.get<CaptchaSettings>('/settings/captcha');
captchaSettings.value = response.data;
console.log('[SettingsStore] CAPTCHA 设置加载完成:', { ...response.data, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets
} catch (err: any) {
console.error('加载 CAPTCHA 设置失败:', err);
error.value = err.response?.data?.message || err.message || '加载 CAPTCHA 设置失败';
captchaSettings.value = null; // Reset on error
} finally {
isLoading.value = false;
}
}
/**
* Updates CAPTCHA settings on the backend.
* @param updates - An object containing the CAPTCHA settings fields to update.
*/
async function updateCaptchaSettings(updates: UpdateCaptchaSettingsDto) {
isLoading.value = true;
error.value = null;
try {
console.log('[SettingsStore] 更新 CAPTCHA 设置:', { ...updates, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets
// Use the correct endpoint defined in the backend routes
await apiClient.put('/settings/captcha', updates);
// Update local state after successful API call
// Merge updates into the existing state or reload
if (captchaSettings.value) {
captchaSettings.value = { ...captchaSettings.value, ...updates };
} else {
// If settings were null, reload them after update
await loadCaptchaSettings();
}
console.log('[SettingsStore] CAPTCHA 设置更新成功。');
} catch (err: any) {
console.error('更新 CAPTCHA 设置失败:', err);
error.value = err.response?.data?.message || err.message || '更新 CAPTCHA 设置失败';
throw error; // Re-throw to allow component to handle UI feedback
} finally {
isLoading.value = false;
}
}
// 移除外观相关 actions: saveCustomThemes, resetCustomThemes, toggleStyleCustomizer
// --- Getters ---
@@ -411,7 +491,14 @@ export const useSettingsStore = defineStore('settings', () => {
return parsedFileManagerColWidths.value;
});
return {
// --- CAPTCHA Getters (Public Only) ---
const isCaptchaEnabled = computed(() => captchaSettings.value?.enabled ?? false);
const captchaProvider = computed(() => captchaSettings.value?.provider ?? 'none');
const hcaptchaSiteKey = computed(() => captchaSettings.value?.hcaptchaSiteKey ?? '');
const recaptchaSiteKey = computed(() => captchaSettings.value?.recaptchaSiteKey ?? '');
// DO NOT expose secret keys via getters
return {
settings, // 只包含通用设置
isLoading,
error,
@@ -426,6 +513,14 @@ export const useSettingsStore = defineStore('settings', () => {
getSidebarPaneWidth, // +++ 暴露获取特定面板宽度的 getter +++
fileManagerRowSizeMultiplierNumber, // +++ 暴露文件管理器行大小 getter +++
fileManagerColWidthsObject, // +++ 暴露文件管理器列宽 getter +++
// CAPTCHA related exports
captchaSettings, // Expose the full (but reactive) object for the settings page v-model
isCaptchaEnabled,
captchaProvider,
hcaptchaSiteKey,
recaptchaSiteKey,
loadCaptchaSettings,
updateCaptchaSettings,
// 移除外观相关的 getters 和 actions
loadInitialSettings,
updateSetting,
+127 -14
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'; // 导入 ref
import { reactive, ref, onMounted, computed } from 'vue'; // Import onMounted, computed
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth.store';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha'; // <-- Import hCaptcha component
import { useReCaptcha } from 'vue-recaptcha-v3'; // <-- Import reCAPTCHA v3 hook
const { t } = useI18n();
const authStore = useAuthStore();
// 获取 loginRequires2FA 状态
const { isLoading, error, loginRequires2FA } = storeToRefs(authStore);
const { isLoading, error, loginRequires2FA, publicCaptchaConfig } = storeToRefs(authStore); // Get publicCaptchaConfig
// 表单数据
const credentials = reactive({
@@ -16,19 +18,102 @@ const credentials = reactive({
});
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
const rememberMe = ref(false); // 新增:记住我状态,默认为 false
const captchaToken = ref<string | null>(null); // NEW: Store CAPTCHA token
const captchaError = ref<string | null>(null); // NEW: Store CAPTCHA specific error
const hcaptchaWidget = ref<InstanceType<typeof VueHcaptcha> | null>(null); // NEW: Ref for hCaptcha component instance
// --- reCAPTCHA v3 Initialization ---
const recaptchaInstance = useReCaptcha(); // Get the instance, might be undefined
// --- CAPTCHA Event Handlers ---
// TODO: Implement functions to handle successful CAPTCHA completion and token retrieval
const handleCaptchaVerified = (token: string) => {
console.log('CAPTCHA verified, token:', token);
captchaToken.value = token;
captchaError.value = null; // Clear error on successful verification
};
const handleCaptchaExpired = () => {
console.log('CAPTCHA expired');
captchaToken.value = null;
};
const handleCaptchaError = (errorDetails: any) => {
console.error('CAPTCHA error:', errorDetails);
captchaToken.value = null;
captchaError.value = t('login.error.captchaLoadFailed'); // Need translation
};
const resetCaptchaWidget = () => {
console.log('Resetting CAPTCHA widget...');
captchaToken.value = null;
// Reset hCaptcha if it exists
hcaptchaWidget.value?.reset();
// reCAPTCHA v3 doesn't typically need explicit reset in the same way
};
// --- End CAPTCHA Event Handlers ---
// 处理登录或 2FA 验证提交
const handleSubmit = async () => {
if (loginRequires2FA.value) {
// 如果需要 2FA,则调用 2FA 验证 action
await authStore.verifyLogin2FA(twoFactorToken.value);
} else {
// 否则,调用常规登录 action,并传递 rememberMe 状态
await authStore.login({ ...credentials, rememberMe: rememberMe.value });
captchaError.value = null; // Clear previous CAPTCHA error
// --- CAPTCHA Execution & Check ---
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value) {
// If reCAPTCHA v3, execute it now to get the token
if (publicCaptchaConfig.value.provider === 'recaptcha') {
// Check if instance and methods are available
if (recaptchaInstance?.recaptchaLoaded && recaptchaInstance?.executeRecaptcha) {
try {
await recaptchaInstance.recaptchaLoaded(); // Ensure library is loaded
const token = await recaptchaInstance.executeRecaptcha('login'); // Execute with action 'login'
console.log('reCAPTCHA v3 token obtained:', token);
captchaToken.value = token; // Store the obtained token
} catch (reError: any) {
console.error('reCAPTCHA v3 execution failed:', reError);
captchaError.value = t('login.error.captchaLoadFailed');
return; // Stop submission if reCAPTCHA execution fails
}
} else {
// Handle case where reCAPTCHA is not ready/initialized
console.error('reCAPTCHA v3 not initialized or ready.');
captchaError.value = t('login.error.captchaLoadFailed'); // Or a more specific error
return;
}
}
// Check if token exists (for both hCaptcha and reCAPTCHA)
if (!captchaToken.value) {
captchaError.value = t('login.error.captchaRequired'); // Need translation
return; // Stop submission if CAPTCHA is required but not completed/obtained
}
}
// 成功后的重定向由 store action 处理
// 失败会更新 error 状态并在模板中显示
// --- End CAPTCHA Check ---
try {
if (loginRequires2FA.value) {
// 如果需要 2FA,则调用 2FA 验证 action
await authStore.verifyLogin2FA(twoFactorToken.value);
} else {
// 否则,调用常规登录 action,并传递 rememberMe 和 captchaToken 状态
await authStore.login({
...credentials,
rememberMe: rememberMe.value,
captchaToken: captchaToken.value ?? undefined // Pass token or undefined if null
});
}
// 成功后的重定向由 store action 处理
// 失败会更新 error 状态并在模板中显示
} finally {
// Reset CAPTCHA after attempt (success or failure handled by store redirect/error display)
if (publicCaptchaConfig.value?.enabled) {
resetCaptchaWidget(); // Reset the widget for potential retry
}
} // <-- Correctly closing the try block here
// --- Remove the extraneous else block that was causing the syntax error ---
};
// Fetch CAPTCHA config on component mount
onMounted(() => {
authStore.fetchCaptchaConfig();
});
</script>
<template>
@@ -79,11 +164,39 @@ const handleSubmit = async () => {
<label for="twoFactorToken" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.twoFactorPrompt') }}</label>
<input type="text" id="twoFactorToken" v-model="twoFactorToken" required :disabled="isLoading" pattern="\d{6}" title="请输入 6 位数字验证码"
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
</div>
</div>
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
{{ error }}
</div>
<!-- CAPTCHA Area -->
<div v-if="publicCaptchaConfig?.enabled && !loginRequires2FA" class="space-y-2">
<label class="block text-sm font-medium text-text-secondary">{{ t('login.captchaPrompt') }}</label>
<!-- hCaptcha Component -->
<div v-if="publicCaptchaConfig.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
<VueHcaptcha
ref="hcaptchaWidget"
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
@verify="handleCaptchaVerified"
@expired="handleCaptchaExpired"
@error="handleCaptchaError"
theme="auto"
></VueHcaptcha>
</div>
<!-- reCAPTCHA v3 Info (usually invisible) -->
<div v-else-if="publicCaptchaConfig.provider === 'recaptcha'">
<p class="text-xs text-text-secondary italic">
{{ t('login.recaptchaV3Notice', 'This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.') }}
</p>
<!-- v3 is typically invisible, token obtained programmatically on submit -->
</div>
<!-- CAPTCHA Error Message -->
<div v-if="captchaError" class="text-error text-sm">
{{ captchaError }}
</div>
</div>
<!-- General Login Error -->
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
{{ error }}
</div>
<button type="submit" :disabled="isLoading"
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">
+160 -2
View File
@@ -141,6 +141,76 @@
</div>
</form>
</div>
<hr class="border-border/50"> <!-- Separator -->
<!-- CAPTCHA Settings -->
<div class="settings-section-content">
<h3 class="text-base font-semibold text-foreground mb-3">{{ $t('settings.captcha.title') }}</h3>
<p class="text-sm text-text-secondary mb-4">{{ $t('settings.captcha.description') }}</p>
<div v-if="!captchaSettings" class="p-4 text-center text-text-secondary italic">
{{ $t('common.loading') }}
</div>
<form v-else @submit.prevent="handleUpdateCaptchaSettings" class="space-y-4">
<!-- Enable Switch -->
<div class="flex items-center">
<input type="checkbox" id="captchaEnabled" v-model="captchaForm.enabled"
class="h-4 w-4 rounded border-border text-primary focus:ring-primary mr-2 cursor-pointer">
<label for="captchaEnabled" class="text-sm text-foreground cursor-pointer select-none">{{ $t('settings.captcha.enableLabel') }}</label>
</div>
<!-- Provider Select (Only show if enabled) -->
<div v-if="captchaForm.enabled">
<label for="captchaProvider" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.providerLabel') }}</label>
<select id="captchaProvider" v-model="captchaForm.provider"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8"
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
<option value="none">{{ $t('settings.captcha.providerNone') }}</option>
<option value="hcaptcha">hCaptcha</option>
<option value="recaptcha">Google reCAPTCHA v2</option>
</select>
</div>
<!-- hCaptcha Settings (Only show if enabled and provider is hcaptcha) -->
<div v-if="captchaForm.enabled && captchaForm.provider === 'hcaptcha'" class="space-y-4 pl-4 border-l-2 border-border/50 ml-1 pt-2">
<p class="text-xs text-text-secondary">{{ $t('settings.captcha.hcaptchaHint') }} <a href="https://www.hcaptcha.com/" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">hCaptcha.com</a></p>
<div>
<label for="hcaptchaSiteKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.siteKeyLabel') }}</label>
<input type="text" id="hcaptchaSiteKey" v-model="captchaForm.hcaptchaSiteKey"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="hcaptchaSecretKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.secretKeyLabel') }}</label>
<input type="password" id="hcaptchaSecretKey" v-model="captchaForm.hcaptchaSecretKey" placeholder="••••••••••••" autocomplete="new-password"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
<small class="block mt-1 text-xs text-text-secondary">{{ $t('settings.captcha.secretKeyHint') }}</small>
</div>
</div>
<!-- reCAPTCHA Settings (Only show if enabled and provider is recaptcha) -->
<div v-if="captchaForm.enabled && captchaForm.provider === 'recaptcha'" class="space-y-4 pl-4 border-l-2 border-border/50 ml-1 pt-2">
<p class="text-xs text-text-secondary">{{ $t('settings.captcha.recaptchaHint') }} <a href="https://www.google.com/recaptcha/" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline">Google reCAPTCHA</a></p>
<div>
<label for="recaptchaSiteKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.siteKeyLabel') }}</label>
<input type="text" id="recaptchaSiteKey" v-model="captchaForm.recaptchaSiteKey"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
</div>
<div>
<label for="recaptchaSecretKey" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('settings.captcha.secretKeyLabel') }}</label>
<input type="password" id="recaptchaSecretKey" v-model="captchaForm.recaptchaSecretKey" placeholder="••••••••••••" autocomplete="new-password"
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary">
<small class="block mt-1 text-xs text-text-secondary">{{ $t('settings.captcha.secretKeyHint') }}</small>
</div>
</div>
<!-- Save Button & Message -->
<div class="flex items-center justify-between pt-2">
<button type="submit" :disabled="captchaLoading"
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">
{{ captchaLoading ? $t('common.saving') : $t('settings.captcha.saveButton') }}
</button>
<p v-if="captchaMessage" :class="['text-sm', captchaSuccess ? 'text-success' : 'text-error']">{{ captchaMessage }}</p>
</div>
</form>
</div>
</div>
</div>
@@ -395,6 +465,16 @@
</template>
<script setup lang="ts">
// Define necessary types locally if not shared
type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none';
interface UpdateCaptchaSettingsDto {
enabled?: boolean;
provider?: CaptchaProvider;
hcaptchaSiteKey?: string;
hcaptchaSecretKey?: string;
recaptchaSiteKey?: string;
recaptchaSecretKey?: string;
}
import { ref, onMounted, computed, reactive, watch } from 'vue';
import { useAuthStore } from '../stores/auth.store';
import { useSettingsStore } from '../stores/settings.store';
@@ -413,7 +493,19 @@ const { t } = useI18n();
// --- Reactive state from store ---
// 使用 storeToRefs 获取响应式 getter,包括 language
const { settings, isLoading: settingsLoading, error: settingsError, showPopupFileEditorBoolean, shareFileEditorTabsBoolean, autoCopyOnSelectBoolean, dockerDefaultExpandBoolean, statusMonitorIntervalSecondsNumber, language: storeLanguage, workspaceSidebarPersistentBoolean } = storeToRefs(settingsStore); // +++ 添加 workspaceSidebarPersistentBoolean getter +++
const {
settings,
isLoading: settingsLoading,
error: settingsError,
showPopupFileEditorBoolean,
shareFileEditorTabsBoolean,
autoCopyOnSelectBoolean,
dockerDefaultExpandBoolean,
statusMonitorIntervalSecondsNumber,
language: storeLanguage,
workspaceSidebarPersistentBoolean,
captchaSettings, // <-- Import CAPTCHA settings state
} = storeToRefs(settingsStore);
// --- Local state for forms ---
const ipWhitelistInput = ref('');
@@ -460,6 +552,19 @@ const workspaceSidebarPersistentLoading = ref(false); // 新增
const workspaceSidebarPersistentMessage = ref(''); // 新增
const workspaceSidebarPersistentSuccess = ref(false); // 新增
// CAPTCHA Form State
const captchaForm = reactive<UpdateCaptchaSettingsDto>({ // Use reactive for the form object
enabled: false,
provider: 'none',
hcaptchaSiteKey: '',
hcaptchaSecretKey: '',
recaptchaSiteKey: '',
recaptchaSecretKey: '',
});
const captchaLoading = ref(false);
const captchaMessage = ref('');
const captchaSuccess = ref(false);
// --- Watcher to sync local form state with store state ---
watch(settings, (newSettings, oldSettings) => {
@@ -482,6 +587,27 @@ watch(settings, (newSettings, oldSettings) => {
}, { deep: true, immediate: true }); // immediate: true to run on initial load
// Watcher for CAPTCHA settings
watch(captchaSettings, (newCaptchaSettings) => {
if (newCaptchaSettings) {
captchaForm.enabled = newCaptchaSettings.enabled;
captchaForm.provider = newCaptchaSettings.provider;
captchaForm.hcaptchaSiteKey = newCaptchaSettings.hcaptchaSiteKey || '';
captchaForm.hcaptchaSecretKey = newCaptchaSettings.hcaptchaSecretKey || ''; // Keep secret keys local
captchaForm.recaptchaSiteKey = newCaptchaSettings.recaptchaSiteKey || '';
captchaForm.recaptchaSecretKey = newCaptchaSettings.recaptchaSecretKey || ''; // Keep secret keys local
} else {
// Reset form if settings are null (e.g., on error)
captchaForm.enabled = false;
captchaForm.provider = 'none';
captchaForm.hcaptchaSiteKey = '';
captchaForm.hcaptchaSecretKey = '';
captchaForm.recaptchaSiteKey = '';
captchaForm.recaptchaSecretKey = '';
}
}, { immediate: true }); // immediate: true to run on initial load
// --- Popup Editor setting method ---
const handleUpdatePopupEditorSetting = async () => {
popupEditorLoading.value = true;
@@ -849,10 +975,42 @@ const handleUpdateBlacklistSettings = async () => {
}
};
// --- CAPTCHA Settings Method ---
const handleUpdateCaptchaSettings = async () => {
captchaLoading.value = true;
captchaMessage.value = '';
captchaSuccess.value = false;
try {
// Prepare DTO, ensuring keys are present even if empty
const dto: UpdateCaptchaSettingsDto = {
enabled: captchaForm.enabled,
provider: captchaForm.provider,
hcaptchaSiteKey: captchaForm.hcaptchaSiteKey || '',
hcaptchaSecretKey: captchaForm.hcaptchaSecretKey || '', // Send secret key
recaptchaSiteKey: captchaForm.recaptchaSiteKey || '',
recaptchaSecretKey: captchaForm.recaptchaSecretKey || '', // Send secret key
};
await settingsStore.updateCaptchaSettings(dto);
captchaMessage.value = t('settings.captcha.success.saved'); // Need translation
captchaSuccess.value = true;
// Clear secret key fields in the form after successful save for security
captchaForm.hcaptchaSecretKey = '';
captchaForm.recaptchaSecretKey = '';
} catch (error: any) {
console.error('更新 CAPTCHA 设置失败:', error);
captchaMessage.value = error.message || t('settings.captcha.error.saveFailed'); // Need translation
captchaSuccess.value = false;
} finally {
captchaLoading.value = false;
}
};
// --- Lifecycle Hooks ---
onMounted(async () => {
await checkTwoFactorStatus(); // Check 2FA status
await fetchIpBlacklist(); // Fetch current blacklist entries
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
});
@@ -861,4 +1019,4 @@ onMounted(async () => {
<style scoped>
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
</style>
]]>