mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-05 13:27:55 +08:00
✨ feat(connection-package): 支持连接恢复包双模式加密导入导出
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路 - 扩展前后端导入导出流程并兼容 v1 与 legacy 格式 - 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
This commit is contained in:
@@ -26,6 +26,7 @@ import { getConnectionWorkbenchState } from './utils/startupReadiness';
|
||||
import { toSaveGlobalProxyInput } from './utils/globalProxyDraft';
|
||||
import {
|
||||
detectConnectionImportKind,
|
||||
isConnectionPackagePasswordRequiredError,
|
||||
resolveConnectionPackageExportResult,
|
||||
normalizeConnectionPackagePassword,
|
||||
} from './utils/connectionExport';
|
||||
@@ -120,6 +121,8 @@ type ConnectionPackageDialogMode = 'import' | 'export';
|
||||
type ConnectionPackageDialogState = {
|
||||
open: boolean;
|
||||
mode: ConnectionPackageDialogMode;
|
||||
includeSecrets: boolean;
|
||||
useFilePassword: boolean;
|
||||
password: string;
|
||||
error: string;
|
||||
confirmLoading: boolean;
|
||||
@@ -128,6 +131,8 @@ type ConnectionPackageDialogState = {
|
||||
const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogState => ({
|
||||
open: false,
|
||||
mode: 'export',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: '',
|
||||
error: '',
|
||||
confirmLoading: false,
|
||||
@@ -1476,22 +1481,24 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (importKind === 'encrypted-package') {
|
||||
setPendingConnectionImportPayload(raw);
|
||||
setConnectionPackageDialog({
|
||||
open: true,
|
||||
mode: 'import',
|
||||
password: '',
|
||||
error: '',
|
||||
confirmLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPendingConnectionImportPayload(null);
|
||||
const importedViews = await importConnectionsPayload(raw, '');
|
||||
void message.success(`成功导入 ${importedViews.length} 个连接`);
|
||||
} catch (e: any) {
|
||||
if (isConnectionPackagePasswordRequiredError(e)) {
|
||||
setPendingConnectionImportPayload(raw);
|
||||
setConnectionPackageDialog({
|
||||
open: true,
|
||||
mode: 'import',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: '',
|
||||
error: '',
|
||||
confirmLoading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
void message.error(e?.message || '导入失败');
|
||||
}
|
||||
};
|
||||
@@ -1505,6 +1512,8 @@ function App() {
|
||||
setConnectionPackageDialog({
|
||||
open: true,
|
||||
mode: 'export',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: '',
|
||||
error: '',
|
||||
confirmLoading: false,
|
||||
@@ -1515,7 +1524,7 @@ function App() {
|
||||
const backendApp = (window as any).go?.app?.App;
|
||||
const password = normalizeConnectionPackagePassword(connectionPackageDialog.password);
|
||||
|
||||
if (!password) {
|
||||
if (connectionPackageDialog.mode === 'import' && !password) {
|
||||
setConnectionPackageDialog((current) => ({
|
||||
...current,
|
||||
error: '恢复包密码不能为空',
|
||||
@@ -1523,9 +1532,25 @@ function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
connectionPackageDialog.mode === 'export'
|
||||
&& connectionPackageDialog.includeSecrets
|
||||
&& connectionPackageDialog.useFilePassword
|
||||
&& !password
|
||||
) {
|
||||
setConnectionPackageDialog((current) => ({
|
||||
...current,
|
||||
error: '文件保护密码不能为空',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectionPackageDialog((current) => ({
|
||||
...current,
|
||||
password,
|
||||
password: (
|
||||
current.mode === 'export'
|
||||
&& (!current.includeSecrets || !current.useFilePassword)
|
||||
) ? '' : password,
|
||||
error: '',
|
||||
confirmLoading: true,
|
||||
}));
|
||||
@@ -1536,7 +1561,13 @@ function App() {
|
||||
throw new Error('导出失败:当前后端未提供新版导出能力');
|
||||
}
|
||||
|
||||
const res = await backendApp.ExportConnectionsPackage(password);
|
||||
const res = await backendApp.ExportConnectionsPackage({
|
||||
includeSecrets: connectionPackageDialog.includeSecrets,
|
||||
filePassword: (
|
||||
connectionPackageDialog.includeSecrets
|
||||
&& connectionPackageDialog.useFilePassword
|
||||
) ? password : '',
|
||||
});
|
||||
const exportResult = resolveConnectionPackageExportResult(connectionPackageDialog, res);
|
||||
if (exportResult.kind === 'canceled') {
|
||||
setConnectionPackageDialog(exportResult.nextDialog);
|
||||
@@ -2559,11 +2590,31 @@ function App() {
|
||||
/>
|
||||
<ConnectionPackagePasswordModal
|
||||
open={connectionPackageDialog.open}
|
||||
title={connectionPackageDialog.mode === 'export' ? '输入导出密码' : '输入导入密码'}
|
||||
title={connectionPackageDialog.mode === 'export' ? '导出连接' : '输入导入密码'}
|
||||
mode={connectionPackageDialog.mode}
|
||||
includeSecrets={connectionPackageDialog.includeSecrets}
|
||||
useFilePassword={connectionPackageDialog.useFilePassword}
|
||||
password={connectionPackageDialog.password}
|
||||
error={connectionPackageDialog.error}
|
||||
confirmLoading={connectionPackageDialog.confirmLoading}
|
||||
confirmText={connectionPackageDialog.mode === 'export' ? '开始导出' : '开始导入'}
|
||||
onIncludeSecretsChange={(value) => {
|
||||
setConnectionPackageDialog((current) => ({
|
||||
...current,
|
||||
includeSecrets: value,
|
||||
useFilePassword: value ? current.useFilePassword : false,
|
||||
password: value ? current.password : '',
|
||||
error: '',
|
||||
}));
|
||||
}}
|
||||
onUseFilePasswordChange={(value) => {
|
||||
setConnectionPackageDialog((current) => ({
|
||||
...current,
|
||||
useFilePassword: value,
|
||||
password: value ? current.password : '',
|
||||
error: '',
|
||||
}));
|
||||
}}
|
||||
onPasswordChange={(value) => {
|
||||
setConnectionPackageDialog((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Input, Modal, Typography } from 'antd';
|
||||
import { Checkbox, Input, Modal, Typography } from 'antd';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type ConnectionPackagePasswordModalMode = 'import' | 'export';
|
||||
|
||||
export interface ConnectionPackagePasswordModalProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
mode?: ConnectionPackagePasswordModalMode;
|
||||
includeSecrets?: boolean;
|
||||
useFilePassword?: boolean;
|
||||
password: string;
|
||||
error?: string;
|
||||
confirmLoading?: boolean;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onIncludeSecretsChange?: (value: boolean) => void;
|
||||
onUseFilePasswordChange?: (value: boolean) => void;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -19,15 +26,29 @@ export interface ConnectionPackagePasswordModalProps {
|
||||
export default function ConnectionPackagePasswordModal({
|
||||
open,
|
||||
title,
|
||||
mode = 'import',
|
||||
includeSecrets = true,
|
||||
useFilePassword = false,
|
||||
password,
|
||||
error,
|
||||
confirmLoading,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消',
|
||||
onIncludeSecretsChange,
|
||||
onUseFilePasswordChange,
|
||||
onPasswordChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectionPackagePasswordModalProps) {
|
||||
const isExportMode = mode === 'export';
|
||||
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||
const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码';
|
||||
const helperText = !includeSecrets
|
||||
? '将仅导出连接配置,不包含密码。'
|
||||
: (useFilePassword
|
||||
? '请通过单独渠道将密码告知接收方,不要和文件一起发送。'
|
||||
: '密码已加密保护。如需通过公网传输,建议设置文件保护密码。');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
@@ -40,12 +61,37 @@ export default function ConnectionPackagePasswordModal({
|
||||
destroyOnClose={false}
|
||||
maskClosable={false}
|
||||
>
|
||||
<Input.Password
|
||||
autoFocus
|
||||
value={password}
|
||||
placeholder="请输入恢复包密码"
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
/>
|
||||
{isExportMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox
|
||||
checked={includeSecrets}
|
||||
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||
>
|
||||
导出连接密码
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={useFilePassword}
|
||||
disabled={!includeSecrets}
|
||||
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||
>
|
||||
设置文件保护密码
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : null}
|
||||
{showFilePasswordInput ? (
|
||||
<Input.Password
|
||||
autoFocus
|
||||
value={password}
|
||||
placeholder={placeholder}
|
||||
disabled={isExportMode && !useFilePassword}
|
||||
onChange={(event) => onPasswordChange(event.target.value)}
|
||||
/>
|
||||
) : null}
|
||||
{isExportMode ? (
|
||||
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
|
||||
{helperText}
|
||||
</Text>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
|
||||
{error}
|
||||
|
||||
@@ -51,8 +51,8 @@ const importMain = async () => {
|
||||
app?: {
|
||||
App?: {
|
||||
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
|
||||
ImportConnectionsPayload: (raw: string) => Promise<unknown>;
|
||||
ExportConnectionsPackage: () => Promise<{ success: boolean; message?: string }>;
|
||||
ImportConnectionsPayload: (raw: string, password?: string) => Promise<unknown>;
|
||||
ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -83,7 +83,7 @@ describe('main browser mock', () => {
|
||||
success: false,
|
||||
message: '已取消',
|
||||
});
|
||||
await expect(app!.ExportConnectionsPackage()).resolves.toEqual({
|
||||
await expect(app!.ExportConnectionsPackage({ includeSecrets: true, filePassword: '' })).resolves.toEqual({
|
||||
success: false,
|
||||
message: '浏览器 mock 不支持恢复包导出',
|
||||
});
|
||||
|
||||
@@ -123,7 +123,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
OpenDownloadedUpdateDirectory: async () => ({ success: false }),
|
||||
InstallUpdateAndRestart: async () => ({ success: false }),
|
||||
ImportConfigFile: async () => ({ success: false, message: '已取消' }),
|
||||
ImportConnectionsPayload: async (raw: string) => {
|
||||
ImportConnectionsPayload: async (raw: string, _password?: string) => {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -134,7 +134,7 @@ if (typeof window !== 'undefined' && !(window as any).go) {
|
||||
}
|
||||
throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组');
|
||||
},
|
||||
ExportConnectionsPackage: async () => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
|
||||
ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }),
|
||||
ExportData: async () => ({ success: false }),
|
||||
GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }),
|
||||
SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input),
|
||||
|
||||
@@ -2,13 +2,64 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
detectConnectionImportKind,
|
||||
isConnectionPackagePasswordRequiredError,
|
||||
isConnectionPackageExportCanceled,
|
||||
resolveConnectionPackageExportResult,
|
||||
normalizeConnectionPackagePassword,
|
||||
} from './connectionExport';
|
||||
|
||||
describe('connectionExport', () => {
|
||||
it('detects encrypted packages by gonavi envelope kind', () => {
|
||||
it('detects v2 app-managed packages', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 1,
|
||||
exportedAt: '2026-04-11T21:00:00Z',
|
||||
connections: [],
|
||||
}))).toBe('app-managed-package');
|
||||
});
|
||||
|
||||
it('detects v2 encrypted packages', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 2,
|
||||
kdf: {
|
||||
n: 'a2id',
|
||||
m: 65536,
|
||||
t: 3,
|
||||
l: 4,
|
||||
s: 'c2FsdA==',
|
||||
},
|
||||
nc: 'bm9uY2Utbm9uY2U=',
|
||||
d: 'encrypted-data',
|
||||
}))).toBe('encrypted-package');
|
||||
});
|
||||
|
||||
it('rejects malformed v2 app-managed packages without connections array', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 1,
|
||||
exportedAt: '2026-04-11T21:00:00Z',
|
||||
}))).toBe('invalid');
|
||||
});
|
||||
|
||||
it('rejects malformed v2 encrypted packages without protected payload fields', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 2,
|
||||
kdf: {
|
||||
n: 'a2id',
|
||||
m: 65536,
|
||||
t: 3,
|
||||
l: 4,
|
||||
},
|
||||
}))).toBe('invalid');
|
||||
});
|
||||
|
||||
it('detects v1 encrypted packages by gonavi envelope kind', () => {
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
kind: 'gonavi_connection_package',
|
||||
@@ -39,6 +90,15 @@ describe('connectionExport', () => {
|
||||
|
||||
it('returns invalid for malformed or unsupported content', () => {
|
||||
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
p: 0,
|
||||
}))).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
v: 2,
|
||||
kind: 'gonavi_connection_package',
|
||||
}))).toBe('invalid');
|
||||
expect(detectConnectionImportKind(JSON.stringify({
|
||||
kind: 'gonavi_connection_package',
|
||||
payload: 'encrypted-data',
|
||||
@@ -60,6 +120,14 @@ describe('connectionExport', () => {
|
||||
expect(normalizeConnectionPackagePassword('\n\t \t')).toBe('');
|
||||
});
|
||||
|
||||
it('recognizes backend password-required errors for protected packages', () => {
|
||||
expect(isConnectionPackagePasswordRequiredError(new Error('恢复包密码不能为空'))).toBe(true);
|
||||
expect(isConnectionPackagePasswordRequiredError({ message: '恢复包密码不能为空' })).toBe(true);
|
||||
expect(isConnectionPackagePasswordRequiredError('恢复包密码不能为空')).toBe(true);
|
||||
expect(isConnectionPackagePasswordRequiredError(new Error('文件密码错误或文件已损坏'))).toBe(false);
|
||||
expect(isConnectionPackagePasswordRequiredError(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats export cancel as a non-error backend result', () => {
|
||||
expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true);
|
||||
expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false);
|
||||
@@ -71,6 +139,8 @@ describe('connectionExport', () => {
|
||||
const staleDialog = {
|
||||
open: true,
|
||||
mode: 'export' as const,
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: ' secret-pass ',
|
||||
error: '上一次失败',
|
||||
confirmLoading: false,
|
||||
@@ -83,12 +153,16 @@ describe('connectionExport', () => {
|
||||
expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({
|
||||
open: false,
|
||||
mode: 'export',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: 'secret-pass',
|
||||
error: '更新后的错误',
|
||||
confirmLoading: true,
|
||||
})).toEqual({
|
||||
open: false,
|
||||
mode: 'export',
|
||||
includeSecrets: true,
|
||||
useFilePassword: false,
|
||||
password: 'secret-pass',
|
||||
error: '',
|
||||
confirmLoading: false,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { ConnectionConfig, SavedConnection } from '../types';
|
||||
|
||||
export type ConnectionImportKind = 'encrypted-package' | 'legacy-json' | 'invalid';
|
||||
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'invalid';
|
||||
export type ConnectionPackageDialogSnapshot = {
|
||||
open: boolean;
|
||||
mode: 'export' | 'import';
|
||||
includeSecrets: boolean;
|
||||
useFilePassword: boolean;
|
||||
password: string;
|
||||
error: string;
|
||||
confirmLoading: boolean;
|
||||
@@ -20,7 +22,11 @@ export type ConnectionPackageExportResult =
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package';
|
||||
const CONNECTION_PACKAGE_SCHEMA_VERSION_V2 = 2;
|
||||
const CONNECTION_PACKAGE_PROTECTION_APP_MANAGED = 1;
|
||||
const CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD = 2;
|
||||
const CANCELED_MESSAGE = '已取消';
|
||||
const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = '恢复包密码不能为空';
|
||||
|
||||
const isJsonObject = (value: unknown): value is JsonObject => (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
@@ -45,6 +51,36 @@ const isConnectionPackageEnvelope = (value: unknown): value is JsonObject => (
|
||||
&& typeof value.payload === 'string'
|
||||
);
|
||||
|
||||
const isConnectionPackageV2Envelope = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& value.kind === CONNECTION_PACKAGE_KIND
|
||||
&& value.v === CONNECTION_PACKAGE_SCHEMA_VERSION_V2
|
||||
&& typeof value.p === 'number'
|
||||
);
|
||||
|
||||
const isConnectionPackageKDFV2 = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.n === 'string'
|
||||
&& typeof value.m === 'number'
|
||||
&& typeof value.t === 'number'
|
||||
&& typeof value.l === 'number'
|
||||
&& typeof value.s === 'string'
|
||||
);
|
||||
|
||||
const isConnectionPackageV2AppManagedEnvelope = (value: unknown): value is JsonObject => (
|
||||
isConnectionPackageV2Envelope(value)
|
||||
&& value.p === CONNECTION_PACKAGE_PROTECTION_APP_MANAGED
|
||||
&& Array.isArray(value.connections)
|
||||
);
|
||||
|
||||
const isConnectionPackageV2ProtectedEnvelope = (value: unknown): value is JsonObject => (
|
||||
isConnectionPackageV2Envelope(value)
|
||||
&& value.p === CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD
|
||||
&& isConnectionPackageKDFV2(value.kdf)
|
||||
&& typeof value.nc === 'string'
|
||||
&& typeof value.d === 'string'
|
||||
);
|
||||
|
||||
const isLegacyConnectionConfig = (value: unknown): value is JsonObject => (
|
||||
isJsonObject(value)
|
||||
&& typeof value.type === 'string'
|
||||
@@ -72,6 +108,18 @@ const parseConnectionImportRaw = (raw: unknown): unknown => {
|
||||
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
|
||||
const parsed = parseConnectionImportRaw(raw);
|
||||
|
||||
if (isConnectionPackageV2AppManagedEnvelope(parsed)) {
|
||||
return 'app-managed-package';
|
||||
}
|
||||
|
||||
if (isConnectionPackageV2ProtectedEnvelope(parsed)) {
|
||||
return 'encrypted-package';
|
||||
}
|
||||
|
||||
if (isConnectionPackageV2Envelope(parsed)) {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed) && parsed.every((item) => isLegacyConnectionItem(item))) {
|
||||
return 'legacy-json';
|
||||
}
|
||||
@@ -85,6 +133,20 @@ export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind =
|
||||
|
||||
export const normalizeConnectionPackagePassword = (value: string): string => value.trim();
|
||||
|
||||
export const isConnectionPackagePasswordRequiredError = (value: unknown): boolean => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
|
||||
}
|
||||
|
||||
if (value instanceof Error) {
|
||||
return value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
|
||||
}
|
||||
|
||||
return isJsonObject(value)
|
||||
&& typeof value.message === 'string'
|
||||
&& value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE;
|
||||
};
|
||||
|
||||
export const isConnectionPackageExportCanceled = (result: unknown): boolean => (
|
||||
isJsonObject(result)
|
||||
&& result.success === false
|
||||
|
||||
Reference in New Issue
Block a user