Merge branch 'main-upstream' into pr/custom-currency-1923
# Conflicts: # web/src/components/settings/personal/cards/AccountManagement.jsx # web/src/components/table/channels/modals/EditChannelModal.jsx # web/src/hooks/channels/useChannelsData.jsx # web/src/hooks/common/useSidebar.js # web/src/i18n/locales/fr.json # web/src/pages/Setting/Operation/SettingsGeneral.jsx
This commit is contained in:
@@ -84,6 +84,7 @@ export const useChannelsData = () => {
|
||||
const [selectedModelKeys, setSelectedModelKeys] = useState([]);
|
||||
const [isBatchTesting, setIsBatchTesting] = useState(false);
|
||||
const [modelTablePage, setModelTablePage] = useState(1);
|
||||
const [selectedEndpointType, setSelectedEndpointType] = useState('');
|
||||
|
||||
// 使用 ref 来避免闭包问题,类似旧版实现
|
||||
const shouldStopBatchTestingRef = useRef(false);
|
||||
@@ -753,7 +754,7 @@ export const useChannelsData = () => {
|
||||
};
|
||||
|
||||
// Test channel - 单个模型测试,参考旧版实现
|
||||
const testChannel = async (record, model) => {
|
||||
const testChannel = async (record, model, endpointType = '') => {
|
||||
const testKey = `${record.id}-${model}`;
|
||||
|
||||
// 检查是否应该停止批量测试
|
||||
@@ -765,9 +766,11 @@ export const useChannelsData = () => {
|
||||
setTestingModels((prev) => new Set([...prev, model]));
|
||||
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/channel/test/${record.id}?model=${model}`,
|
||||
);
|
||||
let url = `/api/channel/test/${record.id}?model=${model}`;
|
||||
if (endpointType) {
|
||||
url += `&endpoint_type=${endpointType}`;
|
||||
}
|
||||
const res = await API.get(url);
|
||||
|
||||
// 检查是否在请求期间被停止
|
||||
if (shouldStopBatchTestingRef.current && isBatchTesting) {
|
||||
@@ -895,7 +898,7 @@ export const useChannelsData = () => {
|
||||
);
|
||||
|
||||
const batchPromises = batch.map((model) =>
|
||||
testChannel(currentTestChannel, model),
|
||||
testChannel(currentTestChannel, model, selectedEndpointType),
|
||||
);
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
results.push(...batchResults);
|
||||
@@ -979,6 +982,7 @@ export const useChannelsData = () => {
|
||||
setTestingModels(new Set());
|
||||
setSelectedModelKeys([]);
|
||||
setModelTablePage(1);
|
||||
setSelectedEndpointType('');
|
||||
// 可选择性保留测试结果,这里不清空以便用户查看
|
||||
};
|
||||
|
||||
@@ -1066,6 +1070,8 @@ export const useChannelsData = () => {
|
||||
isBatchTesting,
|
||||
modelTablePage,
|
||||
setModelTablePage,
|
||||
selectedEndpointType,
|
||||
setSelectedEndpointType,
|
||||
allSelectingRef,
|
||||
|
||||
// Multi-key management states
|
||||
|
||||
@@ -40,7 +40,7 @@ export const useHeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const location = useLocation();
|
||||
|
||||
const loading = statusState?.status === undefined;
|
||||
const isLoading = useMinimumLoadingTime(loading);
|
||||
const isLoading = useMinimumLoadingTime(loading, 200);
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
|
||||
246
web/src/hooks/common/useSecureVerification.jsx
Normal file
246
web/src/hooks/common/useSecureVerification.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SecureVerificationService } from '../../services/secureVerification';
|
||||
import { showError, showSuccess } from '../../helpers';
|
||||
import { isVerificationRequiredError } from '../../helpers/secureApiCall';
|
||||
|
||||
/**
|
||||
* 通用安全验证 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onSuccess - 验证成功回调
|
||||
* @param {Function} options.onError - 验证失败回调
|
||||
* @param {string} options.successMessage - 成功提示消息
|
||||
* @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
|
||||
*/
|
||||
export const useSecureVerification = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
successMessage,
|
||||
autoReset = true
|
||||
} = {}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 验证方式可用性状态
|
||||
const [verificationMethods, setVerificationMethods] = useState({
|
||||
has2FA: false,
|
||||
hasPasskey: false,
|
||||
passkeySupported: false
|
||||
});
|
||||
|
||||
// 模态框状态
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
|
||||
// 当前验证状态
|
||||
const [verificationState, setVerificationState] = useState({
|
||||
method: null, // '2fa' | 'passkey'
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null
|
||||
});
|
||||
|
||||
// 检查可用的验证方式
|
||||
const checkVerificationMethods = useCallback(async () => {
|
||||
const methods = await SecureVerificationService.checkAvailableVerificationMethods();
|
||||
setVerificationMethods(methods);
|
||||
return methods;
|
||||
}, []);
|
||||
|
||||
// 初始化时检查验证方式
|
||||
useEffect(() => {
|
||||
checkVerificationMethods();
|
||||
}, [checkVerificationMethods]);
|
||||
|
||||
// 重置状态
|
||||
const resetState = useCallback(() => {
|
||||
setVerificationState({
|
||||
method: null,
|
||||
loading: false,
|
||||
code: '',
|
||||
apiCall: null
|
||||
});
|
||||
setIsModalVisible(false);
|
||||
}, []);
|
||||
|
||||
// 开始验证流程
|
||||
const startVerification = useCallback(async (apiCall, options = {}) => {
|
||||
const { preferredMethod, title, description } = options;
|
||||
|
||||
// 检查验证方式
|
||||
const methods = await checkVerificationMethods();
|
||||
|
||||
if (!methods.has2FA && !methods.hasPasskey) {
|
||||
const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
|
||||
showError(errorMessage);
|
||||
onError?.(new Error(errorMessage));
|
||||
return false;
|
||||
}
|
||||
|
||||
// 设置默认验证方式
|
||||
let defaultMethod = preferredMethod;
|
||||
if (!defaultMethod) {
|
||||
if (methods.hasPasskey && methods.passkeySupported) {
|
||||
defaultMethod = 'passkey';
|
||||
} else if (methods.has2FA) {
|
||||
defaultMethod = '2fa';
|
||||
}
|
||||
}
|
||||
|
||||
setVerificationState(prev => ({
|
||||
...prev,
|
||||
method: defaultMethod,
|
||||
apiCall,
|
||||
title,
|
||||
description
|
||||
}));
|
||||
setIsModalVisible(true);
|
||||
|
||||
return true;
|
||||
}, [checkVerificationMethods, onError, t]);
|
||||
|
||||
// 执行验证
|
||||
const executeVerification = useCallback(async (method, code = '') => {
|
||||
if (!verificationState.apiCall) {
|
||||
showError(t('验证配置错误'));
|
||||
return;
|
||||
}
|
||||
|
||||
setVerificationState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
try {
|
||||
// 先调用验证 API,成功后后端会设置 session
|
||||
await SecureVerificationService.verify(method, code);
|
||||
|
||||
// 验证成功,调用业务 API(此时中间件会通过)
|
||||
const result = await verificationState.apiCall();
|
||||
|
||||
// 显示成功消息
|
||||
if (successMessage) {
|
||||
showSuccess(successMessage);
|
||||
}
|
||||
|
||||
// 调用成功回调
|
||||
onSuccess?.(result, method);
|
||||
|
||||
// 自动重置状态
|
||||
if (autoReset) {
|
||||
resetState();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
showError(error.message || t('验证失败,请重试'));
|
||||
onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
setVerificationState(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
}, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
|
||||
|
||||
// 设置验证码
|
||||
const setVerificationCode = useCallback((code) => {
|
||||
setVerificationState(prev => ({ ...prev, code }));
|
||||
}, []);
|
||||
|
||||
// 切换验证方式
|
||||
const switchVerificationMethod = useCallback((method) => {
|
||||
setVerificationState(prev => ({ ...prev, method, code: '' }));
|
||||
}, []);
|
||||
|
||||
// 取消验证
|
||||
const cancelVerification = useCallback(() => {
|
||||
resetState();
|
||||
}, [resetState]);
|
||||
|
||||
// 检查是否可以使用某种验证方式
|
||||
const canUseMethod = useCallback((method) => {
|
||||
switch (method) {
|
||||
case '2fa':
|
||||
return verificationMethods.has2FA;
|
||||
case 'passkey':
|
||||
return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [verificationMethods]);
|
||||
|
||||
// 获取推荐的验证方式
|
||||
const getRecommendedMethod = useCallback(() => {
|
||||
if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
|
||||
return 'passkey';
|
||||
}
|
||||
if (verificationMethods.has2FA) {
|
||||
return '2fa';
|
||||
}
|
||||
return null;
|
||||
}, [verificationMethods]);
|
||||
|
||||
/**
|
||||
* 包装 API 调用,自动处理验证错误
|
||||
* 当 API 返回需要验证的错误时,自动弹出验证模态框
|
||||
* @param {Function} apiCall - API 调用函数
|
||||
* @param {Object} options - 验证选项(同 startVerification)
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
const withVerification = useCallback(async (apiCall, options = {}) => {
|
||||
try {
|
||||
// 直接尝试调用 API
|
||||
return await apiCall();
|
||||
} catch (error) {
|
||||
// 检查是否是需要验证的错误
|
||||
if (isVerificationRequiredError(error)) {
|
||||
// 自动触发验证流程
|
||||
await startVerification(apiCall, options);
|
||||
// 不抛出错误,让验证模态框处理
|
||||
return null;
|
||||
}
|
||||
// 其他错误继续抛出
|
||||
throw error;
|
||||
}
|
||||
}, [startVerification]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isModalVisible,
|
||||
verificationMethods,
|
||||
verificationState,
|
||||
|
||||
// 方法
|
||||
startVerification,
|
||||
executeVerification,
|
||||
cancelVerification,
|
||||
resetState,
|
||||
setVerificationCode,
|
||||
switchVerificationMethod,
|
||||
checkVerificationMethods,
|
||||
|
||||
// 辅助方法
|
||||
canUseMethod,
|
||||
getRecommendedMethod,
|
||||
withVerification, // 新增:自动处理验证的包装函数
|
||||
|
||||
// 便捷属性
|
||||
hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
|
||||
isLoading: verificationState.loading,
|
||||
currentMethod: verificationState.method,
|
||||
code: verificationState.code
|
||||
};
|
||||
};
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useContext } from 'react';
|
||||
import { useState, useEffect, useMemo, useContext, useRef } from 'react';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { API } from '../../helpers';
|
||||
|
||||
@@ -29,6 +29,13 @@ export const useSidebar = () => {
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userConfig, setUserConfig] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const instanceIdRef = useRef(null);
|
||||
const hasLoadedOnceRef = useRef(false);
|
||||
|
||||
if (!instanceIdRef.current) {
|
||||
const randomPart = Math.random().toString(16).slice(2);
|
||||
instanceIdRef.current = `sidebar-${Date.now()}-${randomPart}`;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const defaultAdminConfig = {
|
||||
@@ -74,9 +81,17 @@ export const useSidebar = () => {
|
||||
}, [statusState?.status?.SidebarModulesAdmin]);
|
||||
|
||||
// 加载用户配置的通用方法
|
||||
const loadUserConfig = async () => {
|
||||
const loadUserConfig = async ({ withLoading } = {}) => {
|
||||
const shouldShowLoader =
|
||||
typeof withLoading === 'boolean'
|
||||
? withLoading
|
||||
: !hasLoadedOnceRef.current;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
if (shouldShowLoader) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const res = await API.get('/api/user/self');
|
||||
if (res.data.success && res.data.data.sidebar_modules) {
|
||||
let config;
|
||||
@@ -122,18 +137,25 @@ export const useSidebar = () => {
|
||||
});
|
||||
setUserConfig(defaultUserConfig);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (shouldShowLoader) {
|
||||
setLoading(false);
|
||||
}
|
||||
hasLoadedOnceRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新用户配置的方法(供外部调用)
|
||||
const refreshUserConfig = async () => {
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
await loadUserConfig();
|
||||
await loadUserConfig({ withLoading: false });
|
||||
}
|
||||
|
||||
// 触发全局刷新事件,通知所有useSidebar实例更新
|
||||
sidebarEventTarget.dispatchEvent(new CustomEvent(SIDEBAR_REFRESH_EVENT));
|
||||
sidebarEventTarget.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_REFRESH_EVENT, {
|
||||
detail: { sourceId: instanceIdRef.current, skipLoader: true },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 加载用户配置
|
||||
@@ -146,9 +168,15 @@ export const useSidebar = () => {
|
||||
|
||||
// 监听全局刷新事件
|
||||
useEffect(() => {
|
||||
const handleRefresh = () => {
|
||||
const handleRefresh = (event) => {
|
||||
if (event?.detail?.sourceId === instanceIdRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(adminConfig).length > 0) {
|
||||
loadUserConfig();
|
||||
loadUserConfig({
|
||||
withLoading: event?.detail?.skipLoader ? false : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export const useUsersData = () => {
|
||||
};
|
||||
|
||||
// Search users with keyword and group
|
||||
const searchUsers = async (
|
||||
const searchUsers = async (
|
||||
startIdx,
|
||||
pageSize,
|
||||
searchKeyword = null,
|
||||
@@ -154,6 +154,40 @@ export const useUsersData = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const resetUserPasskey = async (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await API.delete(`/api/user/${user.id}/reset_passkey`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('Passkey 已重置'));
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserTwoFA = async (user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await API.delete(`/api/user/${user.id}/2fa`);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('二步验证已重置'));
|
||||
} else {
|
||||
showError(message || t('操作失败,请重试'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('操作失败,请重试'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
@@ -271,6 +305,8 @@ export const useUsersData = () => {
|
||||
loadUsers,
|
||||
searchUsers,
|
||||
manageUser,
|
||||
resetUserPasskey,
|
||||
resetUserTwoFA,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
handleRow,
|
||||
|
||||
Reference in New Issue
Block a user