feat(security): 完成配置密文存储前后端闭环

- 补齐连接与代理密文字段的保留替换清空语义

- 接通保存复制删除导入接口并返回 secretless 视图

- 刷新 Wails 绑定并补充实现留痕文档
This commit is contained in:
tianqijiuyun-latiao
2026-04-03 20:11:53 +08:00
parent 91b5b85904
commit 4718755208
17 changed files with 1207 additions and 186 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Tooltip } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
@@ -61,6 +61,24 @@ const detectNavigatorPlatform = (): string => {
return navigator.userAgent || '';
};
const toLegacySavedConnectionInput = (item: any) => ({
id: typeof item?.id === 'string' ? item.id : '',
name: typeof item?.name === 'string' ? item.name : '',
config: (item?.config && typeof item.config === 'object') ? item.config : {},
includeDatabases: Array.isArray(item?.includeDatabases) ? item.includeDatabases : undefined,
includeRedisDatabases: Array.isArray(item?.includeRedisDatabases) ? item.includeRedisDatabases : undefined,
iconType: typeof item?.iconType === 'string' ? item.iconType : '',
iconColor: typeof item?.iconColor === 'string' ? item.iconColor : '',
});
const mergeSavedConnections = (current: SavedConnection[], imported: SavedConnection[]): SavedConnection[] => {
const merged = new Map<string, SavedConnection>();
current.forEach((conn) => merged.set(conn.id, conn));
imported.forEach((conn) => merged.set(conn.id, conn));
return Array.from(merged.values());
};
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSyncModalOpen, setIsSyncModalOpen] = useState(false);
@@ -186,7 +204,7 @@ function App() {
if (typeof backendApp?.ImportLegacyConnections === 'function') {
try {
await backendApp.ImportLegacyConnections(
legacy.connections.map(({ id, name, config }) => ({ id, name, config }))
legacy.connections.map(toLegacySavedConnectionInput)
);
importedLegacyConnections = true;
} catch (err) {
@@ -751,7 +769,6 @@ function App() {
const addTab = useStore(state => state.addTab);
const activeContext = useStore(state => state.activeContext);
const connections = useStore(state => state.connections);
const addConnection = useStore(state => state.addConnection);
const tabs = useStore(state => state.tabs);
const activeTabId = useStore(state => state.activeTabId);
const updateCheckInFlightRef = React.useRef(false);
@@ -1166,20 +1183,29 @@ function App() {
if (res.success) {
try {
const imported = JSON.parse(res.data);
if (Array.isArray(imported)) {
let count = 0;
imported.forEach((conn: any) => {
if (!connections.some(c => c.id === conn.id)) {
addConnection(conn);
count++;
}
});
void message.success(`成功导入 ${count} 个连接`);
} else {
if (!Array.isArray(imported)) {
void message.error("文件格式错误:需要 JSON 数组");
return;
}
} catch (e) {
void message.error("解析 JSON 失败");
const normalizedItems = imported.map(toLegacySavedConnectionInput);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.ImportLegacyConnections === 'function') {
const importedViews = await backendApp.ImportLegacyConnections(normalizedItems);
if (!Array.isArray(importedViews)) {
throw new Error('导入失败:后端未返回连接列表');
}
replaceConnections(mergeSavedConnections(connections, importedViews));
void message.success(`成功导入 ${importedViews.length} 个连接`);
return;
}
const fallbackItems = normalizedItems as SavedConnection[];
replaceConnections(mergeSavedConnections(connections, fallbackItems));
void message.success(`成功导入 ${fallbackItems.length} 个连接`);
} catch (e: any) {
void message.error(e?.message || "解析 JSON 失败");
}
} else if (res.message !== "已取消") {
void message.error("导入失败: " + res.message);
@@ -1191,7 +1217,7 @@ function App() {
void message.warning("没有连接可导出");
return;
}
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases'], "connections", "json");
const res = await (window as any).go.app.App.ExportData(connections, ['id','name','config','includeDatabases','includeRedisDatabases','iconType','iconColor'], "connections", "json");
if (res.success) {
void message.success("导出成功");
} else if (res.message !== "已取消") {

View File

@@ -353,7 +353,12 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
if (!activeProvider) return;
try {
const Service = (window as any).go?.aiservice?.Service;
const payload = { ...activeProvider, model: val };
const payload = {
...activeProvider,
model: val,
apiKey: activeProvider.apiKey || '',
hasSecret: activeProvider.hasSecret ?? Boolean(activeProvider.secretRef),
};
await Service?.AISaveProvider?.(payload);
setActiveProvider(payload);
setComposerNotice(null);

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Modal, Button, Input, Select, Form, Checkbox, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
import {
@@ -18,6 +18,7 @@ import {
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../utils/aiSettingsPresetLayout';
import { resolveProviderSecretDraft } from '../utils/providerSecretDraft';
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
@@ -88,6 +89,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [builtinPrompts, setBuiltinPrompts] = useState<Record<string, string>>({});
const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers');
const [clearProviderSecret, setClearProviderSecret] = useState(false);
const [form] = Form.useForm();
const modalBodyRef = useRef<HTMLDivElement>(null);
@@ -105,6 +107,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
const watchedType = Form.useWatch('type', form);
const watchedPresetKey = Form.useWatch('presetKey', form);
const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai';
const watchedApiKeyInput = Form.useWatch('apiKey', form);
const loadConfig = useCallback(async () => {
try {
@@ -217,12 +220,18 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
const payload = {
...editingProvider,
...values,
...resolvedTransport,
name: finalName,
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
model: finalModel,
models: resolvedModels,
baseUrl: finalBaseUrl,
@@ -230,7 +239,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
};
// 后端 AISaveProvider 统一处理新增和更新,返回 void失败抛异常
await Service?.AISaveProvider?.(payload);
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); void loadConfig();
void messageApi.success('已保存'); setIsEditing(false); setEditingProvider(null); setClearProviderSecret(false); void loadConfig();
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
} catch (e: any) {
if (e?.errorFields) { /* antd form validation error, ignore */ }
@@ -287,10 +296,20 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
presetFixedApiFormat: preset.fixedApiFormat,
valuesApiFormat: values.apiFormat,
});
const secretDraft = resolveProviderSecretDraft({
hasSecret: editingProvider?.hasSecret,
apiKeyInput: values.apiKey,
clearSecret: clearProviderSecret,
});
if (secretDraft.mode === 'clear') {
throw new Error('测试连接前请填写新的 API Key或取消清除已保存密钥');
}
const res = await Service?.AITestProvider?.({
...editingProvider,
...values,
...resolvedTransport,
apiKey: secretDraft.apiKey,
hasSecret: secretDraft.hasSecret,
baseUrl: finalBaseUrl,
model: finalModel,
models: resolvedModels,
@@ -401,7 +420,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<div>
{/* 顶部返回 */}
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 10 }}>
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); }}
<Button size="small" onClick={() => { setIsEditing(false); setEditingProvider(null); setClearProviderSecret(false); }}
style={{ borderRadius: 8 }}> </Button>
<span style={{ fontWeight: 700, fontSize: 16, color: overlayTheme.titleText }}>
{editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'}
@@ -492,11 +511,25 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
<div style={fieldLabelStyle}>
<KeyOutlined style={{ fontSize: 14 }} /> &
</div>
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}>
<Input.Password placeholder="sk-... / 你的 API Key"
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Key</span>} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || clearProviderSecret || editingProvider?.hasSecret) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: editingProvider?.hasSecret ? 8 : 16 }}>
<Input.Password placeholder={editingProvider?.hasSecret ? '留空表示继续沿用已保存密钥' : 'sk-... / 你的 API Key'}
size="middle"
style={{ borderRadius: 8, background: inputBg, border: `1px solid ${cardBorder}` }} />
</Form.Item>
{editingProvider?.hasSecret && (
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: `1px solid ${cardBorder}`, background: cardBg }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
API Key沿
</div>
<Checkbox
checked={clearProviderSecret}
disabled={String(watchedApiKeyInput || '').trim() !== ''}
onChange={(event) => setClearProviderSecret(event.target.checked)}
>
API Key
</Checkbox>
</div>
)}
{(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
<Form.Item label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>API Endpoint (URL)</span>} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}>
@@ -765,3 +798,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
};
export default AISettingsModal;

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo } from 'react';
import { Modal, Form, Input, InputNumber, Button, message, Checkbox, Divider, Select, Alert, Card, Row, Col, Typography, Collapse, Space, Table, Tag } from 'antd';
import { DatabaseOutlined, ConsoleSqlOutlined, FileTextOutlined, CloudServerOutlined, AppstoreAddOutlined, CloudOutlined, CheckCircleFilled, CloseCircleFilled, LinkOutlined, EditOutlined, AppstoreOutlined, BgColorsOutlined } from '@ant-design/icons';
import { getDbIcon, getDbDefaultColor, getDbIconLabel, DB_ICON_TYPES, PRESET_ICON_COLORS } from './DatabaseIcons';
import { useStore } from '../store';
import { buildOverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
import { resolveConnectionSecretDraft } from '../utils/connectionSecretDraft';
import { DBGetDatabases, GetDriverStatusList, MongoDiscoverMembers, TestConnection, RedisConnect, SelectDatabaseFile, SelectSSHKeyFile } from '../../wailsjs/go/app/App';
import { ConnectionConfig, MongoMemberInfo, SavedConnection } from '../types';
@@ -18,6 +19,29 @@ const CONNECTION_MODAL_BODY_HEIGHT = 620;
const STEP1_SIDEBAR_DIVIDER_DARK = 'rgba(255, 255, 255, 0.16)';
const STEP1_SIDEBAR_DIVIDER_LIGHT = 'rgba(0, 0, 0, 0.08)';
type ConnectionSecretKey =
| 'primaryPassword'
| 'sshPassword'
| 'proxyPassword'
| 'httpTunnelPassword'
| 'mysqlReplicaPassword'
| 'mongoReplicaPassword'
| 'opaqueURI'
| 'opaqueDSN';
type ConnectionSecretClearState = Record<ConnectionSecretKey, boolean>;
const createEmptyConnectionSecretClearState = (): ConnectionSecretClearState => ({
primaryPassword: false,
sshPassword: false,
proxyPassword: false,
httpTunnelPassword: false,
mysqlReplicaPassword: false,
mongoReplicaPassword: false,
opaqueURI: false,
opaqueDSN: false,
});
const getDefaultPortByType = (type: string) => {
switch (type) {
case 'mysql': return 3306;
@@ -122,6 +146,7 @@ const ConnectionModal: React.FC<{
const [driverStatusLoaded, setDriverStatusLoaded] = useState(false);
const [selectingDbFile, setSelectingDbFile] = useState(false);
const [selectingSSHKey, setSelectingSSHKey] = useState(false);
const [clearSecrets, setClearSecrets] = useState<ConnectionSecretClearState>(createEmptyConnectionSecretClearState);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
@@ -192,6 +217,51 @@ const ConnectionModal: React.FC<{
lineHeight: 1.6,
}), [overlayTheme]);
const renderStoredSecretControls = ({
fieldName,
clearKey,
hasStoredSecret,
clearLabel,
description,
}: {
fieldName: string;
clearKey: ConnectionSecretKey;
hasStoredSecret?: boolean;
clearLabel: string;
description: string;
}) => {
if (!initialValues || !hasStoredSecret) {
return null;
}
return (
<Form.Item noStyle shouldUpdate={(prev, next) => prev[fieldName] !== next[fieldName]}>
{({ getFieldValue }) => {
const draftValue = getFieldValue(fieldName);
const hasDraftValue = String(draftValue ?? '') !== '';
const cardBorder = darkMode ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(16,24,40,0.08)';
const cardBg = darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(16,24,40,0.03)';
const effectiveChecked = clearSecrets[clearKey] && !hasDraftValue;
return (
<div style={{ marginBottom: 16, padding: '10px 12px', borderRadius: 10, border: cardBorder, background: cardBg }}>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.6, marginBottom: 8 }}>
{hasDraftValue ? '已输入新值,保存时会替换当前已保存内容。' : description}
</div>
<Checkbox
checked={effectiveChecked}
disabled={hasDraftValue}
onChange={(event) => {
const checked = event.target.checked;
setClearSecrets((prev) => ({ ...prev, [clearKey]: checked }));
}}
>
{clearLabel}
</Checkbox>
</div>
);
}}
</Form.Item>
);
};
const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12 }}>
<div style={{ width: 36, height: 36, borderRadius: 12, display: 'grid', placeItems: 'center', background: overlayTheme.iconBg, color: overlayTheme.iconColor, flexShrink: 0 }}>
@@ -1066,6 +1136,7 @@ const ConnectionModal: React.FC<{
setUriFeedback(null);
setCustomIconType(undefined);
setCustomIconColor(undefined);
setClearSecrets(createEmptyConnectionSecretClearState());
setTypeSelectWarning(null);
setDriverStatusLoaded(false);
void refreshDriverStatus();
@@ -1198,6 +1269,107 @@ const ConnectionModal: React.FC<{
};
}, []);
const buildSavedConnectionInput = (config: ConnectionConfig, values: any) => {
const connectionId = initialValues?.id || config.id || Date.now().toString();
const primaryDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasPrimaryPassword,
valueInput: config.password,
clearSecret: clearSecrets.primaryPassword,
forceClear: values.type === 'mongodb' && values.savePassword === false,
});
const sshDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasSSHPassword,
valueInput: config.ssh?.password,
clearSecret: clearSecrets.sshPassword,
forceClear: !config.useSSH,
});
const proxyDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasProxyPassword,
valueInput: config.proxy?.password,
clearSecret: clearSecrets.proxyPassword,
forceClear: !config.useProxy,
});
const httpTunnelDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasHttpTunnelPassword,
valueInput: config.httpTunnel?.password,
clearSecret: clearSecrets.httpTunnelPassword,
forceClear: !config.useHttpTunnel,
});
const mysqlReplicaEnabled = (config.type === 'mysql' || config.type === 'mariadb' || config.type === 'diros' || config.type === 'sphinx')
&& config.topology === 'replica';
const mysqlReplicaDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasMySQLReplicaPassword,
valueInput: config.mysqlReplicaPassword,
clearSecret: clearSecrets.mysqlReplicaPassword,
forceClear: !mysqlReplicaEnabled,
});
const mongoReplicaEnabled = config.type === 'mongodb'
&& config.topology === 'replica'
&& values.savePassword !== false;
const mongoReplicaDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasMongoReplicaPassword,
valueInput: config.mongoReplicaPassword,
clearSecret: clearSecrets.mongoReplicaPassword,
forceClear: !mongoReplicaEnabled,
});
const opaqueUriDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasOpaqueURI,
valueInput: config.uri,
clearSecret: clearSecrets.opaqueURI,
forceClear: values.type === 'custom',
trimInput: true,
});
const opaqueDsnDraft = resolveConnectionSecretDraft({
hasSecret: initialValues?.hasOpaqueDSN,
valueInput: config.dsn,
clearSecret: clearSecrets.opaqueDSN,
forceClear: values.type !== 'custom',
trimInput: true,
});
const isRedisType = values.type === 'redis';
const displayHost = String((config as any).host || values.host || '').trim();
const nextName = values.name || (isFileDatabaseType(values.type)
? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB')
: (values.type === 'redis' ? `Redis ${displayHost}` : displayHost));
return {
id: connectionId,
name: nextName,
config: {
...config,
id: connectionId,
password: primaryDraft.value,
ssh: {
...(config.ssh || { host: '', port: 22, user: '', password: '', keyPath: '' }),
password: sshDraft.value,
},
proxy: {
...(config.proxy || { type: 'socks5', host: '', port: 1080, user: '', password: '' }),
password: proxyDraft.value,
},
httpTunnel: {
...(config.httpTunnel || { host: '', port: 8080, user: '', password: '' }),
password: httpTunnelDraft.value,
},
uri: opaqueUriDraft.value,
dsn: opaqueDsnDraft.value,
mysqlReplicaPassword: mysqlReplicaDraft.value,
mongoReplicaPassword: mongoReplicaDraft.value,
},
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
iconType: customIconType || '',
iconColor: customIconColor || '',
clearPrimaryPassword: primaryDraft.clearStoredSecret,
clearSSHPassword: sshDraft.clearStoredSecret,
clearProxyPassword: proxyDraft.clearStoredSecret,
clearHttpTunnelPassword: httpTunnelDraft.clearStoredSecret,
clearMySQLReplicaPassword: mysqlReplicaDraft.clearStoredSecret,
clearMongoReplicaPassword: mongoReplicaDraft.clearStoredSecret,
clearOpaqueURI: opaqueUriDraft.clearStoredSecret,
clearOpaqueDSN: opaqueDsnDraft.clearStoredSecret,
};
};
const handleOk = async () => {
try {
await form.validateFields();
@@ -1211,28 +1383,21 @@ const ConnectionModal: React.FC<{
setLoading(true);
const config = await buildConfig(values, true);
const displayHost = String((config as any).host || values.host || '').trim();
const isRedisType = values.type === 'redis';
const newConn = {
id: initialValues ? initialValues.id : Date.now().toString(),
name: values.name || (isFileDatabaseType(values.type) ? (values.type === 'duckdb' ? 'DuckDB DB' : 'SQLite DB') : (values.type === 'redis' ? `Redis ${displayHost}` : displayHost)),
config: config,
includeDatabases: values.includeDatabases,
includeRedisDatabases: isRedisType ? values.includeRedisDatabases : undefined,
iconType: customIconType,
iconColor: customIconColor,
};
const payload = buildSavedConnectionInput(config, values);
const backendApp = (window as any).go?.app?.App;
const savedConnection = await backendApp?.SaveConnection?.(payload);
if (!savedConnection) {
throw new Error('保存连接失败:后端接口不可用');
}
if (initialValues) {
updateConnection(newConn);
updateConnection(savedConnection);
message.success('配置已更新(未连接)');
} else {
addConnection(newConn);
addConnection(savedConnection);
message.success('配置已保存(未连接)');
}
setLoading(false);
form.resetFields();
setUseSSL(false);
setUseSSH(false);
@@ -1240,8 +1405,11 @@ const ConnectionModal: React.FC<{
setUseHttpTunnel(false);
setDbType('mysql');
setStep(1);
setClearSecrets(createEmptyConnectionSecretClearState());
onClose();
} catch (e) {
} catch (e: any) {
message.error(e?.message || '保存失败');
} finally {
setLoading(false);
}
};
@@ -1271,6 +1439,30 @@ const ConnectionModal: React.FC<{
}
};
const getBlockingSecretClearMessage = (values: any): string | null => {
if (clearSecrets.primaryPassword && values.type !== 'custom' && !isFileDatabaseType(values.type) && String(values.password ?? '') === '') {
return '测试连接前请填写新的密码,或取消清除已保存密码';
}
if (clearSecrets.sshPassword && values.useSSH && String(values.sshPassword ?? '') === '') {
return '测试连接前请填写新的 SSH 密码,或取消清除已保存 SSH 密码';
}
if (clearSecrets.proxyPassword && values.useProxy && !values.useHttpTunnel && String(values.proxyPassword ?? '') === '') {
return '测试连接前请填写新的代理密码,或取消清除已保存代理密码';
}
if (clearSecrets.httpTunnelPassword && values.useHttpTunnel && String(values.httpTunnelPassword ?? '') === '') {
return '测试连接前请填写新的隧道密码,或取消清除已保存隧道密码';
}
if (clearSecrets.mysqlReplicaPassword && (values.type === 'mysql' || values.type === 'mariadb' || values.type === 'diros' || values.type === 'sphinx') && values.mysqlTopology === 'replica' && String(values.mysqlReplicaPassword ?? '') === '') {
return '测试连接前请填写新的从库密码,或取消清除已保存从库密码';
}
if (clearSecrets.mongoReplicaPassword && values.type === 'mongodb' && values.mongoTopology === 'replica' && String(values.mongoReplicaPassword ?? '') === '') {
return '测试连接前请填写新的副本集密码,或取消清除已保存副本集密码';
}
if (values.type === 'mongodb' && values.savePassword === false && initialValues?.hasPrimaryPassword && String(values.password ?? '') === '') {
return '测试连接前请填写新的 MongoDB 密码,或重新勾选保存密码';
}
return null;
};
const buildTestFailureMessage = (reason: unknown, fallback: string) => {
const text = String(reason ?? '').trim();
const normalized = text && text !== 'undefined' && text !== 'null' ? text : fallback;
@@ -1290,9 +1482,17 @@ const ConnectionModal: React.FC<{
promptInstallDriver(values.type, unavailableReason);
return;
}
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
if (blockingSecretClearMessage) {
setTestResult({ type: 'error', message: blockingSecretClearMessage });
return;
}
setLoading(true);
setTestResult(null);
const config = await buildConfig(values, false);
if (initialValues?.id) {
config.id = initialValues.id;
}
const timeoutSecondsRaw = Number(values.timeout);
const timeoutSeconds = Number.isFinite(timeoutSecondsRaw) && timeoutSecondsRaw > 0
? Math.min(timeoutSecondsRaw, MAX_TIMEOUT_SECONDS)
@@ -1368,7 +1568,15 @@ const ConnectionModal: React.FC<{
await form.validateFields();
const values = form.getFieldsValue(true);
setDiscoveringMembers(true);
const blockingSecretClearMessage = getBlockingSecretClearMessage(values);
if (blockingSecretClearMessage) {
message.error(blockingSecretClearMessage);
return;
}
const config = await buildConfig(values, false);
if (initialValues?.id) {
config.id = initialValues.id;
}
const result = await MongoDiscoverMembers(config as any);
if (!result.success) {
message.error(result.message || '成员发现失败');
@@ -1877,6 +2085,13 @@ const ConnectionModal: React.FC<{
style={{ marginBottom: 16 }}
/>
)}
{renderStoredSecretControls({
fieldName: 'uri',
clearKey: 'opaqueURI',
hasStoredSecret: initialValues?.hasOpaqueURI,
clearLabel: '清除已保存 URI',
description: '当前已保存连接 URI。留空表示继续沿用输入新值表示替换。',
})}
</>
)}
@@ -1888,6 +2103,13 @@ const ConnectionModal: React.FC<{
<Form.Item name="dsn" label="连接字符串 (DSN)" rules={[{ required: true, message: '请输入连接字符串' }]}>
<Input.TextArea rows={4} placeholder="例如: user:pass@tcp(localhost:3306)/dbname?charset=utf8" />
</Form.Item>
{renderStoredSecretControls({
fieldName: 'dsn',
clearKey: 'opaqueDSN',
hasStoredSecret: initialValues?.hasOpaqueDSN,
clearLabel: '清除已保存 DSN',
description: '当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。',
})}
</>
) : (
<>
@@ -1968,6 +2190,13 @@ const ConnectionModal: React.FC<{
<Input.Password placeholder="留空沿用主库密码" />
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'mysqlReplicaPassword',
clearKey: 'mysqlReplicaPassword',
hasStoredSecret: initialValues?.hasMySQLReplicaPassword,
clearLabel: '清除已保存从库密码',
description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。',
})}
</>
)}
</>
@@ -2010,6 +2239,13 @@ const ConnectionModal: React.FC<{
<Form.Item name="mongoReplicaPassword" label="副本集密码(可选)" style={{ marginBottom: 0 }}>
<Input.Password placeholder="留空沿用主密码" />
</Form.Item>
{renderStoredSecretControls({
fieldName: 'mongoReplicaPassword',
clearKey: 'mongoReplicaPassword',
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
clearLabel: '清除已保存副本集密码',
description: '当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。',
})}
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
<Button onClick={handleDiscoverMongoMembers} loading={discoveringMembers}></Button>
</Space>
@@ -2084,6 +2320,13 @@ const ConnectionModal: React.FC<{
<Form.Item name="password" label="密码 (可选)">
<Input.Password placeholder="Redis 密码(如果设置了 requirepass" />
</Form.Item>
{renderStoredSecretControls({
fieldName: 'password',
clearKey: 'primaryPassword',
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: '清除已保存密码',
description: '当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。',
})}
<Form.Item
name="includeRedisDatabases"
label="显示数据库 (留空显示全部)"
@@ -2097,6 +2340,7 @@ const ConnectionModal: React.FC<{
)}
{!isFileDb && !isRedis && (
<>
<div style={{ display: 'grid', gridTemplateColumns: dbType === 'mongodb' ? 'minmax(0, 1fr) minmax(0, 1fr) 180px' : 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item
name="user"
@@ -2124,6 +2368,14 @@ const ConnectionModal: React.FC<{
</Form.Item>
)}
</div>
{renderStoredSecretControls({
fieldName: 'password',
clearKey: 'primaryPassword',
hasStoredSecret: initialValues?.hasPrimaryPassword,
clearLabel: '清除已保存密码',
description: '当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。',
})}
</>
)}
{dbType === 'mongodb' && (
@@ -2233,6 +2485,13 @@ const ConnectionModal: React.FC<{
</Button>
</Space.Compact>
</Form.Item>
{renderStoredSecretControls({
fieldName: 'sshPassword',
clearKey: 'sshPassword',
hasStoredSecret: initialValues?.hasSSHPassword,
clearLabel: '清除已保存 SSH 密码',
description: '当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。',
})}
</div>
)}
</div>
@@ -2271,6 +2530,13 @@ const ConnectionModal: React.FC<{
<Input.Password placeholder="留空表示无认证" />
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'proxyPassword',
clearKey: 'proxyPassword',
hasStoredSecret: initialValues?.hasProxyPassword,
clearLabel: '清除已保存代理密码',
description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。',
})}
</div>
)}
</div>
@@ -2302,6 +2568,13 @@ const ConnectionModal: React.FC<{
<Input.Password placeholder="留空表示无认证" />
</Form.Item>
</div>
{renderStoredSecretControls({
fieldName: 'httpTunnelPassword',
clearKey: 'httpTunnelPassword',
hasStoredSecret: initialValues?.hasHttpTunnelPassword,
clearLabel: '清除已保存隧道密码',
description: '当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。',
})}
<Text type="secondary" style={{ fontSize: 12 }}>使 HTTP CONNECT </Text>
</div>
)}
@@ -2832,3 +3105,6 @@ const ConnectionModal: React.FC<{
};
export default ConnectionModal;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState, useMemo, useRef } from 'react';
import React, { useEffect, useState, useMemo, useRef } from 'react';
import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, Checkbox, Space, Select, Popover, Tooltip, Progress } from 'antd';
import {
DatabaseOutlined,
@@ -367,129 +367,25 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
});
}, [connections, connectionTags]);
const buildDuplicateConnectionName = (rawName: string): string => {
const baseName = String(rawName || '').trim() || '连接';
const suffix = ' - 副本';
const usedNames = new Set(connections.map(conn => String(conn.name || '').trim()));
let candidate = `${baseName}${suffix}`;
let counter = 2;
while (usedNames.has(candidate)) {
candidate = `${baseName}${suffix} ${counter}`;
counter += 1;
}
return candidate;
};
const handleDuplicateConnection = async (conn: SavedConnection) => {
if (!conn?.id) return;
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DuplicateConnection !== 'function') {
message.error('复制连接失败:后端接口不可用');
return;
}
const cloneConnectionConfig = (config: SavedConnection['config']): SavedConnection['config'] => {
const raw: any = config || {};
let cloned: any = {};
try {
cloned = typeof structuredClone === 'function'
? structuredClone(raw)
: JSON.parse(JSON.stringify(raw));
} catch {
cloned = { ...raw };
const duplicatedConnection = await backendApp.DuplicateConnection(conn.id);
if (!duplicatedConnection) {
throw new Error('复制连接失败:后端未返回结果');
}
addConnection(duplicatedConnection);
message.success(`已复制连接: ${duplicatedConnection.name}`);
} catch (error: any) {
message.error(error?.message || '复制连接失败');
}
const readString = (...values: unknown[]): string => {
for (const value of values) {
if (typeof value === 'string') {
return value;
}
}
return '';
};
const readBool = (fallback: boolean, ...values: unknown[]): boolean => {
for (const value of values) {
if (typeof value === 'boolean') {
return value;
}
}
return fallback;
};
const readNumber = (fallback: number, ...values: unknown[]): number => {
for (const value of values) {
const num = Number(value);
if (Number.isFinite(num)) {
return num;
}
}
return fallback;
};
const rawSSH = (cloned.ssh ?? cloned.SSH ?? {}) as Record<string, unknown>;
const normalizedSSH = {
host: readString(rawSSH.host, rawSSH.Host, cloned.sshHost, cloned.SSHHost),
port: readNumber(22, rawSSH.port, rawSSH.Port, cloned.sshPort, cloned.SSHPort),
user: readString(rawSSH.user, rawSSH.User, cloned.sshUser, cloned.SSHUser),
password: readString(rawSSH.password, rawSSH.Password, cloned.sshPassword, cloned.SSHPassword),
keyPath: readString(rawSSH.keyPath, rawSSH.KeyPath, cloned.sshKeyPath, cloned.SSHKeyPath),
};
const hasSSHDetail = Boolean(
normalizedSSH.host
|| normalizedSSH.user
|| normalizedSSH.password
|| normalizedSSH.keyPath
);
const rawProxy = (cloned.proxy ?? cloned.Proxy ?? {}) as Record<string, unknown>;
const proxyTypeRaw = readString(rawProxy.type, rawProxy.Type, cloned.proxyType, cloned.ProxyType).toLowerCase();
const proxyType: 'socks5' | 'http' = proxyTypeRaw === 'http' ? 'http' : 'socks5';
const normalizedProxy = {
type: proxyType,
host: readString(rawProxy.host, rawProxy.Host, cloned.proxyHost, cloned.ProxyHost),
port: readNumber(proxyType === 'http' ? 8080 : 1080, rawProxy.port, rawProxy.Port, cloned.proxyPort, cloned.ProxyPort),
user: readString(rawProxy.user, rawProxy.User, cloned.proxyUser, cloned.ProxyUser),
password: readString(rawProxy.password, rawProxy.Password, cloned.proxyPassword, cloned.ProxyPassword),
};
const hasProxyDetail = Boolean(normalizedProxy.host || normalizedProxy.user || normalizedProxy.password);
const rawHttpTunnel = (cloned.httpTunnel ?? cloned.HTTPTunnel ?? {}) as Record<string, unknown>;
const normalizedHttpTunnel = {
host: readString(rawHttpTunnel.host, rawHttpTunnel.Host, cloned.httpTunnelHost, cloned.HttpTunnelHost),
port: readNumber(8080, rawHttpTunnel.port, rawHttpTunnel.Port, cloned.httpTunnelPort, cloned.HttpTunnelPort),
user: readString(rawHttpTunnel.user, rawHttpTunnel.User, cloned.httpTunnelUser, cloned.HttpTunnelUser),
password: readString(rawHttpTunnel.password, rawHttpTunnel.Password, cloned.httpTunnelPassword, cloned.HttpTunnelPassword),
};
const hasHttpTunnelDetail = Boolean(normalizedHttpTunnel.host || normalizedHttpTunnel.user || normalizedHttpTunnel.password);
const normalizedUseHttpTunnel = readBool(hasHttpTunnelDetail, cloned.useHttpTunnel, cloned.UseHTTPTunnel);
const normalizedUseProxy = !normalizedUseHttpTunnel && readBool(hasProxyDetail, cloned.useProxy, cloned.UseProxy);
const rawHosts = Array.isArray(cloned.hosts)
? cloned.hosts
: (Array.isArray(cloned.Hosts) ? cloned.Hosts : []);
const normalizedHosts = rawHosts
.map((entry: unknown) => String(entry || '').trim())
.filter((entry: string) => !!entry);
return {
...(cloned as SavedConnection['config']),
useSSH: readBool(hasSSHDetail, cloned.useSSH, cloned.UseSSH),
ssh: normalizedSSH,
useProxy: normalizedUseProxy,
proxy: normalizedProxy,
useHttpTunnel: normalizedUseHttpTunnel,
httpTunnel: normalizedHttpTunnel,
hosts: normalizedHosts,
timeout: readNumber(30, cloned.timeout, cloned.Timeout),
};
};
const handleDuplicateConnection = (conn: SavedConnection) => {
if (!conn) return;
const duplicatedConnection: SavedConnection = {
...conn,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: buildDuplicateConnectionName(conn.name),
config: cloneConnectionConfig(conn.config),
includeDatabases: conn.includeDatabases ? [...conn.includeDatabases] : undefined,
includeRedisDatabases: conn.includeRedisDatabases ? [...conn.includeRedisDatabases] : undefined,
};
addConnection(duplicatedConnection);
message.success(`已复制连接: ${duplicatedConnection.name}`);
};
const updateTreeData = (list: TreeNode[], key: React.Key, children: TreeNode[] | undefined): TreeNode[] => {
return list.map(node => {
@@ -3163,9 +3059,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => {
closeTabsByConnection(String(node.key));
removeConnection(node.key);
onOk: async () => {
const connId = String(node.key);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DeleteConnection !== 'function') {
message.error('删除连接失败:后端接口不可用');
throw new Error('DeleteConnection unavailable');
}
try {
await backendApp.DeleteConnection(connId);
closeTabsByConnection(connId);
removeConnection(connId);
message.success('已删除连接');
} catch (error: any) {
message.error(error?.message || '删除连接失败');
throw error;
}
}
});
}
@@ -3300,9 +3209,22 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => {
closeTabsByConnection(String(node.key));
removeConnection(node.key);
onOk: async () => {
const connId = String(node.key);
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.DeleteConnection !== 'function') {
message.error('删除连接失败:后端接口不可用');
throw new Error('DeleteConnection unavailable');
}
try {
await backendApp.DeleteConnection(connId);
closeTabsByConnection(connId);
removeConnection(connId);
message.success('已删除连接');
} catch (error: any) {
message.error(error?.message || '删除连接失败');
throw error;
}
}
});
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
// import './index.css' // Optional global styles
@@ -17,15 +17,125 @@ import { loader } from '@monaco-editor/react'
import * as monaco from 'monaco-editor'
loader.config({ monaco })
const cloneBrowserMockValue = (value: any) => {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
};
const resolveBrowserMockSecretFlag = (nextValue: unknown, clearFlag: boolean, existingFlag?: boolean) => {
if (String(nextValue ?? '') !== '') return true;
if (clearFlag) return false;
return !!existingFlag;
};
const buildBrowserMockDuplicateName = (rawName: string, items: any[]): string => {
const baseName = String(rawName || '').trim() || '连接';
const suffix = ' - 副本';
const usedNames = new Set(items.map((item) => String(item?.name || '').trim()));
let candidate = `${baseName}${suffix}`;
let counter = 2;
while (usedNames.has(candidate)) {
candidate = `${baseName}${suffix} ${counter}`;
counter += 1;
}
return candidate;
};
if (typeof window !== 'undefined' && !(window as any).go) {
const mockConnections: any[] = [];
let mockGlobalProxy: any = { enabled: false, type: 'socks5', host: '', port: 1080, user: '', password: '', hasPassword: false };
const upsertMockConnection = (view: any) => {
const index = mockConnections.findIndex((item) => item.id === view.id);
if (index >= 0) {
mockConnections[index] = view;
return;
}
mockConnections.push(view);
};
const saveMockConnection = (input: any) => {
const existing = mockConnections.find((item) => item.id === input?.id);
const config = (input?.config && typeof input.config === 'object') ? input.config : {};
const ssh = (config.ssh && typeof config.ssh === 'object') ? config.ssh : {};
const proxy = (config.proxy && typeof config.proxy === 'object') ? config.proxy : {};
const httpTunnel = (config.httpTunnel && typeof config.httpTunnel === 'object') ? config.httpTunnel : {};
const nextId = String(input?.id || existing?.id || `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
const view = {
id: nextId,
name: String(input?.name || existing?.name || '未命名连接'),
config: {
...config,
id: nextId,
password: '',
ssh: { ...ssh, password: '' },
proxy: { ...proxy, password: '' },
httpTunnel: { ...httpTunnel, password: '' },
uri: '',
dsn: '',
mysqlReplicaPassword: '',
mongoReplicaPassword: '',
},
includeDatabases: Array.isArray(input?.includeDatabases) ? [...input.includeDatabases] : existing?.includeDatabases,
includeRedisDatabases: Array.isArray(input?.includeRedisDatabases) ? [...input.includeRedisDatabases] : existing?.includeRedisDatabases,
iconType: typeof input?.iconType === 'string' ? input.iconType : (existing?.iconType || ''),
iconColor: typeof input?.iconColor === 'string' ? input.iconColor : (existing?.iconColor || ''),
hasPrimaryPassword: resolveBrowserMockSecretFlag(config.password, !!input?.clearPrimaryPassword, existing?.hasPrimaryPassword),
hasSSHPassword: resolveBrowserMockSecretFlag(ssh.password, !!input?.clearSSHPassword, existing?.hasSSHPassword),
hasProxyPassword: resolveBrowserMockSecretFlag(proxy.password, !!input?.clearProxyPassword, existing?.hasProxyPassword),
hasHttpTunnelPassword: resolveBrowserMockSecretFlag(httpTunnel.password, !!input?.clearHttpTunnelPassword, existing?.hasHttpTunnelPassword),
hasMySQLReplicaPassword: resolveBrowserMockSecretFlag(config.mysqlReplicaPassword, !!input?.clearMySQLReplicaPassword, existing?.hasMySQLReplicaPassword),
hasMongoReplicaPassword: resolveBrowserMockSecretFlag(config.mongoReplicaPassword, !!input?.clearMongoReplicaPassword, existing?.hasMongoReplicaPassword),
hasOpaqueURI: resolveBrowserMockSecretFlag(config.uri, !!input?.clearOpaqueURI, existing?.hasOpaqueURI),
hasOpaqueDSN: resolveBrowserMockSecretFlag(config.dsn, !!input?.clearOpaqueDSN, existing?.hasOpaqueDSN),
};
upsertMockConnection(view);
return cloneBrowserMockValue(view);
};
const saveMockGlobalProxy = (input: any) => {
const nextPassword = String(input?.password ?? '');
mockGlobalProxy = {
...mockGlobalProxy,
...input,
password: '',
hasPassword: nextPassword !== '' ? true : !!mockGlobalProxy.hasPassword,
};
return cloneBrowserMockValue(mockGlobalProxy);
};
(window as any).go = {
app: {
App: {
CheckUpdate: async () => ({ success: false }),
DownloadUpdate: async () => ({ success: false }),
GetSavedConnections: async () => [],
SaveConnection: async () => null,
DeleteConnection: async () => null,
GetSavedConnections: async () => cloneBrowserMockValue(mockConnections),
SaveConnection: async (input: any) => saveMockConnection(input),
DeleteConnection: async (id: string) => {
const index = mockConnections.findIndex((item) => item.id === id);
if (index >= 0) {
mockConnections.splice(index, 1);
}
return null;
},
DuplicateConnection: async (id: string) => {
const existing = mockConnections.find((item) => item.id === id);
if (!existing) return null;
const duplicated = cloneBrowserMockValue({
...existing,
id: `mock-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: buildBrowserMockDuplicateName(existing.name, mockConnections),
config: cloneBrowserMockValue(existing.config),
includeDatabases: Array.isArray(existing.includeDatabases) ? [...existing.includeDatabases] : undefined,
includeRedisDatabases: Array.isArray(existing.includeRedisDatabases) ? [...existing.includeRedisDatabases] : undefined,
});
mockConnections.push(duplicated);
return cloneBrowserMockValue(duplicated);
},
ImportLegacyConnections: async (items: any[]) => items.map((item) => saveMockConnection(item)),
OpenConnection: async () => null,
CloseConnection: async () => null,
GetDatabases: async () => [],
@@ -42,11 +152,13 @@ if (typeof window !== 'undefined' && !(window as any).go) {
InstallUpdateAndRestart: async () => ({ success: false }),
ImportConfigFile: async () => ({ success: false }),
ExportData: async () => ({ success: false }),
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
ImportLegacyGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
}
}
};
}
// 全局注册透明主题,避免每个 Editor 组件 beforeMount 中重复定义
monaco.editor.defineTheme('transparent-dark', {
base: 'vs-dark', inherit: true, rules: [],

View File

@@ -217,6 +217,8 @@ export interface AIProviderConfig {
type: AIProviderType;
name: string;
apiKey: string;
secretRef?: string;
hasSecret?: boolean;
baseUrl: string;
model: string;
models?: string[];

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { resolveConnectionSecretDraft } from './connectionSecretDraft';
describe('resolveConnectionSecretDraft', () => {
it('keeps an existing stored secret when edit form leaves the field blank', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: '',
clearSecret: false,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(true);
expect(result.hasSecretAfterSave).toBe(true);
});
it('replaces the stored secret when a new value is entered', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: ' mongodb://demo ',
clearSecret: false,
trimInput: true,
});
expect(result.value).toBe('mongodb://demo');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(true);
});
it('clears the stored secret when explicitly requested', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: '',
clearSecret: true,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(true);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(false);
});
it('prefers a newly entered value over a stale clear toggle', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: 'new-password',
clearSecret: true,
});
expect(result.value).toBe('new-password');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(true);
});
it('does not emit a clear flag for a brand new blank field', () => {
const result = resolveConnectionSecretDraft({
hasSecret: false,
valueInput: '',
clearSecret: false,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(false);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(false);
});
it('supports force clearing stored secrets', () => {
const result = resolveConnectionSecretDraft({
hasSecret: true,
valueInput: 'temporary',
clearSecret: false,
forceClear: true,
});
expect(result.value).toBe('');
expect(result.clearStoredSecret).toBe(true);
expect(result.keepsStoredSecret).toBe(false);
expect(result.hasSecretAfterSave).toBe(false);
});
});

