diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c285de3..f6c1554 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(); + 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 !== "已取消") { diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 695bf09..754aafe 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -353,7 +353,12 @@ export const AIChatPanel: React.FC = ({ 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); diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index 403f352..7d586a4 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -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 = ({ open, onClose, darkMo const [testStatus, setTestStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [builtinPrompts, setBuiltinPrompts] = useState>({}); const [activeSection, setActiveSection] = useState<'providers' | 'safety' | 'context' | 'prompts' | 'tools'>('providers'); + const [clearProviderSecret, setClearProviderSecret] = useState(false); const [form] = Form.useForm(); const modalBodyRef = useRef(null); @@ -105,6 +107,7 @@ const AISettingsModal: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ open, onClose, darkMo
{/* 顶部返回 */}
- {editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'} @@ -492,11 +511,25 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
认证 & 连接
- API Key
} name="apiKey" rules={[{ required: true, message: '请输入 API Key' }]} style={{ marginBottom: 16 }}> - API Key} 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 }}> + + {editingProvider?.hasSecret && ( +
+
+ 当前已保存 API Key。留空表示继续沿用,输入新值表示替换。 +
+ setClearProviderSecret(event.target.checked)} + > + 清除已保存 API Key + +
+ )} {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> @@ -765,3 +798,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }; export default AISettingsModal; + + + + diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index c1f17a8..b0f0763 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -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; + +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(createEmptyConnectionSecretClearState); const testInFlightRef = useRef(false); const testTimerRef = useRef(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 ( + 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 ( +
+
+ {hasDraftValue ? '已输入新值,保存时会替换当前已保存内容。' : description} +
+ { + const checked = event.target.checked; + setClearSecrets((prev) => ({ ...prev, [clearKey]: checked })); + }} + > + {clearLabel} + +
+ ); + }} +
+ ); + }; const renderConnectionModalTitle = (icon: React.ReactNode, title: string, description: string) => (
@@ -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<{ + {renderStoredSecretControls({ + fieldName: 'dsn', + clearKey: 'opaqueDSN', + hasStoredSecret: initialValues?.hasOpaqueDSN, + clearLabel: '清除已保存 DSN', + description: '当前已保存连接字符串。留空表示继续沿用,输入新值表示替换。', + })} ) : ( <> @@ -1968,6 +2190,13 @@ const ConnectionModal: React.FC<{
+ {renderStoredSecretControls({ + fieldName: 'mysqlReplicaPassword', + clearKey: 'mysqlReplicaPassword', + hasStoredSecret: initialValues?.hasMySQLReplicaPassword, + clearLabel: '清除已保存从库密码', + description: '当前已保存从库密码。留空表示继续沿用,输入新值表示替换。', + })} )} @@ -2010,6 +2239,13 @@ const ConnectionModal: React.FC<{ + {renderStoredSecretControls({ + fieldName: 'mongoReplicaPassword', + clearKey: 'mongoReplicaPassword', + hasStoredSecret: initialValues?.hasMongoReplicaPassword, + clearLabel: '清除已保存副本集密码', + description: '当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。', + })} @@ -2084,6 +2320,13 @@ const ConnectionModal: React.FC<{ + {renderStoredSecretControls({ + fieldName: 'password', + clearKey: 'primaryPassword', + hasStoredSecret: initialValues?.hasPrimaryPassword, + clearLabel: '清除已保存密码', + description: '当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。', + })}
)}
+ {renderStoredSecretControls({ + fieldName: 'password', + clearKey: 'primaryPassword', + hasStoredSecret: initialValues?.hasPrimaryPassword, + clearLabel: '清除已保存密码', + description: '当前已保存主连接密码。留空表示继续沿用,输入新值表示替换。', + })} + )} {dbType === 'mongodb' && ( @@ -2233,6 +2485,13 @@ const ConnectionModal: React.FC<{
+ {renderStoredSecretControls({ + fieldName: 'sshPassword', + clearKey: 'sshPassword', + hasStoredSecret: initialValues?.hasSSHPassword, + clearLabel: '清除已保存 SSH 密码', + description: '当前已保存 SSH 密码。留空表示继续沿用,输入新值表示替换。', + })}
)}
@@ -2271,6 +2530,13 @@ const ConnectionModal: React.FC<{
+ {renderStoredSecretControls({ + fieldName: 'proxyPassword', + clearKey: 'proxyPassword', + hasStoredSecret: initialValues?.hasProxyPassword, + clearLabel: '清除已保存代理密码', + description: '当前已保存代理密码。留空表示继续沿用,输入新值表示替换。', + })} )} @@ -2302,6 +2568,13 @@ const ConnectionModal: React.FC<{ + {renderStoredSecretControls({ + fieldName: 'httpTunnelPassword', + clearKey: 'httpTunnelPassword', + hasStoredSecret: initialValues?.hasHttpTunnelPassword, + clearLabel: '清除已保存隧道密码', + description: '当前已保存隧道密码。留空表示继续沿用,输入新值表示替换。', + })} 与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。 )} @@ -2832,3 +3105,6 @@ const ConnectionModal: React.FC<{ }; export default ConnectionModal; + + + diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 91b61c3..ed3743b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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; - 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; - 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; - 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; + } } }); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 4f8fa6e..becce4a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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: [], diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 34db0ec..40e1e9e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -217,6 +217,8 @@ export interface AIProviderConfig { type: AIProviderType; name: string; apiKey: string; + secretRef?: string; + hasSecret?: boolean; baseUrl: string; model: string; models?: string[]; diff --git a/frontend/src/utils/connectionSecretDraft.test.ts b/frontend/src/utils/connectionSecretDraft.test.ts new file mode 100644 index 0000000..1577c0b --- /dev/null +++ b/frontend/src/utils/connectionSecretDraft.test.ts @@ -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); + }); +}); + diff --git a/frontend/src/utils/connectionSecretDraft.ts b/frontend/src/utils/connectionSecretDraft.ts new file mode 100644 index 0000000..368aeb5 --- /dev/null +++ b/frontend/src/utils/connectionSecretDraft.ts @@ -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, + }; +} + diff --git a/frontend/src/utils/providerSecretDraft.test.ts b/frontend/src/utils/providerSecretDraft.test.ts new file mode 100644 index 0000000..09d652a --- /dev/null +++ b/frontend/src/utils/providerSecretDraft.test.ts @@ -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); + }); +}); diff --git a/frontend/src/utils/providerSecretDraft.ts b/frontend/src/utils/providerSecretDraft.ts new file mode 100644 index 0000000..be0ce45 --- /dev/null +++ b/frontend/src/utils/providerSecretDraft.ts @@ -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, + }; +} diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index d8203ed..08c1dd8 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -52,6 +52,8 @@ export function DataSyncAnalyze(arg1:sync.SyncConfig):Promise; +export function DeleteConnection(arg1:string):Promise; + export function DownloadDriverPackage(arg1:string,arg2:string,arg3:string,arg4:string):Promise; export function DownloadUpdate():Promise; @@ -64,6 +66,8 @@ export function DropTable(arg1:connection.ConnectionConfig,arg2:string,arg3:stri export function DropView(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; +export function DuplicateConnection(arg1:string):Promise; + export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; @@ -90,12 +94,18 @@ export function GetDriverVersionPackageSize(arg1:string,arg2:string):Promise; +export function GetSavedConnections():Promise>; + export function ImportConfigFile():Promise; export function ImportData(arg1:connection.ConnectionConfig,arg2:string,arg3:string):Promise; export function ImportDataWithProgress(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise; +export function ImportLegacyConnections(arg1:Array):Promise>; + +export function ImportLegacyGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise; + export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string):Promise; export function InstallUpdateAndRestart():Promise; @@ -180,6 +190,10 @@ export function ResolveDriverPackageDownloadURL(arg1:string,arg2:string):Promise export function ResolveDriverRepositoryURL(arg1:string):Promise; +export function SaveConnection(arg1:connection.SavedConnectionInput):Promise; + +export function SaveGlobalProxy(arg1:connection.SaveGlobalProxyInput):Promise; + export function SelectDatabaseFile(arg1:string,arg2:string):Promise; export function SelectDriverDownloadDirectory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index 8862f24..9564131 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -98,6 +98,10 @@ export function DataSyncPreview(arg1, arg2, arg3) { return window['go']['app']['App']['DataSyncPreview'](arg1, arg2, arg3); } +export function DeleteConnection(arg1) { + return window['go']['app']['App']['DeleteConnection'](arg1); +} + export function DownloadDriverPackage(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['DownloadDriverPackage'](arg1, arg2, arg3, arg4); } @@ -122,6 +126,10 @@ export function DropView(arg1, arg2, arg3) { return window['go']['app']['App']['DropView'](arg1, arg2, arg3); } +export function DuplicateConnection(arg1) { + return window['go']['app']['App']['DuplicateConnection'](arg1); +} + export function ExecuteSQLFile(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ExecuteSQLFile'](arg1, arg2, arg3, arg4); } @@ -174,6 +182,10 @@ export function GetGlobalProxyConfig() { return window['go']['app']['App']['GetGlobalProxyConfig'](); } +export function GetSavedConnections() { + return window['go']['app']['App']['GetSavedConnections'](); +} + export function ImportConfigFile() { return window['go']['app']['App']['ImportConfigFile'](); } @@ -186,6 +198,14 @@ export function ImportDataWithProgress(arg1, arg2, arg3, arg4) { return window['go']['app']['App']['ImportDataWithProgress'](arg1, arg2, arg3, arg4); } +export function ImportLegacyConnections(arg1) { + return window['go']['app']['App']['ImportLegacyConnections'](arg1); +} + +export function ImportLegacyGlobalProxy(arg1) { + return window['go']['app']['App']['ImportLegacyGlobalProxy'](arg1); +} + export function InstallLocalDriverPackage(arg1, arg2, arg3) { return window['go']['app']['App']['InstallLocalDriverPackage'](arg1, arg2, arg3); } @@ -354,6 +374,14 @@ export function ResolveDriverRepositoryURL(arg1) { return window['go']['app']['App']['ResolveDriverRepositoryURL'](arg1); } +export function SaveConnection(arg1) { + return window['go']['app']['App']['SaveConnection'](arg1); +} + +export function SaveGlobalProxy(arg1) { + return window['go']['app']['App']['SaveGlobalProxy'](arg1); +} + export function SelectDatabaseFile(arg1, arg2) { return window['go']['app']['App']['SelectDatabaseFile'](arg1, arg2); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e9558a8..433b7bc 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -78,6 +78,8 @@ export namespace ai { type: string; name: string; apiKey: string; + secretRef?: string; + hasSecret?: boolean; baseUrl: string; model: string; models?: string[]; @@ -96,6 +98,8 @@ export namespace ai { this.type = source["type"]; this.name = source["name"]; this.apiKey = source["apiKey"]; + this.secretRef = source["secretRef"]; + this.hasSecret = source["hasSecret"]; this.baseUrl = source["baseUrl"]; this.model = source["model"]; this.models = source["models"]; @@ -284,6 +288,7 @@ export namespace connection { } } export class ConnectionConfig { + id?: string; type: string; host: string; port: number; @@ -324,6 +329,7 @@ export namespace connection { constructor(source: any = {}) { if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; this.type = source["type"]; this.host = source["host"]; this.port = source["port"]; @@ -377,6 +383,32 @@ export namespace connection { return a; } } + export class GlobalProxyView { + enabled: boolean; + type: string; + host: string; + port: number; + user?: string; + password?: string; + hasPassword?: boolean; + secretRef?: string; + + static createFrom(source: any = {}) { + return new GlobalProxyView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.type = source["type"]; + this.host = source["host"]; + this.port = source["port"]; + this.user = source["user"]; + this.password = source["password"]; + this.hasPassword = source["hasPassword"]; + this.secretRef = source["secretRef"]; + } + } export class QueryResult { @@ -400,6 +432,146 @@ export namespace connection { } } + export class SaveGlobalProxyInput { + enabled: boolean; + type: string; + host: string; + port: number; + user?: string; + password?: string; + + static createFrom(source: any = {}) { + return new SaveGlobalProxyInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.type = source["type"]; + this.host = source["host"]; + this.port = source["port"]; + this.user = source["user"]; + this.password = source["password"]; + } + } + export class SavedConnectionInput { + id?: string; + name: string; + config: ConnectionConfig; + includeDatabases?: string[]; + includeRedisDatabases?: number[]; + iconType?: string; + iconColor?: string; + clearPrimaryPassword?: boolean; + clearSSHPassword?: boolean; + clearProxyPassword?: boolean; + clearHttpTunnelPassword?: boolean; + clearMySQLReplicaPassword?: boolean; + clearMongoReplicaPassword?: boolean; + clearOpaqueURI?: boolean; + clearOpaqueDSN?: boolean; + + static createFrom(source: any = {}) { + return new SavedConnectionInput(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.config = this.convertValues(source["config"], ConnectionConfig); + this.includeDatabases = source["includeDatabases"]; + this.includeRedisDatabases = source["includeRedisDatabases"]; + this.iconType = source["iconType"]; + this.iconColor = source["iconColor"]; + this.clearPrimaryPassword = source["clearPrimaryPassword"]; + this.clearSSHPassword = source["clearSSHPassword"]; + this.clearProxyPassword = source["clearProxyPassword"]; + this.clearHttpTunnelPassword = source["clearHttpTunnelPassword"]; + this.clearMySQLReplicaPassword = source["clearMySQLReplicaPassword"]; + this.clearMongoReplicaPassword = source["clearMongoReplicaPassword"]; + this.clearOpaqueURI = source["clearOpaqueURI"]; + this.clearOpaqueDSN = source["clearOpaqueDSN"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class SavedConnectionView { + id: string; + name: string; + config: ConnectionConfig; + includeDatabases?: string[]; + includeRedisDatabases?: number[]; + iconType?: string; + iconColor?: string; + secretRef?: string; + hasPrimaryPassword?: boolean; + hasSSHPassword?: boolean; + hasProxyPassword?: boolean; + hasHttpTunnelPassword?: boolean; + hasMySQLReplicaPassword?: boolean; + hasMongoReplicaPassword?: boolean; + hasOpaqueURI?: boolean; + hasOpaqueDSN?: boolean; + + static createFrom(source: any = {}) { + return new SavedConnectionView(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.id = source["id"]; + this.name = source["name"]; + this.config = this.convertValues(source["config"], ConnectionConfig); + this.includeDatabases = source["includeDatabases"]; + this.includeRedisDatabases = source["includeRedisDatabases"]; + this.iconType = source["iconType"]; + this.iconColor = source["iconColor"]; + this.secretRef = source["secretRef"]; + this.hasPrimaryPassword = source["hasPrimaryPassword"]; + this.hasSSHPassword = source["hasSSHPassword"]; + this.hasProxyPassword = source["hasProxyPassword"]; + this.hasHttpTunnelPassword = source["hasHttpTunnelPassword"]; + this.hasMySQLReplicaPassword = source["hasMySQLReplicaPassword"]; + this.hasMongoReplicaPassword = source["hasMongoReplicaPassword"]; + this.hasOpaqueURI = source["hasOpaqueURI"]; + this.hasOpaqueDSN = source["hasOpaqueDSN"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } } diff --git a/internal/app/methods_saved_connections_test.go b/internal/app/methods_saved_connections_test.go index 4b117cc..d17785f 100644 --- a/internal/app/methods_saved_connections_test.go +++ b/internal/app/methods_saved_connections_test.go @@ -1,6 +1,7 @@ package app import ( + "reflect" "testing" "GoNavi-Wails/internal/connection" @@ -11,8 +12,11 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) { app.configDir = t.TempDir() result, err := app.SaveConnection(connection.SavedConnectionInput{ - ID: "conn-1", - Name: "Primary", + ID: "conn-1", + Name: "Primary", + IncludeDatabases: []string{"appdb"}, + IconType: "postgres", + IconColor: "#1677ff", Config: connection.ConnectionConfig{ ID: "conn-1", Type: "postgres", @@ -31,6 +35,79 @@ func TestSaveConnectionMethodReturnsSecretlessView(t *testing.T) { if !result.HasPrimaryPassword { t.Fatal("expected HasPrimaryPassword=true") } + if !reflect.DeepEqual(result.IncludeDatabases, []string{"appdb"}) { + t.Fatalf("expected include databases to be preserved, got %#v", result.IncludeDatabases) + } + if result.IconType != "postgres" || result.IconColor != "#1677ff" { + t.Fatalf("expected icon metadata to be preserved, got type=%q color=%q", result.IconType, result.IconColor) + } +} + +func TestSaveConnectionClearsRequestedSecretFields(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-1", + Name: "Primary", + Config: connection.ConnectionConfig{ + ID: "conn-1", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Password: "postgres-secret", + UseSSH: true, + SSH: connection.SSHConfig{ + Host: "jump.local", + Port: 22, + User: "ops", + Password: "ssh-secret", + }, + }, + }) + if err != nil { + t.Fatal(err) + } + + view, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-1", + Name: "Primary", + Config: connection.ConnectionConfig{ + ID: "conn-1", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + UseSSH: true, + SSH: connection.SSHConfig{ + Host: "jump.local", + Port: 22, + User: "ops", + }, + }, + ClearPrimaryPassword: true, + }) + if err != nil { + t.Fatal(err) + } + if view.HasPrimaryPassword { + t.Fatal("expected HasPrimaryPassword=false after clearing") + } + if !view.HasSSHPassword { + t.Fatal("expected SSH password to stay stored") + } + + resolved, err := app.resolveConnectionSecrets(view.Config) + if err != nil { + t.Fatal(err) + } + if resolved.Password != "" { + t.Fatalf("expected cleared primary password, got %q", resolved.Password) + } + if resolved.SSH.Password != "ssh-secret" { + t.Fatalf("expected SSH password to stay stored, got %q", resolved.SSH.Password) + } } func TestDuplicateConnectionClonesSecretBundle(t *testing.T) { @@ -38,8 +115,12 @@ func TestDuplicateConnectionClonesSecretBundle(t *testing.T) { app.configDir = t.TempDir() _, err := app.SaveConnection(connection.SavedConnectionInput{ - ID: "conn-1", - Name: "Primary", + ID: "conn-1", + Name: "Primary", + IncludeDatabases: []string{"appdb"}, + IncludeRedisDatabases: []int{0, 1}, + IconType: "postgres", + IconColor: "#1677ff", Config: connection.ConnectionConfig{ ID: "conn-1", Type: "postgres", @@ -60,6 +141,18 @@ func TestDuplicateConnectionClonesSecretBundle(t *testing.T) { if duplicate.ID == "conn-1" { t.Fatal("duplicate should have a new id") } + if duplicate.Name != "Primary - 副本" { + t.Fatalf("expected duplicate name to keep existing UX, got %q", duplicate.Name) + } + if !reflect.DeepEqual(duplicate.IncludeDatabases, []string{"appdb"}) { + t.Fatalf("expected include databases to be cloned, got %#v", duplicate.IncludeDatabases) + } + if !reflect.DeepEqual(duplicate.IncludeRedisDatabases, []int{0, 1}) { + t.Fatalf("expected redis include databases to be cloned, got %#v", duplicate.IncludeRedisDatabases) + } + if duplicate.IconType != "postgres" || duplicate.IconColor != "#1677ff" { + t.Fatalf("expected icon metadata to be cloned, got type=%q color=%q", duplicate.IconType, duplicate.IconColor) + } resolved, err := app.resolveConnectionSecrets(duplicate.Config) if err != nil { diff --git a/internal/app/saved_connections.go b/internal/app/saved_connections.go index 19eb76d..4a5dbb7 100644 --- a/internal/app/saved_connections.go +++ b/internal/app/saved_connections.go @@ -95,6 +95,53 @@ func mergeConnectionSecretBundles(base, overlay connectionSecretBundle) connecti return merged } +func applyConnectionSecretClears(bundle connectionSecretBundle, input connection.SavedConnectionInput) connectionSecretBundle { + cleared := bundle + if input.ClearPrimaryPassword { + cleared.Password = "" + } + if input.ClearSSHPassword { + cleared.SSHPassword = "" + } + if input.ClearProxyPassword { + cleared.ProxyPassword = "" + } + if input.ClearHTTPTunnelPassword { + cleared.HTTPTunnelPassword = "" + } + if input.ClearMySQLReplicaPassword { + cleared.MySQLReplicaPassword = "" + } + if input.ClearMongoReplicaPassword { + cleared.MongoReplicaPassword = "" + } + if input.ClearOpaqueURI { + cleared.OpaqueURI = "" + } + if input.ClearOpaqueDSN { + cleared.OpaqueDSN = "" + } + return cleared +} + +func cloneStringSlice(input []string) []string { + if len(input) == 0 { + return nil + } + cloned := make([]string, len(input)) + copy(cloned, input) + return cloned +} + +func cloneIntSlice(input []int) []int { + if len(input) == 0 { + return nil + } + cloned := make([]int, len(input)) + copy(cloned, input) + return cloned +} + func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.SavedConnectionView, connectionSecretBundle) { id := strings.TrimSpace(input.ID) if id == "" { @@ -143,6 +190,10 @@ func splitConnectionSecrets(input connection.SavedConnectionInput) (connection.S ID: id, Name: strings.TrimSpace(input.Name), Config: meta, + IncludeDatabases: cloneStringSlice(input.IncludeDatabases), + IncludeRedisDatabases: cloneIntSlice(input.IncludeRedisDatabases), + IconType: strings.TrimSpace(input.IconType), + IconColor: strings.TrimSpace(input.IconColor), HasPrimaryPassword: strings.TrimSpace(bundle.Password) != "", HasSSHPassword: strings.TrimSpace(bundle.SSHPassword) != "", HasProxyPassword: strings.TrimSpace(bundle.ProxyPassword) != "", @@ -223,6 +274,7 @@ func (r *savedConnectionRepository) Save(input connection.SavedConnectionInput) mergedBundle = mergeConnectionSecretBundles(existingBundle, bundle) view.SecretRef = existing.SecretRef } + mergedBundle = applyConnectionSecretClears(mergedBundle, input) if mergedBundle.hasAny() { ref, storeErr := r.storeSecretBundle(view.ID, view.SecretRef, mergedBundle) @@ -332,6 +384,27 @@ func applyConnectionBundleFlags(view *connection.SavedConnectionView, bundle con view.HasOpaqueDSN = strings.TrimSpace(bundle.OpaqueDSN) != "" } +func buildDuplicateConnectionName(baseName string, existing []connection.SavedConnectionView) string { + trimmedBaseName := strings.TrimSpace(baseName) + if trimmedBaseName == "" { + trimmedBaseName = "连接" + } + suffix := " - 副本" + usedNames := make(map[string]struct{}, len(existing)) + for _, item := range existing { + usedNames[strings.TrimSpace(item.Name)] = struct{}{} + } + candidate := trimmedBaseName + suffix + counter := 2 + for { + if _, exists := usedNames[candidate]; !exists { + return candidate + } + candidate = fmt.Sprintf("%s%s %d", trimmedBaseName, suffix, counter) + counter++ + } +} + func (r *savedConnectionRepository) List() ([]connection.SavedConnectionView, error) { return r.load() } @@ -357,15 +430,27 @@ func (r *savedConnectionRepository) Delete(id string) error { } func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnectionView, error) { - original, err := r.Find(id) + connections, err := r.load() if err != nil { return connection.SavedConnectionView{}, err } + index := -1 + for i, item := range connections { + if item.ID == strings.TrimSpace(id) { + index = i + break + } + } + if index < 0 { + return connection.SavedConnectionView{}, fmt.Errorf("saved connection not found: %s", id) + } + + original := connections[index] duplicate := original duplicate.ID = "conn-" + uuid.New().String()[:8] duplicate.Config.ID = duplicate.ID - duplicate.Name = original.Name + " Copy" + duplicate.Name = buildDuplicateConnectionName(original.Name, connections) bundle, err := r.loadSecretBundle(original) if err != nil { @@ -383,10 +468,6 @@ func (r *savedConnectionRepository) Duplicate(id string) (connection.SavedConnec applyConnectionBundleFlags(&duplicate, connectionSecretBundle{}) } - connections, err := r.load() - if err != nil { - return connection.SavedConnectionView{}, err - } connections = append(connections, duplicate) if err := r.saveAll(connections); err != nil { return connection.SavedConnectionView{}, err diff --git a/internal/connection/saved_types.go b/internal/connection/saved_types.go index a364a50..c99bf1a 100644 --- a/internal/connection/saved_types.go +++ b/internal/connection/saved_types.go @@ -1,15 +1,31 @@ package connection type SavedConnectionInput struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Config ConnectionConfig `json:"config"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + Config ConnectionConfig `json:"config"` + IncludeDatabases []string `json:"includeDatabases,omitempty"` + IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + IconType string `json:"iconType,omitempty"` + IconColor string `json:"iconColor,omitempty"` + ClearPrimaryPassword bool `json:"clearPrimaryPassword,omitempty"` + ClearSSHPassword bool `json:"clearSSHPassword,omitempty"` + ClearProxyPassword bool `json:"clearProxyPassword,omitempty"` + ClearHTTPTunnelPassword bool `json:"clearHttpTunnelPassword,omitempty"` + ClearMySQLReplicaPassword bool `json:"clearMySQLReplicaPassword,omitempty"` + ClearMongoReplicaPassword bool `json:"clearMongoReplicaPassword,omitempty"` + ClearOpaqueURI bool `json:"clearOpaqueURI,omitempty"` + ClearOpaqueDSN bool `json:"clearOpaqueDSN,omitempty"` } type SavedConnectionView struct { ID string `json:"id"` Name string `json:"name"` Config ConnectionConfig `json:"config"` + IncludeDatabases []string `json:"includeDatabases,omitempty"` + IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + IconType string `json:"iconType,omitempty"` + IconColor string `json:"iconColor,omitempty"` SecretRef string `json:"secretRef,omitempty"` HasPrimaryPassword bool `json:"hasPrimaryPassword,omitempty"` HasSSHPassword bool `json:"hasSSHPassword,omitempty"`