update
This commit is contained in:
Generated
+33
@@ -495,6 +495,18 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@hcaptcha/vue3-hcaptcha": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hcaptcha/vue3-hcaptcha/-/vue3-hcaptcha-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.2.19"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@hexagon/base64": {
|
"node_modules/@hexagon/base64": {
|
||||||
"version": "1.1.28",
|
"version": "1.1.28",
|
||||||
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
|
||||||
@@ -5605,6 +5617,12 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recaptcha-v3": {
|
||||||
|
"version": "1.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/recaptcha-v3/-/recaptcha-v3-1.11.3.tgz",
|
||||||
|
"integrity": "sha512-sEE6J0RzUkS+sKEBpgCD/AqCU0ffrAVOADGjvAx9vcttN+VLK42SWMkj/J/I6vHu3Kew+xcfbBqDVb65N0QGDw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/regenerator-runtime": {
|
"node_modules/regenerator-runtime": {
|
||||||
"version": "0.14.1",
|
"version": "0.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||||
@@ -7039,6 +7057,18 @@
|
|||||||
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-recaptcha-v3": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-recaptcha-v3/-/vue-recaptcha-v3-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-isEDtOfHU4wWRrZZuxciAELtQmPOeEEdicPNa0f1AOyLPy3sCcBEcpFt+FOcO3RQv5unJ3Yn5NlsWtXv9rXqjg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"recaptcha-v3": "^1.8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.5.0",
|
"version": "4.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz",
|
||||||
@@ -7346,6 +7376,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
@@ -7358,6 +7389,7 @@
|
|||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
"vue-i18n": "^9.14.4",
|
"vue-i18n": "^9.14.4",
|
||||||
|
"vue-recaptcha-v3": "^2.0.1",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
@@ -7365,6 +7397,7 @@
|
|||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
"@types/splitpanes": "^2.2.6",
|
"@types/splitpanes": "^2.2.6",
|
||||||
"@vitejs/plugin-vue": "^4.2.0",
|
"@vitejs/plugin-vue": "^4.2.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { PasskeyService } from '../services/passkey.service'; // 导入 PasskeyS
|
|||||||
import { NotificationService } from '../services/notification.service'; // 导入 NotificationService
|
import { NotificationService } from '../services/notification.service'; // 导入 NotificationService
|
||||||
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService
|
import { AuditLogService } from '../services/audit.service'; // 导入 AuditLogService
|
||||||
import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务
|
import { ipBlacklistService } from '../services/ip-blacklist.service'; // 导入 IP 黑名单服务
|
||||||
|
import { captchaService } from '../services/captcha.service'; // <-- Import CaptchaService
|
||||||
|
import { settingsService } from '../services/settings.service'; // <-- Import SettingsService for config check
|
||||||
|
|
||||||
// Remove top-level db instance acquisition
|
// Remove top-level db instance acquisition
|
||||||
// const db = getDb();
|
// const db = getDb();
|
||||||
@@ -51,6 +53,36 @@ export const login = async (req: Request, res: Response): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// --- CAPTCHA Verification Step ---
|
||||||
|
const captchaConfig = await settingsService.getCaptchaConfig();
|
||||||
|
if (captchaConfig.enabled) {
|
||||||
|
const { captchaToken } = req.body;
|
||||||
|
if (!captchaToken) {
|
||||||
|
console.log(`[AuthController] 登录尝试失败: CAPTCHA 已启用但未提供令牌 - ${username}`);
|
||||||
|
// 记录审计日志等(可选,看是否需要区分)
|
||||||
|
return res.status(400).json({ message: '需要提供 CAPTCHA 令牌。' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isCaptchaValid = await captchaService.verifyToken(captchaToken);
|
||||||
|
if (!isCaptchaValid) {
|
||||||
|
console.log(`[AuthController] 登录尝试失败: CAPTCHA 验证失败 - ${username}`);
|
||||||
|
const clientIp = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
ipBlacklistService.recordFailedAttempt(clientIp); // Record failed attempt for invalid CAPTCHA
|
||||||
|
auditLogService.logAction('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
|
||||||
|
notificationService.sendNotification('LOGIN_FAILURE', { username, reason: 'Invalid CAPTCHA token', ip: clientIp });
|
||||||
|
return res.status(401).json({ message: 'CAPTCHA 验证失败。' });
|
||||||
|
}
|
||||||
|
console.log(`[AuthController] CAPTCHA 验证成功 - ${username}`);
|
||||||
|
} catch (captchaError: any) {
|
||||||
|
console.error(`[AuthController] CAPTCHA 验证过程中出错 (${username}):`, captchaError.message);
|
||||||
|
// 如果是配置错误或 API 请求失败,返回 500
|
||||||
|
return res.status(500).json({ message: 'CAPTCHA 验证服务出错,请稍后重试或检查配置。' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[AuthController] CAPTCHA 未启用,跳过验证 - ${username}`);
|
||||||
|
}
|
||||||
|
// --- End CAPTCHA Verification ---
|
||||||
|
|
||||||
const db = await getDbInstance(); // Get DB instance inside the function
|
const db = await getDbInstance(); // Get DB instance inside the function
|
||||||
// Use the promisified getDb helper
|
// Use the promisified getDb helper
|
||||||
const user = await getDb<User>(db, 'SELECT id, username, hashed_password, two_factor_secret FROM users WHERE username = ?', [username]);
|
const user = await getDb<User>(db, 'SELECT id, username, hashed_password, two_factor_secret FROM users WHERE username = ?', [username]);
|
||||||
@@ -774,3 +806,35 @@ export const logout = (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取公共 CAPTCHA 配置 (GET /api/v1/auth/captcha/config)
|
||||||
|
* 返回给前端用于显示 CAPTCHA 小部件所需的信息 (不含密钥)。
|
||||||
|
*/
|
||||||
|
export const getPublicCaptchaConfig = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
console.log('[AuthController] Received request for public CAPTCHA config.');
|
||||||
|
const fullConfig = await settingsService.getCaptchaConfig(); // Use settingsService
|
||||||
|
|
||||||
|
// *** IMPORTANT: Filter out secret keys before sending to frontend ***
|
||||||
|
const publicConfig = {
|
||||||
|
enabled: fullConfig.enabled,
|
||||||
|
provider: fullConfig.provider,
|
||||||
|
hcaptchaSiteKey: fullConfig.hcaptchaSiteKey,
|
||||||
|
recaptchaSiteKey: fullConfig.recaptchaSiteKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[AuthController] Sending public CAPTCHA config to client:', publicConfig);
|
||||||
|
res.status(200).json(publicConfig);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[AuthController] 获取公共 CAPTCHA 配置时出错:', error);
|
||||||
|
// 即使出错,也返回一个“禁用”状态,避免前端出错
|
||||||
|
res.status(500).json({
|
||||||
|
enabled: false,
|
||||||
|
provider: 'none',
|
||||||
|
hcaptchaSiteKey: '',
|
||||||
|
recaptchaSiteKey: '',
|
||||||
|
error: '获取 CAPTCHA 配置失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ import {
|
|||||||
verifyPasskeyRegistration, // 导入 Passkey 方法
|
verifyPasskeyRegistration, // 导入 Passkey 方法
|
||||||
needsSetup, // 导入 needsSetup 控制器
|
needsSetup, // 导入 needsSetup 控制器
|
||||||
setupAdmin, // 导入 setupAdmin 控制器
|
setupAdmin, // 导入 setupAdmin 控制器
|
||||||
logout // *** 新增:导入 logout 控制器 ***
|
logout, // *** 新增:导入 logout 控制器 ***
|
||||||
|
getPublicCaptchaConfig // <-- Import public CAPTCHA config controller
|
||||||
} from './auth.controller';
|
} from './auth.controller';
|
||||||
import { isAuthenticated } from './auth.middleware';
|
import { isAuthenticated } from './auth.middleware';
|
||||||
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件
|
import { ipBlacklistCheckMiddleware } from './ipBlacklistCheck.middleware'; // 导入 IP 黑名单检查中间件
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// --- Public CAPTCHA Configuration ---
|
||||||
|
// GET /api/v1/auth/captcha/config - 获取公共 CAPTCHA 配置 (公开访问)
|
||||||
|
router.get('/captcha/config', getPublicCaptchaConfig);
|
||||||
|
|
||||||
|
// --- Setup Routes (Public) ---
|
||||||
// GET /api/v1/auth/needs-setup - 检查是否需要初始设置 (公开访问)
|
// GET /api/v1/auth/needs-setup - 检查是否需要初始设置 (公开访问)
|
||||||
router.get('/needs-setup', needsSetup);
|
router.get('/needs-setup', needsSetup);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// packages/backend/src/repositories/settings.repository.ts
|
// packages/backend/src/repositories/settings.repository.ts
|
||||||
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/connection';
|
||||||
import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types'; // <-- Import LayoutNode and PaneName
|
import { SidebarConfig, LayoutNode, PaneName } from '../types/settings.types'; // <-- Import LayoutNode and PaneName
|
||||||
|
import { CaptchaSettings } from '../types/settings.types'; // <-- Import CaptchaSettings
|
||||||
import * as sqlite3 from 'sqlite3'; // Import sqlite3 for Database type hint
|
import * as sqlite3 from 'sqlite3'; // Import sqlite3 for Database type hint
|
||||||
|
|
||||||
// Define keys for specific settings
|
// Define keys for specific settings
|
||||||
const SIDEBAR_CONFIG_KEY = 'sidebarConfig';
|
const SIDEBAR_CONFIG_KEY = 'sidebarConfig';
|
||||||
|
const CAPTCHA_CONFIG_KEY = 'captchaConfig'; // <-- Add key for CAPTCHA settings
|
||||||
|
|
||||||
export interface Setting {
|
export interface Setting {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -145,6 +147,74 @@ export const setSidebarConfig = async (config: SidebarConfig): Promise<void> =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- CAPTCHA Settings ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 CAPTCHA 配置
|
||||||
|
* @returns Promise<CaptchaSettings> - 返回解析后的配置或默认值
|
||||||
|
*/
|
||||||
|
export const getCaptchaConfig = async (): Promise<CaptchaSettings> => {
|
||||||
|
const defaultValue: CaptchaSettings = {
|
||||||
|
enabled: false,
|
||||||
|
provider: 'none',
|
||||||
|
hcaptchaSiteKey: '',
|
||||||
|
hcaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible
|
||||||
|
recaptchaSiteKey: '',
|
||||||
|
recaptchaSecretKey: '', // Secret keys should ideally not have defaults stored directly here if possible
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const jsonString = await settingsRepository.getSetting(CAPTCHA_CONFIG_KEY);
|
||||||
|
if (jsonString) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(jsonString);
|
||||||
|
// Basic validation (add more specific checks if needed)
|
||||||
|
if (config && typeof config.enabled === 'boolean' && typeof config.provider === 'string') {
|
||||||
|
// Ensure all keys exist, even if undefined/null from older saves
|
||||||
|
return {
|
||||||
|
enabled: config.enabled ?? defaultValue.enabled,
|
||||||
|
provider: config.provider ?? defaultValue.provider,
|
||||||
|
hcaptchaSiteKey: config.hcaptchaSiteKey ?? defaultValue.hcaptchaSiteKey,
|
||||||
|
hcaptchaSecretKey: config.hcaptchaSecretKey ?? defaultValue.hcaptchaSecretKey,
|
||||||
|
recaptchaSiteKey: config.recaptchaSiteKey ?? defaultValue.recaptchaSiteKey,
|
||||||
|
recaptchaSecretKey: config.recaptchaSecretKey ?? defaultValue.recaptchaSecretKey,
|
||||||
|
} as CaptchaSettings;
|
||||||
|
}
|
||||||
|
console.warn(`[SettingsRepo] Invalid captchaConfig format found in DB: ${jsonString}. Returning default.`);
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(`[SettingsRepo] Failed to parse captchaConfig JSON from DB: ${jsonString}`, parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SettingsRepo] Error fetching captcha config setting (key: ${CAPTCHA_CONFIG_KEY}):`, error);
|
||||||
|
}
|
||||||
|
// Return default if not found, invalid, or error occurred
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 CAPTCHA 配置
|
||||||
|
* @param config - The CAPTCHA configuration object
|
||||||
|
*/
|
||||||
|
export const setCaptchaConfig = async (config: CaptchaSettings): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Basic validation before stringifying
|
||||||
|
if (!config || typeof config !== 'object' || typeof config.enabled !== 'boolean' || typeof config.provider !== 'string') {
|
||||||
|
throw new Error('Invalid CAPTCHA config object provided.');
|
||||||
|
}
|
||||||
|
// Ensure secret keys are strings, even if empty
|
||||||
|
config.hcaptchaSecretKey = config.hcaptchaSecretKey || '';
|
||||||
|
config.recaptchaSecretKey = config.recaptchaSecretKey || '';
|
||||||
|
config.hcaptchaSiteKey = config.hcaptchaSiteKey || '';
|
||||||
|
config.recaptchaSiteKey = config.recaptchaSiteKey || '';
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(config);
|
||||||
|
await settingsRepository.setSetting(CAPTCHA_CONFIG_KEY, jsonString);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SettingsRepo] Error setting CAPTCHA config (key: ${CAPTCHA_CONFIG_KEY}):`, error);
|
||||||
|
throw new Error('Failed to save CAPTCHA configuration.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- Initialization ---
|
||||||
|
|
||||||
@@ -200,6 +270,15 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
|||||||
right: []
|
right: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultCaptchaSettings: CaptchaSettings = {
|
||||||
|
enabled: false,
|
||||||
|
provider: 'none',
|
||||||
|
hcaptchaSiteKey: '',
|
||||||
|
hcaptchaSecretKey: '',
|
||||||
|
recaptchaSiteKey: '',
|
||||||
|
recaptchaSecretKey: '',
|
||||||
|
};
|
||||||
|
|
||||||
// --- Define All Default Settings ---
|
// --- Define All Default Settings ---
|
||||||
const defaultSettings: Record<string, string> = {
|
const defaultSettings: Record<string, string> = {
|
||||||
ipWhitelistEnabled: 'false',
|
ipWhitelistEnabled: 'false',
|
||||||
@@ -216,6 +295,7 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
|
|||||||
dockerDefaultExpand: 'false', // Default Docker expand state
|
dockerDefaultExpand: 'false', // Default Docker expand state
|
||||||
statusMonitorIntervalSeconds: '3', // Default Status Monitor interval
|
statusMonitorIntervalSeconds: '3', // Default Status Monitor interval
|
||||||
[SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure), // Use the defined structure
|
[SIDEBAR_CONFIG_KEY]: JSON.stringify(defaultSidebarPanesStructure), // Use the defined structure
|
||||||
|
[CAPTCHA_CONFIG_KEY]: JSON.stringify(defaultCaptchaSettings), // Add default CAPTCHA settings
|
||||||
// Add other default settings here
|
// Add other default settings here
|
||||||
};
|
};
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { settingsService } from './settings.service';
|
||||||
|
import { CaptchaSettings, CaptchaProvider } from '../types/settings.types';
|
||||||
|
|
||||||
|
// CAPTCHA 验证 API 端点
|
||||||
|
const HCAPTCHA_VERIFY_URL = 'https://api.hcaptcha.com/siteverify';
|
||||||
|
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; // v2
|
||||||
|
|
||||||
|
export class CaptchaService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证提供的 CAPTCHA 令牌。
|
||||||
|
* 根据系统设置自动选择合适的提供商进行验证。
|
||||||
|
* @param token - 从前端获取的 CAPTCHA 令牌 (h-captcha-response 或 g-recaptcha-response)
|
||||||
|
* @returns Promise<boolean> - 令牌是否有效
|
||||||
|
* @throws Error 如果配置无效或验证请求失败
|
||||||
|
*/
|
||||||
|
async verifyToken(token: string): Promise<boolean> {
|
||||||
|
if (!token) {
|
||||||
|
console.warn('[CaptchaService] 验证失败:未提供令牌。');
|
||||||
|
return false; // 没有令牌,直接视为无效
|
||||||
|
}
|
||||||
|
|
||||||
|
const captchaConfig = await settingsService.getCaptchaConfig();
|
||||||
|
|
||||||
|
if (!captchaConfig.enabled) {
|
||||||
|
console.log('[CaptchaService] CAPTCHA 未启用,跳过验证。');
|
||||||
|
return true; // 未启用则视为验证通过
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (captchaConfig.provider) {
|
||||||
|
case 'hcaptcha':
|
||||||
|
if (!captchaConfig.hcaptchaSecretKey) {
|
||||||
|
throw new Error('hCaptcha 配置无效:缺少 Secret Key。');
|
||||||
|
}
|
||||||
|
return this._verifyHCaptcha(token, captchaConfig.hcaptchaSecretKey);
|
||||||
|
case 'recaptcha':
|
||||||
|
if (!captchaConfig.recaptchaSecretKey) {
|
||||||
|
throw new Error('Google reCAPTCHA 配置无效:缺少 Secret Key。');
|
||||||
|
}
|
||||||
|
return this._verifyReCaptcha(token, captchaConfig.recaptchaSecretKey);
|
||||||
|
case 'none':
|
||||||
|
console.log('[CaptchaService] CAPTCHA 提供商设置为 "none",跳过验证。');
|
||||||
|
return true; // 提供商为 none 也视为通过
|
||||||
|
default:
|
||||||
|
console.error(`[CaptchaService] 未知的 CAPTCHA 提供商: ${captchaConfig.provider}`);
|
||||||
|
throw new Error(`未知的 CAPTCHA 提供商配置: ${captchaConfig.provider}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 hCaptcha API 验证令牌。
|
||||||
|
* @param token - h-captcha-response 令牌
|
||||||
|
* @param secretKey - hCaptcha Secret Key
|
||||||
|
* @returns Promise<boolean> - 令牌是否有效
|
||||||
|
*/
|
||||||
|
private async _verifyHCaptcha(token: string, secretKey: string): Promise<boolean> {
|
||||||
|
console.log('[CaptchaService] 正在验证 hCaptcha 令牌...');
|
||||||
|
try {
|
||||||
|
const response = await axios.post(HCAPTCHA_VERIFY_URL, null, { // 使用 POST,数据在 params 中
|
||||||
|
params: {
|
||||||
|
secret: secretKey,
|
||||||
|
response: token,
|
||||||
|
// remoteip: 可选,用户 IP 地址
|
||||||
|
},
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[CaptchaService] hCaptcha 验证响应:', response.data);
|
||||||
|
if (response.data && response.data.success === true) {
|
||||||
|
console.log('[CaptchaService] hCaptcha 令牌验证成功。');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn('[CaptchaService] hCaptcha 令牌验证失败:', response.data['error-codes'] || '未知错误');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[CaptchaService] 调用 hCaptcha 验证 API 时出错:', error.response?.data || error.message);
|
||||||
|
// 抛出错误,让上层处理(例如,提示用户稍后重试)
|
||||||
|
throw new Error(`hCaptcha 验证请求失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用 Google reCAPTCHA API 验证令牌。
|
||||||
|
* @param token - g-recaptcha-response 令牌
|
||||||
|
* @param secretKey - Google reCAPTCHA Secret Key
|
||||||
|
* @returns Promise<boolean> - 令牌是否有效
|
||||||
|
*/
|
||||||
|
private async _verifyReCaptcha(token: string, secretKey: string): Promise<boolean> {
|
||||||
|
console.log('[CaptchaService] 正在验证 Google reCAPTCHA 令牌...');
|
||||||
|
try {
|
||||||
|
const response = await axios.post(RECAPTCHA_VERIFY_URL, null, { // 使用 POST,数据在 params 中
|
||||||
|
params: {
|
||||||
|
secret: secretKey,
|
||||||
|
response: token,
|
||||||
|
// remoteip: 可选,用户 IP 地址
|
||||||
|
},
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[CaptchaService] Google reCAPTCHA 验证响应:', response.data);
|
||||||
|
if (response.data && response.data.success === true) {
|
||||||
|
// 可选:检查 hostname, score (v3), action (v3) 等
|
||||||
|
console.log('[CaptchaService] Google reCAPTCHA 令牌验证成功。');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn('[CaptchaService] Google reCAPTCHA 令牌验证失败:', response.data['error-codes'] || '未知错误');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[CaptchaService] 调用 Google reCAPTCHA 验证 API 时出错:', error.response?.data || error.message);
|
||||||
|
// 抛出错误,让上层处理
|
||||||
|
throw new Error(`Google reCAPTCHA 验证请求失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出一个单例供其他服务使用
|
||||||
|
export const captchaService = new CaptchaService();
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { settingsRepository, Setting, getSidebarConfig as getSidebarConfigFromRepo, setSidebarConfig as setSidebarConfigInRepo } from '../repositories/settings.repository'; // Import specific repo functions
|
import {
|
||||||
import { SidebarConfig, PaneName, UpdateSidebarConfigDto } from '../types/settings.types';
|
settingsRepository,
|
||||||
|
Setting,
|
||||||
|
getSidebarConfig as getSidebarConfigFromRepo,
|
||||||
|
setSidebarConfig as setSidebarConfigInRepo,
|
||||||
|
getCaptchaConfig as getCaptchaConfigFromRepo, // <-- Import CAPTCHA repo getter
|
||||||
|
setCaptchaConfig as setCaptchaConfigInRepo, // <-- Import CAPTCHA repo setter
|
||||||
|
} from '../repositories/settings.repository';
|
||||||
|
import {
|
||||||
|
SidebarConfig,
|
||||||
|
PaneName,
|
||||||
|
UpdateSidebarConfigDto,
|
||||||
|
CaptchaSettings, // <-- Import CAPTCHA types
|
||||||
|
UpdateCaptchaSettingsDto, // <-- Import CAPTCHA types
|
||||||
|
CaptchaProvider, // <-- Import CAPTCHA types
|
||||||
|
} from '../types/settings.types';
|
||||||
|
|
||||||
// +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++
|
// +++ 定义焦点切换完整配置接口 (与前端 store 保持一致) +++
|
||||||
interface FocusItemConfig { // 单个项目的配置
|
interface FocusItemConfig { // 单个项目的配置
|
||||||
@@ -378,6 +392,76 @@ export const settingsService = {
|
|||||||
// Directly call the specific repository function
|
// Directly call the specific repository function
|
||||||
await setSidebarConfigInRepo(configToSave);
|
await setSidebarConfigInRepo(configToSave);
|
||||||
console.log('[SettingsService] Sidebar config successfully set.');
|
console.log('[SettingsService] Sidebar config successfully set.');
|
||||||
} // <-- No comma after the last method in the object
|
}, // <-- Add comma here
|
||||||
|
|
||||||
|
// --- CAPTCHA Settings Specific Functions ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 CAPTCHA 配置
|
||||||
|
* @returns Promise<CaptchaSettings>
|
||||||
|
*/
|
||||||
|
async getCaptchaConfig(): Promise<CaptchaSettings> {
|
||||||
|
console.log('[SettingsService] Getting CAPTCHA config...');
|
||||||
|
// Directly call the specific repository function
|
||||||
|
const config = await getCaptchaConfigFromRepo();
|
||||||
|
// Mask secret keys before logging
|
||||||
|
const maskedConfig = { ...config, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' };
|
||||||
|
console.log('[SettingsService] Returning CAPTCHA config:', maskedConfig);
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 CAPTCHA 配置
|
||||||
|
* @param configDto - The CAPTCHA configuration object from DTO
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
async setCaptchaConfig(configDto: UpdateCaptchaSettingsDto): Promise<void> {
|
||||||
|
console.log('[SettingsService] Setting CAPTCHA config (DTO):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' }); // Mask secrets in log
|
||||||
|
|
||||||
|
// --- Validation ---
|
||||||
|
if (!configDto || typeof configDto !== 'object') {
|
||||||
|
throw new Error('无效的 CAPTCHA 配置格式。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the current settings to merge with the DTO
|
||||||
|
const currentConfig = await getCaptchaConfigFromRepo();
|
||||||
|
const configToSave: CaptchaSettings = { ...currentConfig };
|
||||||
|
|
||||||
|
// Validate and update individual fields from DTO
|
||||||
|
if (configDto.enabled !== undefined) {
|
||||||
|
if (typeof configDto.enabled !== 'boolean') throw new Error('captcha.enabled 必须是布尔值。');
|
||||||
|
configToSave.enabled = configDto.enabled;
|
||||||
|
}
|
||||||
|
if (configDto.provider !== undefined) {
|
||||||
|
const validProviders: CaptchaProvider[] = ['hcaptcha', 'recaptcha', 'none'];
|
||||||
|
if (!validProviders.includes(configDto.provider)) throw new Error(`无效的 CAPTCHA 提供商: ${configDto.provider}`);
|
||||||
|
configToSave.provider = configDto.provider;
|
||||||
|
}
|
||||||
|
if (configDto.hcaptchaSiteKey !== undefined) {
|
||||||
|
if (typeof configDto.hcaptchaSiteKey !== 'string') throw new Error('hcaptchaSiteKey 必须是字符串。');
|
||||||
|
configToSave.hcaptchaSiteKey = configDto.hcaptchaSiteKey;
|
||||||
|
}
|
||||||
|
if (configDto.hcaptchaSecretKey !== undefined) {
|
||||||
|
if (typeof configDto.hcaptchaSecretKey !== 'string') throw new Error('hcaptchaSecretKey 必须是字符串。');
|
||||||
|
configToSave.hcaptchaSecretKey = configDto.hcaptchaSecretKey;
|
||||||
|
}
|
||||||
|
if (configDto.recaptchaSiteKey !== undefined) {
|
||||||
|
if (typeof configDto.recaptchaSiteKey !== 'string') throw new Error('recaptchaSiteKey 必须是字符串。');
|
||||||
|
configToSave.recaptchaSiteKey = configDto.recaptchaSiteKey;
|
||||||
|
}
|
||||||
|
if (configDto.recaptchaSecretKey !== undefined) {
|
||||||
|
if (typeof configDto.recaptchaSecretKey !== 'string') throw new Error('recaptchaSecretKey 必须是字符串。');
|
||||||
|
configToSave.recaptchaSecretKey = configDto.recaptchaSecretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure consistency: if disabled, provider should ideally be 'none' (optional enforcement)
|
||||||
|
// if (!configToSave.enabled) {
|
||||||
|
// configToSave.provider = 'none';
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Directly call the specific repository function with the full, validated config
|
||||||
|
await setCaptchaConfigInRepo(configToSave);
|
||||||
|
console.log('[SettingsService] CAPTCHA config successfully set.');
|
||||||
|
} // <-- No comma after the last method
|
||||||
|
|
||||||
}; // <-- End of settingsService object definition
|
}; // <-- End of settingsService object definition
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Request, Response } from 'express';
|
|||||||
import { settingsService } from '../services/settings.service';
|
import { settingsService } from '../services/settings.service';
|
||||||
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
|
import { AuditLogService } from '../services/audit.service'; // 引入 AuditLogService
|
||||||
import { ipBlacklistService } from '../services/ip-blacklist.service';
|
import { ipBlacklistService } from '../services/ip-blacklist.service';
|
||||||
import { UpdateSidebarConfigDto } from '../types/settings.types'; // <-- Correct import path
|
import { UpdateSidebarConfigDto, UpdateCaptchaSettingsDto, CaptchaSettings } from '../types/settings.types'; // <-- Import CAPTCHA types
|
||||||
|
|
||||||
const auditLogService = new AuditLogService();
|
const auditLogService = new AuditLogService();
|
||||||
|
|
||||||
@@ -358,6 +358,69 @@ export const settingsController = {
|
|||||||
res.status(500).json({ message: '设置侧栏配置失败', error: error.message });
|
res.status(500).json({ message: '设置侧栏配置失败', error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // <-- No comma after the last method
|
}, // <-- Add comma here
|
||||||
|
|
||||||
|
// --- CAPTCHA Settings Controller Methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取公共 CAPTCHA 配置 (不含密钥)
|
||||||
|
*/
|
||||||
|
async getCaptchaConfig(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('[Controller] Received request to get CAPTCHA config.');
|
||||||
|
const fullConfig = await settingsService.getCaptchaConfig();
|
||||||
|
|
||||||
|
// *** IMPORTANT: Filter out secret keys before sending to frontend ***
|
||||||
|
const publicConfig = {
|
||||||
|
enabled: fullConfig.enabled,
|
||||||
|
provider: fullConfig.provider,
|
||||||
|
hcaptchaSiteKey: fullConfig.hcaptchaSiteKey,
|
||||||
|
recaptchaSiteKey: fullConfig.recaptchaSiteKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('[Controller] Sending public CAPTCHA config to client:', publicConfig);
|
||||||
|
res.json(publicConfig);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Controller] 获取 CAPTCHA 配置时出错:', error);
|
||||||
|
res.status(500).json({ message: '获取 CAPTCHA 配置失败', error: error.message });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 CAPTCHA 配置
|
||||||
|
*/
|
||||||
|
async setCaptchaConfig(req: Request, res: Response): Promise<void> {
|
||||||
|
console.log('[Controller] Received request to set CAPTCHA config.');
|
||||||
|
try {
|
||||||
|
const configDto: UpdateCaptchaSettingsDto = req.body;
|
||||||
|
// Mask secrets immediately if logging the DTO
|
||||||
|
console.log('[Controller] Request body (DTO, secrets masked):', { ...configDto, hcaptchaSecretKey: '***', recaptchaSecretKey: '***' });
|
||||||
|
|
||||||
|
// --- DTO Validation (Basic) ---
|
||||||
|
if (!configDto || typeof configDto !== 'object') {
|
||||||
|
console.warn('[Controller] Invalid CAPTCHA config format received (not an object):', configDto);
|
||||||
|
res.status(400).json({ message: '无效的请求体,应为 JSON 对象' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// More specific validation happens in the service layer
|
||||||
|
|
||||||
|
console.log('[Controller] Calling settingsService.setCaptchaConfig...');
|
||||||
|
await settingsService.setCaptchaConfig(configDto);
|
||||||
|
console.log('[Controller] settingsService.setCaptchaConfig completed successfully.');
|
||||||
|
|
||||||
|
auditLogService.logAction('CAPTCHA_SETTINGS_UPDATED'); // Add audit log
|
||||||
|
|
||||||
|
console.log('[Controller] Sending success response.');
|
||||||
|
res.status(200).json({ message: 'CAPTCHA 配置已成功更新' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[Controller] 设置 CAPTCHA 配置时出错:', error);
|
||||||
|
// Handle specific validation errors from the service
|
||||||
|
if (error.message.includes('无效的') || error.message.includes('必须是')) { // Basic check for validation errors
|
||||||
|
res.status(400).json({ message: `设置 CAPTCHA 配置失败: ${error.message}` });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ message: '设置 CAPTCHA 配置失败', error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // <-- No comma after the last method
|
||||||
|
|
||||||
}; // <-- End of settingsController object
|
}; // <-- End of settingsController object
|
||||||
|
|||||||
@@ -51,3 +51,9 @@ router.put('/sidebar', settingsController.setSidebarConfig);
|
|||||||
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
// +++ 新增:CAPTCHA 配置路由 +++
|
||||||
|
// GET /api/v1/settings/captcha - 获取公共 CAPTCHA 配置 (不含密钥)
|
||||||
|
router.get('/captcha', settingsController.getCaptchaConfig);
|
||||||
|
// PUT /api/v1/settings/captcha - 更新 CAPTCHA 配置
|
||||||
|
router.put('/captcha', settingsController.setCaptchaConfig);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type AuditLogActionType =
|
|||||||
// Settings
|
// Settings
|
||||||
| 'SETTINGS_UPDATED' // General settings update
|
| 'SETTINGS_UPDATED' // General settings update
|
||||||
| 'IP_WHITELIST_UPDATED' // Specific setting update
|
| 'IP_WHITELIST_UPDATED' // Specific setting update
|
||||||
|
| 'CAPTCHA_SETTINGS_UPDATED' // Specific setting update for CAPTCHA
|
||||||
// | 'FOCUS_SWITCHER_SEQUENCE_UPDATED' // Removed
|
// | 'FOCUS_SWITCHER_SEQUENCE_UPDATED' // Removed
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
|
|||||||
@@ -28,4 +28,45 @@ export interface SidebarConfig {
|
|||||||
*/
|
*/
|
||||||
export interface UpdateSidebarConfigDto extends SidebarConfig {} // Simple alias for now, can add validation later
|
export interface UpdateSidebarConfigDto extends SidebarConfig {} // Simple alias for now, can add validation later
|
||||||
|
|
||||||
// You can add other settings-related types here if needed
|
// You can add other settings-related types here if needed
|
||||||
|
/**
|
||||||
|
* CAPTCHA 提供商类型
|
||||||
|
*/
|
||||||
|
export type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CAPTCHA 设置接口
|
||||||
|
*/
|
||||||
|
export interface CaptchaSettings {
|
||||||
|
enabled: boolean; // 是否启用 CAPTCHA
|
||||||
|
provider: CaptchaProvider; // 当前选择的提供商
|
||||||
|
hcaptchaSiteKey?: string; // hCaptcha 站点密钥 (公开)
|
||||||
|
hcaptchaSecretKey?: string; // hCaptcha 秘密密钥 (保密) - 后端存储和使用
|
||||||
|
recaptchaSiteKey?: string; // Google reCAPTCHA v2 站点密钥 (公开)
|
||||||
|
recaptchaSecretKey?: string; // Google reCAPTCHA v2 秘密密钥 (保密) - 后端存储和使用
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于更新 CAPTCHA 设置的 DTO
|
||||||
|
* (可以添加验证规则)
|
||||||
|
*/
|
||||||
|
export interface UpdateCaptchaSettingsDto {
|
||||||
|
enabled?: boolean;
|
||||||
|
provider?: CaptchaProvider;
|
||||||
|
hcaptchaSiteKey?: string;
|
||||||
|
hcaptchaSecretKey?: string;
|
||||||
|
recaptchaSiteKey?: string;
|
||||||
|
recaptchaSecretKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整的应用设置接口 (聚合所有设置类型)
|
||||||
|
* 注意:这只是一个示例结构,实际可能需要根据 SettingsRepository 的实现调整
|
||||||
|
*/
|
||||||
|
export interface AppSettings {
|
||||||
|
sidebar?: SidebarConfig;
|
||||||
|
captcha?: CaptchaSettings;
|
||||||
|
// 可以添加其他设置模块,例如:
|
||||||
|
// security?: SecuritySettings;
|
||||||
|
// general?: GeneralSettings;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
|
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||||
"@simplewebauthn/browser": "^13.1.0",
|
"@simplewebauthn/browser": "^13.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.4",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@xterm/addon-search": "^0.15.0",
|
"@xterm/addon-search": "^0.15.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"vue": "^3.3.0",
|
"vue": "^3.3.0",
|
||||||
"vue-i18n": "^9.14.4",
|
"vue-i18n": "^9.14.4",
|
||||||
|
"vue-recaptcha-v3": "^2.0.1",
|
||||||
"vue-router": "^4.5.0",
|
"vue-router": "^4.5.0",
|
||||||
"vuedraggable": "^4.1.0",
|
"vuedraggable": "^4.1.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
@@ -29,11 +31,11 @@
|
|||||||
"xterm-addon-web-links": "^0.9.0"
|
"xterm-addon-web-links": "^0.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
"@types/splitpanes": "^2.2.6",
|
"@types/splitpanes": "^2.2.6",
|
||||||
"@vitejs/plugin-vue": "^4.2.0",
|
"@vitejs/plugin-vue": "^4.2.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
"vue-tsc": "^2.2.8",
|
"vue-tsc": "^2.2.8"
|
||||||
"@types/node": "^20"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,12 @@
|
|||||||
"loggingIn": "Logging in...",
|
"loggingIn": "Logging in...",
|
||||||
"twoFactorPrompt": "Enter your two-factor authentication code:",
|
"twoFactorPrompt": "Enter your two-factor authentication code:",
|
||||||
"verifyButton": "Verify",
|
"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": {
|
"connections": {
|
||||||
"addConnection": "Add New Connection",
|
"addConnection": "Add New Connection",
|
||||||
@@ -602,6 +607,25 @@
|
|||||||
"invalidBanDuration": "Ban duration must be a positive integer (seconds).",
|
"invalidBanDuration": "Ban duration must be a positive integer (seconds).",
|
||||||
"updateConfigFailed": "Failed to update blacklist configuration"
|
"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": {
|
"common": {
|
||||||
|
|||||||
@@ -98,7 +98,12 @@
|
|||||||
"loggingIn": "正在登录...",
|
"loggingIn": "正在登录...",
|
||||||
"twoFactorPrompt": "请输入两步验证码:",
|
"twoFactorPrompt": "请输入两步验证码:",
|
||||||
"verifyButton": "验证",
|
"verifyButton": "验证",
|
||||||
"rememberMe": "记住我 (7 天)"
|
"rememberMe": "记住我 (7 天)",
|
||||||
|
"captchaPrompt": "请完成下方的验证:",
|
||||||
|
"error": {
|
||||||
|
"captchaLoadFailed": "加载 CAPTCHA 失败,请尝试刷新页面。",
|
||||||
|
"captchaRequired": "请完成 CAPTCHA 验证。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"connections": {
|
"connections": {
|
||||||
"addConnection": "添加新连接",
|
"addConnection": "添加新连接",
|
||||||
@@ -602,6 +607,25 @@
|
|||||||
"invalidBanDuration": "封禁时长必须是正整数(秒)。",
|
"invalidBanDuration": "封禁时长必须是正整数(秒)。",
|
||||||
"updateConfigFailed": "更新黑名单配置失败"
|
"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": {
|
"common": {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import './style.css';
|
|||||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
// 导入 splitpanes CSS
|
// 导入 splitpanes CSS
|
||||||
import 'splitpanes/dist/splitpanes.css';
|
import 'splitpanes/dist/splitpanes.css';
|
||||||
|
// 导入 reCAPTCHA v3
|
||||||
|
import { VueReCaptcha } from 'vue-recaptcha-v3';
|
||||||
|
|
||||||
const pinia = createPinia(); // 创建 Pinia 实例
|
const pinia = createPinia(); // 创建 Pinia 实例
|
||||||
pinia.use(piniaPluginPersistedstate); // 使用持久化插件
|
pinia.use(piniaPluginPersistedstate); // 使用持久化插件
|
||||||
@@ -22,6 +24,15 @@ app.use(pinia); // 使用配置好的 Pinia 实例
|
|||||||
// 注意:在状态初始化完成前,暂时不 use(router)
|
// 注意:在状态初始化完成前,暂时不 use(router)
|
||||||
app.use(i18n); // 使用 i18n
|
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 IIFE 来允许顶层 await
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
|||||||
import router from '../router'; // 引入 router 用于重定向
|
import router from '../router'; // 引入 router 用于重定向
|
||||||
import { setLocale } from '../i18n'; // 导入 setLocale
|
import { setLocale } from '../i18n'; // 导入 setLocale
|
||||||
|
|
||||||
// 扩展的用户信息接口,包含 2FA 状态
|
|
||||||
// 扩展的用户信息接口,包含 2FA 状态和语言偏好
|
// 扩展的用户信息接口,包含 2FA 状态和语言偏好
|
||||||
interface UserInfo {
|
interface UserInfo {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -19,6 +18,14 @@ interface LoginPayload {
|
|||||||
rememberMe?: boolean; // 可选的“记住我”标志
|
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 接口
|
// Auth Store State 接口
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -26,12 +33,13 @@ interface AuthState {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
|
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA
|
||||||
// 新增:存储 IP 黑名单数据
|
// 新增:存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持)
|
||||||
ipBlacklist: {
|
ipBlacklist: {
|
||||||
entries: any[]; // TODO: Define a proper type for blacklist entries
|
entries: any[]; // TODO: Define a proper type for blacklist entries
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
needsSetup: boolean; // 新增:是否需要初始设置
|
needsSetup: boolean; // 新增:是否需要初始设置
|
||||||
|
publicCaptchaConfig: PublicCaptchaConfig | null; // NEW: Public CAPTCHA config
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', {
|
export const useAuthStore = defineStore('auth', {
|
||||||
@@ -43,20 +51,21 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
loginRequires2FA: false, // 初始为不需要
|
loginRequires2FA: false, // 初始为不需要
|
||||||
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
|
ipBlacklist: { entries: [], total: 0 }, // 初始化黑名单状态
|
||||||
needsSetup: false, // 初始假设不需要设置
|
needsSetup: false, // 初始假设不需要设置
|
||||||
|
publicCaptchaConfig: null, // NEW: Initialize CAPTCHA config as null
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
// 可以添加一些 getter,例如获取用户名
|
// 可以添加一些 getter,例如获取用户名
|
||||||
loggedInUser: (state) => state.user?.username,
|
loggedInUser: (state) => state.user?.username,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
// 登录 Action - 更新为接受 LoginPayload
|
// 登录 Action - 更新为接受 LoginPayload + optional captchaToken
|
||||||
async login(payload: LoginPayload) {
|
async login(payload: LoginPayload & { captchaToken?: string }) { // Add captchaToken to payload
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
this.loginRequires2FA = false; // 重置 2FA 状态
|
this.loginRequires2FA = false; // 重置 2FA 状态
|
||||||
try {
|
try {
|
||||||
// 后端可能返回 user 或 requiresTwoFactor
|
// 后端可能返回 user 或 requiresTwoFactor
|
||||||
// 将完整的 payload (包含 rememberMe) 发送给后端
|
// 将完整的 payload (包含 rememberMe 和 captchaToken) 发送给后端
|
||||||
const response = await apiClient.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/auth/login', payload); // 使用 apiClient
|
const response = await apiClient.post<{ message: string; user?: UserInfo; requiresTwoFactor?: boolean }>('/auth/login', payload); // 使用 apiClient
|
||||||
|
|
||||||
if (response.data.requiresTwoFactor) {
|
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
|
// 新增:检查并更新认证状态 Action
|
||||||
async checkAuthStatus() {
|
async checkAuthStatus() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
@@ -245,9 +226,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
const response = await apiClient.get('/settings/ip-blacklist', { // 使用 apiClient
|
const response = await apiClient.get('/settings/ip-blacklist', { // 使用 apiClient
|
||||||
params: { limit, offset }
|
params: { limit, offset }
|
||||||
});
|
});
|
||||||
// 注意:这里需要将获取到的数据存储在 state 中,
|
// 更新本地状态
|
||||||
// 但当前 AuthState 没有定义相关字段。
|
this.ipBlacklist.entries = response.data.entries;
|
||||||
// 暂时只返回数据,需要在 state 中添加 ipBlacklist 字段。
|
this.ipBlacklist.total = response.data.total;
|
||||||
console.log('获取 IP 黑名单成功:', response.data);
|
console.log('获取 IP 黑名单成功:', response.data);
|
||||||
return response.data; // { entries: [], total: number }
|
return response.data; // { entries: [], total: number }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -270,7 +251,9 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
try {
|
try {
|
||||||
await apiClient.delete(`/settings/ip-blacklist/${encodeURIComponent(ip)}`); // 使用 apiClient
|
await apiClient.delete(`/settings/ip-blacklist/${encodeURIComponent(ip)}`); // 使用 apiClient
|
||||||
console.log(`IP ${ip} 已从黑名单删除`);
|
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;
|
return true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(`删除 IP ${ip} 失败:`, err);
|
console.error(`删除 IP ${ip} 失败:`, err);
|
||||||
@@ -297,6 +280,27 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
return false;
|
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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
|||||||
import { ref, computed } from 'vue'; // 移除 watch
|
import { ref, computed } from 'vue'; // 移除 watch
|
||||||
import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale
|
import i18n, { setLocale, defaultLng } from '../i18n'; // Import i18n instance and setLocale
|
||||||
import type { PaneName } from './layout.store'; // +++ Import PaneName type +++
|
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
|
// 移除 ITheme 和默认主题定义,这些移到 appearance.store.ts
|
||||||
|
|
||||||
// 定义通用设置状态类型
|
// 定义通用设置状态类型
|
||||||
@@ -32,6 +52,7 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
const settings = ref<Partial<SettingsState>>({}); // 通用设置状态
|
const settings = ref<Partial<SettingsState>>({}); // 通用设置状态
|
||||||
const parsedSidebarPaneWidths = ref<Record<string, string>>({}); // NEW: 解析后的侧边栏宽度对象
|
const parsedSidebarPaneWidths = ref<Record<string, string>>({}); // NEW: 解析后的侧边栏宽度对象
|
||||||
const parsedFileManagerColWidths = ref<Record<string, number>>({}); // NEW: 解析后的文件管理器列宽对象
|
const parsedFileManagerColWidths = ref<Record<string, number>>({}); // NEW: 解析后的文件管理器列宽对象
|
||||||
|
const captchaSettings = ref<CaptchaSettings | null>(null); // NEW: CAPTCHA 设置状态
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
// 移除外观相关状态: isStyleCustomizerVisible, currentUiTheme, currentXtermTheme
|
// 移除外观相关状态: 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
|
// 移除外观相关 actions: saveCustomThemes, resetCustomThemes, toggleStyleCustomizer
|
||||||
|
|
||||||
// --- Getters ---
|
// --- Getters ---
|
||||||
@@ -411,7 +491,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
return parsedFileManagerColWidths.value;
|
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, // 只包含通用设置
|
settings, // 只包含通用设置
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
@@ -426,6 +513,14 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
getSidebarPaneWidth, // +++ 暴露获取特定面板宽度的 getter +++
|
getSidebarPaneWidth, // +++ 暴露获取特定面板宽度的 getter +++
|
||||||
fileManagerRowSizeMultiplierNumber, // +++ 暴露文件管理器行大小 getter +++
|
fileManagerRowSizeMultiplierNumber, // +++ 暴露文件管理器行大小 getter +++
|
||||||
fileManagerColWidthsObject, // +++ 暴露文件管理器列宽 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
|
// 移除外观相关的 getters 和 actions
|
||||||
loadInitialSettings,
|
loadInitialSettings,
|
||||||
updateSetting,
|
updateSetting,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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 { storeToRefs } from 'pinia';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAuthStore } from '../stores/auth.store';
|
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 { t } = useI18n();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
// 获取 loginRequires2FA 状态
|
// 获取 loginRequires2FA 状态
|
||||||
const { isLoading, error, loginRequires2FA } = storeToRefs(authStore);
|
const { isLoading, error, loginRequires2FA, publicCaptchaConfig } = storeToRefs(authStore); // Get publicCaptchaConfig
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const credentials = reactive({
|
const credentials = reactive({
|
||||||
@@ -16,19 +18,102 @@ const credentials = reactive({
|
|||||||
});
|
});
|
||||||
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
||||||
const rememberMe = ref(false); // 新增:记住我状态,默认为 false
|
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 验证提交
|
// 处理登录或 2FA 验证提交
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (loginRequires2FA.value) {
|
captchaError.value = null; // Clear previous CAPTCHA error
|
||||||
// 如果需要 2FA,则调用 2FA 验证 action
|
|
||||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
// --- CAPTCHA Execution & Check ---
|
||||||
} else {
|
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value) {
|
||||||
// 否则,调用常规登录 action,并传递 rememberMe 状态
|
// If reCAPTCHA v3, execute it now to get the token
|
||||||
await authStore.login({ ...credentials, rememberMe: rememberMe.value });
|
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 处理
|
// --- End CAPTCHA Check ---
|
||||||
// 失败会更新 error 状态并在模板中显示
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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>
|
<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 位数字验证码"
|
<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" />
|
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 -->
|
<!-- CAPTCHA Area -->
|
||||||
{{ error }}
|
<div v-if="publicCaptchaConfig?.enabled && !loginRequires2FA" class="space-y-2">
|
||||||
</div>
|
<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"
|
<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">
|
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">
|
||||||
|
|||||||
@@ -141,6 +141,76 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -395,6 +465,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { ref, onMounted, computed, reactive, watch } from 'vue';
|
||||||
import { useAuthStore } from '../stores/auth.store';
|
import { useAuthStore } from '../stores/auth.store';
|
||||||
import { useSettingsStore } from '../stores/settings.store';
|
import { useSettingsStore } from '../stores/settings.store';
|
||||||
@@ -413,7 +493,19 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
// --- Reactive state from store ---
|
// --- Reactive state from store ---
|
||||||
// 使用 storeToRefs 获取响应式 getter,包括 language
|
// 使用 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 ---
|
// --- Local state for forms ---
|
||||||
const ipWhitelistInput = ref('');
|
const ipWhitelistInput = ref('');
|
||||||
@@ -460,6 +552,19 @@ const workspaceSidebarPersistentLoading = ref(false); // 新增
|
|||||||
const workspaceSidebarPersistentMessage = ref(''); // 新增
|
const workspaceSidebarPersistentMessage = ref(''); // 新增
|
||||||
const workspaceSidebarPersistentSuccess = ref(false); // 新增
|
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 ---
|
// --- Watcher to sync local form state with store state ---
|
||||||
watch(settings, (newSettings, oldSettings) => {
|
watch(settings, (newSettings, oldSettings) => {
|
||||||
@@ -482,6 +587,27 @@ watch(settings, (newSettings, oldSettings) => {
|
|||||||
|
|
||||||
}, { deep: true, immediate: true }); // immediate: true to run on initial load
|
}, { 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 ---
|
// --- Popup Editor setting method ---
|
||||||
const handleUpdatePopupEditorSetting = async () => {
|
const handleUpdatePopupEditorSetting = async () => {
|
||||||
popupEditorLoading.value = true;
|
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 ---
|
// --- Lifecycle Hooks ---
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkTwoFactorStatus(); // Check 2FA status
|
await checkTwoFactorStatus(); // Check 2FA status
|
||||||
await fetchIpBlacklist(); // Fetch current blacklist entries
|
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()
|
// Initial settings (including language, whitelist, blacklist config) are loaded in main.ts via settingsStore.loadInitialSettings()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -861,4 +1019,4 @@ onMounted(async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
||||||
</style>
|
</style>
|
||||||
]]>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user