View File

@@ -0,0 +1,63 @@
export interface ConnectionSecretDraftInput {
valueInput?: string;
hasSecret?: boolean;
clearSecret?: boolean;
forceClear?: boolean;
trimInput?: boolean;
}
export interface ConnectionSecretDraftResult {
value: string;
clearStoredSecret: boolean;
keepsStoredSecret: boolean;
hasSecretAfterSave: boolean;
}
export function resolveConnectionSecretDraft(input: ConnectionSecretDraftInput): ConnectionSecretDraftResult {
const rawValue = input.valueInput ?? '';
const value = input.trimInput ? String(rawValue).trim() : String(rawValue);
if (input.forceClear) {
return {
value: '',
clearStoredSecret: true,
keepsStoredSecret: false,
hasSecretAfterSave: false,
};
}
if (value !== '') {
return {
value,
clearStoredSecret: false,
keepsStoredSecret: false,
hasSecretAfterSave: true,
};
}
if (input.clearSecret) {
return {
value: '',
clearStoredSecret: true,
keepsStoredSecret: false,
hasSecretAfterSave: false,
};
}
if (input.hasSecret) {
return {
value: '',
clearStoredSecret: false,
keepsStoredSecret: true,
hasSecretAfterSave: true,
};
}
return {
value: '',
clearStoredSecret: false,
keepsStoredSecret: false,
hasSecretAfterSave: false,
};
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { resolveProviderSecretDraft } from './providerSecretDraft';
describe('resolveProviderSecretDraft', () => {
it('keeps existing provider secret when edit form leaves apiKey blank', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: '',
clearSecret: false,
});
expect(result.mode).toBe('keep');
expect(result.apiKey).toBe('');
expect(result.hasSecret).toBe(true);
});
it('replaces the provider secret when a new apiKey is entered', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: ' sk-new ',
clearSecret: false,
});
expect(result.mode).toBe('replace');
expect(result.apiKey).toBe('sk-new');
expect(result.hasSecret).toBe(true);
});
it('clears the stored provider secret when requested', () => {
const result = resolveProviderSecretDraft({
hasSecret: true,
apiKeyInput: '',
clearSecret: true,
});
expect(result.mode).toBe('clear');
expect(result.apiKey).toBe('');
expect(result.hasSecret).toBe(false);
});
});

View File

@@ -0,0 +1,47 @@
export type ProviderSecretDraftMode = 'keep' | 'replace' | 'clear';
export interface ProviderSecretDraftInput {
hasSecret?: boolean;
apiKeyInput?: string;
clearSecret?: boolean;
}
export interface ProviderSecretDraftResult {
mode: ProviderSecretDraftMode;
apiKey: string;
hasSecret: boolean;
}
export function resolveProviderSecretDraft(input: ProviderSecretDraftInput): ProviderSecretDraftResult {
const apiKey = String(input.apiKeyInput || '').trim();
if (input.clearSecret) {
return {
mode: 'clear',
apiKey: '',
hasSecret: false,
};
}
if (apiKey) {
return {
mode: 'replace',
apiKey,
hasSecret: true,
};
}
if (input.hasSecret) {
return {
mode: 'keep',
apiKey: '',
hasSecret: true,
};
}
return {
mode: 'clear',
apiKey: '',
hasSecret: false,
};
}