mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-25 07:48:37 +08:00
✨ feat(connection): 支持生产连接多项保护策略
- 新增数据编辑、结构编辑、脚本执行和数据导入四类连接级保护配置 - 升级生产连接保护弹窗为多选卡片,并修复选项对齐与勾选态显示 - 按保护类型收口 QueryEditor、DataGrid、表设计、导入与同步目标入口 - 后端统一拦截 SQL 或 Mongo 写操作、结果编辑、结构变更和导入写入 - AI 本地工具与 RPC 执行链路透传连接保护配置并复用后端守卫 - 补充多语言文案、定向测试与需求追踪记录
This commit is contained in:
@@ -459,6 +459,69 @@ describe("ConnectionModal i18n", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
language: "zh-CN" as const,
|
||||
sourceLabel: "MySQL",
|
||||
expectations: [
|
||||
"生产连接保护",
|
||||
"按需勾选限制项",
|
||||
"限制数据编辑",
|
||||
"限制结构编辑",
|
||||
"限制脚本执行",
|
||||
"限制数据导入",
|
||||
"当前策略",
|
||||
],
|
||||
},
|
||||
{
|
||||
language: "en-US" as const,
|
||||
sourceLabel: "MySQL",
|
||||
expectations: [
|
||||
"Production guard",
|
||||
"Select only the restrictions you need",
|
||||
"Restrict data edits",
|
||||
"Restrict structure edits",
|
||||
"Restrict script execution",
|
||||
"Restrict data import",
|
||||
"Current policy",
|
||||
],
|
||||
},
|
||||
])(
|
||||
"renders a detailed production-guard card in $language",
|
||||
async ({ language, sourceLabel, expectations }) => {
|
||||
setCurrentLanguage(language);
|
||||
storeState.languagePreference = language;
|
||||
mockFormValues = {
|
||||
type: "mysql",
|
||||
restrictDataEdit: true,
|
||||
restrictStructureEdit: true,
|
||||
restrictScriptExecution: false,
|
||||
restrictDataImport: false,
|
||||
useSSL: true,
|
||||
sslMode: "preferred",
|
||||
timeout: 30,
|
||||
};
|
||||
|
||||
const { default: ConnectionModal } = await import("./ConnectionModal");
|
||||
|
||||
let renderer: ReactTestRenderer;
|
||||
await act(async () => {
|
||||
renderer = create(<ConnectionModal open onClose={vi.fn()} />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
findClickableCard(renderer!, sourceLabel).props.onClick();
|
||||
});
|
||||
|
||||
const pageText = textContent(renderer!.toJSON());
|
||||
expectations.forEach((expected) => {
|
||||
expect(pageText).toContain(expected);
|
||||
});
|
||||
expect(pageText).not.toContain("connection.modal.section.undefined.title");
|
||||
expect(pageText).not.toContain("connection.modal.section.undefined.description");
|
||||
},
|
||||
);
|
||||
|
||||
it("renders English topology and authentication copy for legacy mysql, mongodb, and redis sections", async () => {
|
||||
storeState.appearance.uiVersion = "legacy";
|
||||
setCurrentLanguage("en-US");
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { getCustomConnectionDsnValidationMessage } from "../utils/customConnectionDsn";
|
||||
import { mergeParsedUriValuesForForm } from "../utils/connectionUriMerge";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import { resolveConnectionProtectionConfig } from "../utils/connectionReadOnly";
|
||||
import { getCustomConnectionDriverHelp } from "../utils/driverImportGuidance";
|
||||
import { isBackendCancelledResult } from "../utils/connectionExport";
|
||||
import {
|
||||
@@ -1379,6 +1380,7 @@ const ConnectionModal: React.FC<{
|
||||
: Number(config.timeout || 30);
|
||||
const hasHttpTunnel = !!config.useHttpTunnel;
|
||||
const hasProxy = !hasHttpTunnel && !!config.useProxy;
|
||||
const protection = resolveConnectionProtectionConfig(config);
|
||||
form.setFieldsValue({
|
||||
type: configType,
|
||||
name: initialValues.name,
|
||||
@@ -1387,7 +1389,11 @@ const ConnectionModal: React.FC<{
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
readOnly: config.readOnly === true,
|
||||
restrictDataEdit: protection.restrictDataEdit === true,
|
||||
restrictStructureEdit: protection.restrictStructureEdit === true,
|
||||
restrictScriptExecution:
|
||||
protection.restrictScriptExecution === true,
|
||||
restrictDataImport: protection.restrictDataImport === true,
|
||||
uri: config.uri || "",
|
||||
connectionParams:
|
||||
config.connectionParams ||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { v4 as generateUuid } from 'uuid';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
|
||||
import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance';
|
||||
import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly';
|
||||
import { getDataSourceCapabilities, resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol';
|
||||
@@ -533,13 +534,15 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
const prefersManualTotalCount = dataSourceCaps.preferManualTotalCount;
|
||||
const supportsApproximateTableCount = dataSourceCaps.supportsApproximateTableCount;
|
||||
const supportsApproximateTotalPages = dataSourceCaps.supportsApproximateTotalPages;
|
||||
const designerReadOnly = dataSourceCaps.forceReadOnlyStructureDesigner;
|
||||
const importRestricted = isConnectionDataImportRestricted(currentConnConfig);
|
||||
const dbType = dataSourceCaps.type;
|
||||
const isMongoDBConnection = dbType === 'mongodb';
|
||||
const isDuckDBConnection = dataSourceCaps.type === 'duckdb';
|
||||
const supportsCopyInsert = dataSourceCaps.supportsCopyInsert;
|
||||
const supportsSqlQueryExport = dataSourceCaps.supportsSqlQueryExport;
|
||||
const isQueryResultExport = exportScope === 'queryResult';
|
||||
const canImport = exportScope === 'table' && !!tableName && !readOnly;
|
||||
const canImport = exportScope === 'table' && !!tableName && !importRestricted;
|
||||
const canExport = !!connectionId && (isQueryResultExport || !!tableName);
|
||||
const canViewDdl = exportScope === 'table' && !!connectionId && !!tableName;
|
||||
const canOpenObjectDesigner = exportScope === 'table' && objectType === 'table' && !!connectionId && !!tableName;
|
||||
@@ -4176,6 +4179,7 @@ const DataGrid: React.FC<DataGridProps> = ({
|
||||
copyRowsForPaste,
|
||||
copyToClipboard,
|
||||
currentConnConfig,
|
||||
designerReadOnly,
|
||||
currentTextRow,
|
||||
darkMode,
|
||||
dataContextValue,
|
||||
|
||||
@@ -100,6 +100,7 @@ const DataGridShell: React.FC<DataGridShellProps> = (props) => {
|
||||
copyRowsForPaste,
|
||||
copyToClipboard,
|
||||
currentConnConfig,
|
||||
designerReadOnly,
|
||||
currentTextRow,
|
||||
darkMode,
|
||||
dataContextValue,
|
||||
@@ -734,7 +735,7 @@ const renderDataTableView = () => (
|
||||
dbName,
|
||||
tableName,
|
||||
initialTab: 'columns',
|
||||
readOnly,
|
||||
readOnly: designerReadOnly,
|
||||
objectType: 'table',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -3116,6 +3116,48 @@ describe('QueryEditor external SQL save', () => {
|
||||
expect(tableSuggestion.detail).not.toContain('Table (analytics)');
|
||||
});
|
||||
|
||||
it('deduplicates Oracle-style database qualified table completion labels when schema matches the qualifier', async () => {
|
||||
storeState.languagePreference = 'zh-CN';
|
||||
setCurrentLanguage('zh-CN');
|
||||
storeState.connections[0].config.type = 'oracle';
|
||||
storeState.connections[0].config.database = 'ORCLPDB1';
|
||||
editorState.value = 'select * from sbdev.AA';
|
||||
autoFetchState.visible = true;
|
||||
backendApp.DBGetDatabases.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: [{ Database: 'ORCLPDB1' }, { Database: 'sbdev' }],
|
||||
});
|
||||
backendApp.DBGetTables.mockImplementation(async (_config: any, dbName: string) => {
|
||||
if (String(dbName || '').toLowerCase() === 'sbdev') {
|
||||
return { success: true, data: [{ Table: 'SBDEV.AAA3_NJ' }] };
|
||||
}
|
||||
return { success: true, data: [] };
|
||||
});
|
||||
backendApp.DBGetAllColumns.mockResolvedValue({ success: true, data: [] });
|
||||
|
||||
await act(async () => {
|
||||
create(<QueryEditor tab={createTab({ query: editorState.value, dbName: 'ORCLPDB1' })} />);
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const completionProvider = editorState.providers[0];
|
||||
expect(completionProvider).toBeTruthy();
|
||||
|
||||
const completionItems = await completionProvider.provideCompletionItems(
|
||||
editorState.editor.getModel(),
|
||||
{ lineNumber: 1, column: editorState.value.length + 1 },
|
||||
);
|
||||
const tableSuggestion = completionItems?.suggestions?.find((item: any) => item?.label === 'AAA3_NJ');
|
||||
|
||||
expect(tableSuggestion).toBeTruthy();
|
||||
expect(tableSuggestion.insertText).toBe('AAA3_NJ');
|
||||
expect(tableSuggestion.detail).toContain('表 (sbdev)');
|
||||
expect(completionItems?.suggestions?.some((item: any) => item?.label === 'sbdev.SBDEV.AAA3_NJ')).toBe(false);
|
||||
});
|
||||
|
||||
it('localizes schema-qualified table completion detail in zh-CN while preserving the raw database and schema names', async () => {
|
||||
storeState.languagePreference = 'zh-CN';
|
||||
setCurrentLanguage('zh-CN');
|
||||
|
||||
@@ -191,6 +191,7 @@ let sharedMaterializedViewsData: CompletionViewMeta[] = [];
|
||||
let sharedTriggersData: CompletionTriggerMeta[] = [];
|
||||
let sharedRoutinesData: CompletionRoutineMeta[] = [];
|
||||
let sharedColumnsCacheData: Record<string, any[]> = {};
|
||||
const QUERY_EDITOR_LAZY_VISIBLE_DB_COMPLETION_LIMIT = 10;
|
||||
const sharedLazyTablesCache: Record<string, CompletionTableMeta[] | undefined> = {};
|
||||
const sharedLazyTablesInFlight: Record<string, Promise<CompletionTableMeta[]> | undefined> = {};
|
||||
const createEmptySqlCompletionResult = () => ({ suggestions: [] as any[] });
|
||||
@@ -204,6 +205,7 @@ const clearRecord = (record: Record<string, unknown>) => {
|
||||
const resetSharedQueryEditorMetadata = () => {
|
||||
sharedTablesData = [];
|
||||
sharedAllColumnsData = [];
|
||||
sharedVisibleDbs = [];
|
||||
sharedViewsData = [];
|
||||
sharedMaterializedViewsData = [];
|
||||
sharedTriggersData = [];
|
||||
@@ -1912,6 +1914,26 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const stripQuotes = stripCompletionIdentifierQuotes;
|
||||
const normalizeQualifiedName = normalizeCompletionQualifiedName;
|
||||
const splitSchemaAndTable = splitCompletionSchemaAndTable;
|
||||
const buildDbQualifiedTableSuggestionMeta = (dbName: string, tableName: string) => {
|
||||
const rawDbName = String(dbName || '').trim();
|
||||
const rawTableName = String(tableName || '').trim();
|
||||
const parsed = splitSchemaAndTable(rawTableName);
|
||||
const schemaMatchesDb = !!parsed.schema
|
||||
&& !!parsed.table
|
||||
&& parsed.schema.toLowerCase() === rawDbName.toLowerCase();
|
||||
const displayName = schemaMatchesDb ? parsed.table : rawTableName;
|
||||
const insertText = schemaMatchesDb
|
||||
? quoteCompletionPart(parsed.table)
|
||||
: quoteCompletionPath(rawTableName);
|
||||
const dbQualifiedLabel = rawDbName
|
||||
? `${rawDbName}.${displayName || rawTableName}`
|
||||
: (displayName || rawTableName);
|
||||
return {
|
||||
displayName: displayName || rawTableName,
|
||||
insertText,
|
||||
dbQualifiedLabel,
|
||||
};
|
||||
};
|
||||
|
||||
const buildConnConfig = () => {
|
||||
const connId = sharedCurrentConnectionId;
|
||||
@@ -2101,18 +2123,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
}
|
||||
}
|
||||
const filtered = prefix
|
||||
? tables.filter(t => (t.tableName || '').toLowerCase().startsWith(prefix))
|
||||
? tables.filter(t => {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || '');
|
||||
return String(suggestionMeta.displayName || '').toLowerCase().startsWith(prefix)
|
||||
|| String(t.tableName || '').toLowerCase().startsWith(prefix);
|
||||
})
|
||||
: tables;
|
||||
|
||||
const suggestions = filtered.map(t => ({
|
||||
label: t.tableName,
|
||||
const suggestions = filtered.map(t => {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || qualifier, t.tableName || '');
|
||||
return {
|
||||
label: suggestionMeta.displayName,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
insertText: quoteCompletionPath(t.tableName),
|
||||
insertText: suggestionMeta.insertText,
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
range,
|
||||
sortText: '0' + t.tableName
|
||||
}));
|
||||
sortText: '0' + suggestionMeta.displayName
|
||||
};
|
||||
});
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
@@ -2201,7 +2230,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
if (normalized.some((candidate) => candidate.includes(wordPrefix))) return '1';
|
||||
return '9';
|
||||
};
|
||||
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix.trim());
|
||||
const expectsTableName = /\b(?:FROM|JOIN|UPDATE|INTO|DELETE\s+FROM|TABLE|DESCRIBE|DESC|EXPLAIN)\s+[`"]?[\w.]*$/i.test(linePrefix);
|
||||
const shouldBoostKeywords = !expectsTableName
|
||||
&& wordPrefix.length > 0
|
||||
&& dialectKeywords.some((keyword) => keyword.toLowerCase().startsWith(wordPrefix));
|
||||
@@ -2230,6 +2259,38 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
});
|
||||
}
|
||||
}
|
||||
if (expectsTableName && sharedVisibleDbs.length > 1) {
|
||||
const loadedDbKeys = new Set(
|
||||
completionTables
|
||||
.map((table) => String(table.dbName || '').toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const missingVisibleDbs = sharedVisibleDbs.filter((dbName) => {
|
||||
const normalizedDbName = String(dbName || '').trim();
|
||||
const dbKey = normalizedDbName.toLowerCase();
|
||||
return normalizedDbName
|
||||
&& dbKey !== currentDatabase.toLowerCase()
|
||||
&& !loadedDbKeys.has(dbKey);
|
||||
});
|
||||
if (
|
||||
missingVisibleDbs.length > 0
|
||||
&& missingVisibleDbs.length <= QUERY_EDITOR_LAZY_VISIBLE_DB_COMPLETION_LIMIT
|
||||
) {
|
||||
const lazyTableGroups = await Promise.all(
|
||||
missingVisibleDbs.map((dbName) => getLazyTablesByDB(dbName)),
|
||||
);
|
||||
if (isSqlCompletionRequestCancelled(token)) {
|
||||
return createEmptySqlCompletionResult();
|
||||
}
|
||||
const seenTableKeys = new Set<string>();
|
||||
completionTables = [...completionTables, ...lazyTableGroups.flat()].filter((table) => {
|
||||
const key = `${String(table.dbName || '').toLowerCase()}.${String(table.tableName || '').toLowerCase()}`;
|
||||
if (seenTableKeys.has(key)) return false;
|
||||
seenTableKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const referencedColumns: CompletionColumnMeta[] = [];
|
||||
if (!expectsTableName) {
|
||||
@@ -2296,9 +2357,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const isCurrentDb = (t.dbName || '').toLowerCase() === currentDatabase.toLowerCase();
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
// 跨库:用 db.table 格式匹配
|
||||
return includesWordPrefix(`${t.dbName}.${t.tableName}`)
|
||||
if (!isCurrentDb) {
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || '');
|
||||
const label = suggestionMeta.dbQualifiedLabel;
|
||||
// 跨库:用 db.table 格式匹配
|
||||
return includesWordPrefix(label)
|
||||
|| includesWordPrefix(t.tableName || '')
|
||||
|| includesWordPrefix(pureTable);
|
||||
}
|
||||
@@ -2310,7 +2373,8 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
const parsed = splitSchemaAndTable(t.tableName || '');
|
||||
const pureTable = parsed.table || t.tableName || '';
|
||||
if (!isCurrentDb) {
|
||||
const label = `${t.dbName}.${t.tableName}`;
|
||||
const suggestionMeta = buildDbQualifiedTableSuggestionMeta(t.dbName || '', t.tableName || '');
|
||||
const label = suggestionMeta.dbQualifiedLabel;
|
||||
return {
|
||||
label,
|
||||
kind: monaco.languages.CompletionItemKind.Class,
|
||||
@@ -2318,7 +2382,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
|
||||
detail: appendCommentToDetail(`${translate('query_editor.object_info.table')} (${t.dbName})`, t.comment),
|
||||
documentation: buildCompletionDocumentation(t.comment),
|
||||
range,
|
||||
sortText: sortGroups.tableOther + getPrefixMatchRank(`${t.dbName}.${t.tableName}`, t.tableName || '', pureTable) + t.tableName,
|
||||
sortText: sortGroups.tableOther + getPrefixMatchRank(label, t.tableName || '', pureTable) + label,
|
||||
};
|
||||
}
|
||||
// 当前库:检查是否有跨 schema 同名表
|
||||
|
||||
@@ -119,6 +119,7 @@ import { useAutoFetchVisibility } from '../utils/autoFetchVisibility';
|
||||
import FindInDatabaseModal from './FindInDatabaseModal';
|
||||
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
|
||||
import { resolveDataSourceType } from '../utils/dataSourceCapabilities';
|
||||
import { isConnectionStructureEditRestricted } from '../utils/connectionReadOnly';
|
||||
import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import {
|
||||
resolveSidebarRuntimeDatabase,
|
||||
@@ -1412,7 +1413,10 @@ const Sidebar: React.FC<{
|
||||
|
||||
const openDesign = (node: any, initialTab: string, readOnly: boolean = false) => {
|
||||
const { tableName, dbName, id } = node.dataRef;
|
||||
const forceReadOnly = readOnly || isStructureOnlyDbType(id);
|
||||
const conn = connections.find(c => c.id === id);
|
||||
const forceReadOnly = readOnly
|
||||
|| isStructureOnlyDbType(id)
|
||||
|| isConnectionStructureEditRestricted(conn?.config);
|
||||
addTab({
|
||||
id: `design-${id}-${dbName}-${tableName}`,
|
||||
title: forceReadOnly
|
||||
@@ -1429,7 +1433,8 @@ const Sidebar: React.FC<{
|
||||
|
||||
const openNewTableDesign = (node: any) => {
|
||||
const { dbName, id } = node.dataRef;
|
||||
if (isStructureOnlyDbType(id)) {
|
||||
const conn = connections.find(c => c.id === id);
|
||||
if (isStructureOnlyDbType(id) || isConnectionStructureEditRestricted(conn?.config)) {
|
||||
message.warning(t('sidebar.message.visual_new_table_unsupported'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { getShortcutPlatform } from '../utils/shortcuts';
|
||||
import { t } from '../i18n';
|
||||
import { buildTableExportTab } from '../utils/tableExportTab';
|
||||
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
|
||||
import { V2TableContextMenuView, type V2TableContextMenuActionKey } from './V2TableContextMenu';
|
||||
import { useExportProgressDialog } from './ExportProgressModal';
|
||||
|
||||
@@ -279,7 +280,7 @@ const TableOverview: React.FC<TableOverviewProps> = ({ tab }) => {
|
||||
[connection?.config?.driver, connection?.config?.oceanBaseProtocol, connection?.config?.type]
|
||||
);
|
||||
const schemaName = String((tab as any).schemaName || '').trim();
|
||||
const supportsDesignWrite = metadataDialect !== 'iotdb';
|
||||
const supportsDesignWrite = !getDataSourceCapabilities(connection?.config).forceReadOnlyStructureDesigner;
|
||||
const autoFetchVisible = useAutoFetchVisibility();
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
|
||||
@@ -37,7 +37,9 @@ import {
|
||||
import ConnectionModalMongoSections from "../ConnectionModalMongoSections";
|
||||
import ConnectionModalRedisSections from "../ConnectionModalRedisSections";
|
||||
import { t } from "../../i18n";
|
||||
import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly";
|
||||
import {
|
||||
supportsConnectionReadOnlyMode,
|
||||
} from "../../utils/connectionReadOnly";
|
||||
import {
|
||||
getConnectionConfigLayoutKindLabel,
|
||||
getStoredSecretPlaceholder,
|
||||
@@ -198,6 +200,19 @@ const renderStep2 = () => {
|
||||
driver: form.getFieldValue("driver"),
|
||||
oceanBaseProtocol,
|
||||
});
|
||||
const restrictDataEdit = Form.useWatch("restrictDataEdit", form) === true;
|
||||
const restrictStructureEdit =
|
||||
Form.useWatch("restrictStructureEdit", form) === true;
|
||||
const restrictScriptExecution =
|
||||
Form.useWatch("restrictScriptExecution", form) === true;
|
||||
const restrictDataImport =
|
||||
Form.useWatch("restrictDataImport", form) === true;
|
||||
const connectionProtectionEnabledCount = [
|
||||
restrictDataEdit,
|
||||
restrictStructureEdit,
|
||||
restrictScriptExecution,
|
||||
restrictDataImport,
|
||||
].filter(Boolean).length;
|
||||
const baseInfoSection = (
|
||||
<div style={modalInnerSectionStyle}>
|
||||
<div
|
||||
@@ -1349,20 +1364,252 @@ const renderStep2 = () => {
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "readOnly",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="readOnly"
|
||||
label={t("connection.modal.field.readOnly.label")}
|
||||
help={t("connection.modal.field.readOnly.help")}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
badge: (
|
||||
<Tag
|
||||
color={
|
||||
connectionProtectionEnabledCount > 0 ? "red" : "default"
|
||||
}
|
||||
>
|
||||
<Checkbox
|
||||
onChange={() => clearConnectionTestResultForChoice()}
|
||||
{connectionProtectionEnabledCount > 0
|
||||
? t(
|
||||
"connection.modal.field.readOnly.status.enabledCount",
|
||||
{
|
||||
count: connectionProtectionEnabledCount,
|
||||
},
|
||||
)
|
||||
: t("connection.modal.field.readOnly.status.disabled")}
|
||||
</Tag>
|
||||
),
|
||||
children: (
|
||||
<div style={{ display: "grid", gap: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 16,
|
||||
border: connectionProtectionEnabledCount > 0
|
||||
? darkMode
|
||||
? "1px solid rgba(255,120,117,0.34)"
|
||||
: "1px solid rgba(245,34,45,0.18)"
|
||||
: darkMode
|
||||
? "1px solid rgba(255,214,102,0.24)"
|
||||
: "1px solid rgba(250,173,20,0.18)",
|
||||
background: connectionProtectionEnabledCount > 0
|
||||
? darkMode
|
||||
? "linear-gradient(180deg, rgba(255,120,117,0.12) 0%, rgba(255,120,117,0.05) 100%)"
|
||||
: "linear-gradient(180deg, rgba(255,245,245,0.96) 0%, rgba(255,240,240,0.92) 100%)"
|
||||
: darkMode
|
||||
? "linear-gradient(180deg, rgba(255,214,102,0.10) 0%, rgba(255,214,102,0.04) 100%)"
|
||||
: "linear-gradient(180deg, rgba(255,251,230,0.98) 0%, rgba(255,247,214,0.94) 100%)",
|
||||
boxShadow: darkMode
|
||||
? "inset 0 1px 0 rgba(255,255,255,0.04)"
|
||||
: "inset 0 1px 0 rgba(255,255,255,0.92)",
|
||||
}}
|
||||
>
|
||||
{t("connection.modal.field.readOnly.checkbox")}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) auto",
|
||||
gap: 16,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
}}
|
||||
>
|
||||
{t("connection.modal.field.readOnly.label")}
|
||||
</div>
|
||||
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
|
||||
{t("connection.modal.field.readOnly.help")}
|
||||
</div>
|
||||
</div>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: darkMode ? "#ffd591" : "#ad4e00",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t("connection.modal.field.readOnly.compatibility")}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
field: "restrictDataEdit",
|
||||
checked: restrictDataEdit,
|
||||
label: t(
|
||||
"connection.modal.field.readOnly.option.dataEdit.label",
|
||||
),
|
||||
help: t(
|
||||
"connection.modal.field.readOnly.option.dataEdit.help",
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "restrictStructureEdit",
|
||||
checked: restrictStructureEdit,
|
||||
label: t(
|
||||
"connection.modal.field.readOnly.option.structureEdit.label",
|
||||
),
|
||||
help: t(
|
||||
"connection.modal.field.readOnly.option.structureEdit.help",
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "restrictScriptExecution",
|
||||
checked: restrictScriptExecution,
|
||||
label: t(
|
||||
"connection.modal.field.readOnly.option.scriptExecution.label",
|
||||
),
|
||||
help: t(
|
||||
"connection.modal.field.readOnly.option.scriptExecution.help",
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "restrictDataImport",
|
||||
checked: restrictDataImport,
|
||||
label: t(
|
||||
"connection.modal.field.readOnly.option.dataImport.label",
|
||||
),
|
||||
help: t(
|
||||
"connection.modal.field.readOnly.option.dataImport.help",
|
||||
),
|
||||
},
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.field}
|
||||
onClick={() =>
|
||||
setChoiceFieldValue(item.field, !item.checked)
|
||||
}
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
border: item.checked
|
||||
? darkMode
|
||||
? "1px solid rgba(255,120,117,0.22)"
|
||||
: "1px solid rgba(245,34,45,0.14)"
|
||||
: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(5,5,5,0.08)",
|
||||
background: item.checked
|
||||
? darkMode
|
||||
? "rgba(255,120,117,0.08)"
|
||||
: "rgba(255,241,240,0.92)"
|
||||
: darkMode
|
||||
? "rgba(255,255,255,0.02)"
|
||||
: "rgba(255,255,255,0.9)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "auto minmax(0, 1fr)",
|
||||
gap: 12,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
paddingTop: 2,
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Form.Item
|
||||
name={item.field}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
onChange={() =>
|
||||
clearConnectionTestResultForChoice()
|
||||
}
|
||||
style={{
|
||||
marginInlineStart: 0,
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...modalMutedTextStyle,
|
||||
marginTop: 6,
|
||||
whiteSpace: "normal",
|
||||
}}
|
||||
>
|
||||
{item.help}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 14,
|
||||
border: darkMode
|
||||
? "1px solid rgba(82,196,26,0.22)"
|
||||
: "1px solid rgba(82,196,26,0.18)",
|
||||
background: darkMode
|
||||
? "rgba(82,196,26,0.08)"
|
||||
: "rgba(246,255,237,0.92)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
}}
|
||||
>
|
||||
{t("connection.modal.field.readOnly.summary.title")}
|
||||
</div>
|
||||
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
|
||||
{connectionProtectionEnabledCount > 0
|
||||
? t(
|
||||
"connection.modal.field.readOnly.summary.selected",
|
||||
{ count: connectionProtectionEnabledCount },
|
||||
)
|
||||
: t(
|
||||
"connection.modal.field.readOnly.summary.empty",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...modalMutedTextStyle,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.7,
|
||||
padding: "0 4px",
|
||||
}}
|
||||
>
|
||||
{t("connection.modal.field.readOnly.tip")}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
|
||||
@@ -2333,7 +2580,10 @@ const renderStep2 = () => {
|
||||
keepAliveIntervalMinutes: 240,
|
||||
uri: "",
|
||||
connectionParams: "",
|
||||
readOnly: false,
|
||||
restrictDataEdit: false,
|
||||
restrictStructureEdit: false,
|
||||
restrictScriptExecution: false,
|
||||
restrictDataImport: false,
|
||||
oceanBaseProtocol: "mysql",
|
||||
mysqlTopology: "single",
|
||||
rocketmqTopology: "single",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { ConnectionConfig, SavedConnection } from "../../types";
|
||||
import { supportsConnectionReadOnlyMode } from "../../utils/connectionReadOnly";
|
||||
import {
|
||||
deriveLegacyConnectionReadOnlyFlag,
|
||||
normalizeConnectionProtectionConfig,
|
||||
supportsConnectionReadOnlyMode,
|
||||
} from "../../utils/connectionReadOnly";
|
||||
import { resolveConnectionSecretDraft } from "../../utils/connectionSecretDraft";
|
||||
import {
|
||||
getConnectionTypeDefaultPort as getDefaultPortByType,
|
||||
@@ -797,6 +801,19 @@ export const buildConnectionConfig = async ({
|
||||
)
|
||||
: normalizeConnectionParamsText(mergedValues.connectionParams)
|
||||
: "";
|
||||
const supportsProductionGuard = supportsConnectionReadOnlyMode({
|
||||
type,
|
||||
driver: mergedValues.driver,
|
||||
oceanBaseProtocol: selectedOceanBaseProtocol,
|
||||
});
|
||||
const protection = supportsProductionGuard
|
||||
? normalizeConnectionProtectionConfig({
|
||||
restrictDataEdit: mergedValues.restrictDataEdit === true,
|
||||
restrictStructureEdit: mergedValues.restrictStructureEdit === true,
|
||||
restrictScriptExecution: mergedValues.restrictScriptExecution === true,
|
||||
restrictDataImport: mergedValues.restrictDataImport === true,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
type: mergedValues.type,
|
||||
@@ -806,12 +823,10 @@ export const buildConnectionConfig = async ({
|
||||
password: keepPassword ? mergedValues.password || "" : "",
|
||||
savePassword: savePassword,
|
||||
database: mergedValues.database || "",
|
||||
readOnly:
|
||||
supportsConnectionReadOnlyMode({
|
||||
type,
|
||||
driver: mergedValues.driver,
|
||||
oceanBaseProtocol: selectedOceanBaseProtocol,
|
||||
}) && mergedValues.readOnly === true,
|
||||
readOnly: protection
|
||||
? deriveLegacyConnectionReadOnlyFlag(protection)
|
||||
: false,
|
||||
protection,
|
||||
useSSL: effectiveUseSSL,
|
||||
sslMode: effectiveUseSSL ? sslMode : "disable",
|
||||
sslCAPath: sslCAPath,
|
||||
|
||||
Reference in New Issue
Block a user