mirror of
https://github.com/DeNNiiInc/dbgate.git
synced 2026-04-24 06:06:00 +00:00
Merge branch 'feature/cloud'
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
DEVMODE=1
|
||||
SHELL_SCRIPTING=1
|
||||
# LOCAL_DBGATE_CLOUD=1
|
||||
# LOCAL_DBGATE_IDENTITY=1
|
||||
|
||||
# CLOUD_UPGRADE_FILE=c:\test\upg\upgrade.zip
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ const {
|
||||
} = require('../auth/authProvider');
|
||||
const storage = require('./storage');
|
||||
const { decryptPasswordString } = require('../utility/crypting');
|
||||
const { createDbGateIdentitySession, startCloudTokenChecking } = require('../utility/cloudIntf');
|
||||
const socket = require('../utility/socket');
|
||||
|
||||
const logger = getLogger('auth');
|
||||
|
||||
@@ -135,5 +137,14 @@ module.exports = {
|
||||
return getAuthProviderById(amoid).redirect(params);
|
||||
},
|
||||
|
||||
createCloudLoginSession_meta: true,
|
||||
async createCloudLoginSession({ client }) {
|
||||
const res = await createDbGateIdentitySession(client);
|
||||
startCloudTokenChecking(res.sid, tokenHolder => {
|
||||
socket.emit('got-cloud-token', tokenHolder);
|
||||
});
|
||||
return res;
|
||||
},
|
||||
|
||||
authMiddleware,
|
||||
};
|
||||
|
||||
250
packages/api/src/controllers/cloud.js
Normal file
250
packages/api/src/controllers/cloud.js
Normal file
@@ -0,0 +1,250 @@
|
||||
const {
|
||||
getPublicCloudFiles,
|
||||
getPublicFileData,
|
||||
refreshPublicFiles,
|
||||
callCloudApiGet,
|
||||
callCloudApiPost,
|
||||
getCloudFolderEncryptor,
|
||||
getCloudContent,
|
||||
putCloudContent,
|
||||
removeCloudCachedConnection,
|
||||
} = require('../utility/cloudIntf');
|
||||
const connections = require('./connections');
|
||||
const socket = require('../utility/socket');
|
||||
const { recryptConnection, getInternalEncryptor, encryptConnection } = require('../utility/crypting');
|
||||
const { getConnectionLabel, getLogger, extractErrorLogData } = require('dbgate-tools');
|
||||
const logger = getLogger('cloud');
|
||||
const _ = require('lodash');
|
||||
const fs = require('fs-extra');
|
||||
|
||||
module.exports = {
|
||||
publicFiles_meta: true,
|
||||
async publicFiles() {
|
||||
const res = await getPublicCloudFiles();
|
||||
return res;
|
||||
},
|
||||
|
||||
publicFileData_meta: true,
|
||||
async publicFileData({ path }) {
|
||||
const res = getPublicFileData(path);
|
||||
return res;
|
||||
},
|
||||
|
||||
refreshPublicFiles_meta: true,
|
||||
async refreshPublicFiles({ isRefresh }) {
|
||||
await refreshPublicFiles(isRefresh);
|
||||
return {
|
||||
status: 'ok',
|
||||
};
|
||||
},
|
||||
|
||||
contentList_meta: true,
|
||||
async contentList() {
|
||||
try {
|
||||
const resp = await callCloudApiGet('content-list');
|
||||
return resp;
|
||||
} catch (err) {
|
||||
logger.error(extractErrorLogData(err), 'Error getting cloud content list');
|
||||
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
getContent_meta: true,
|
||||
async getContent({ folid, cntid }) {
|
||||
const resp = await getCloudContent(folid, cntid);
|
||||
return resp;
|
||||
},
|
||||
|
||||
putContent_meta: true,
|
||||
async putContent({ folid, cntid, content, name, type }) {
|
||||
const resp = await putCloudContent(folid, cntid, content, name, type);
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
createFolder_meta: true,
|
||||
async createFolder({ name }) {
|
||||
const resp = await callCloudApiPost(`folders/create`, { name });
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
grantFolder_meta: true,
|
||||
async grantFolder({ inviteLink }) {
|
||||
const m = inviteLink.match(/^dbgate\:\/\/folder\/v1\/([a-zA-Z0-9]+)\?mode=(read|write|admin)$/);
|
||||
if (!m) {
|
||||
throw new Error('Invalid invite link format');
|
||||
}
|
||||
const invite = m[1];
|
||||
const mode = m[2];
|
||||
|
||||
const resp = await callCloudApiPost(`folders/grant/${mode}`, { invite });
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
renameFolder_meta: true,
|
||||
async renameFolder({ folid, name }) {
|
||||
const resp = await callCloudApiPost(`folders/rename`, { folid, name });
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
deleteFolder_meta: true,
|
||||
async deleteFolder({ folid }) {
|
||||
const resp = await callCloudApiPost(`folders/delete`, { folid });
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
getInviteToken_meta: true,
|
||||
async getInviteToken({ folid, role }) {
|
||||
const resp = await callCloudApiGet(`invite-token/${folid}/${role}`);
|
||||
return resp;
|
||||
},
|
||||
|
||||
refreshContent_meta: true,
|
||||
async refreshContent() {
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return {
|
||||
status: 'ok',
|
||||
};
|
||||
},
|
||||
|
||||
copyConnectionCloud_meta: true,
|
||||
async copyConnectionCloud({ conid, folid }) {
|
||||
const conn = await connections.getCore({ conid });
|
||||
const folderEncryptor = await getCloudFolderEncryptor(folid);
|
||||
const recryptedConn = recryptConnection(conn, getInternalEncryptor(), folderEncryptor);
|
||||
const connToSend = _.omit(recryptedConn, ['_id']);
|
||||
const resp = await putCloudContent(
|
||||
folid,
|
||||
undefined,
|
||||
JSON.stringify(connToSend),
|
||||
getConnectionLabel(conn),
|
||||
'connection'
|
||||
);
|
||||
return resp;
|
||||
},
|
||||
|
||||
saveConnection_meta: true,
|
||||
async saveConnection({ folid, connection }) {
|
||||
let cntid = undefined;
|
||||
if (connection._id) {
|
||||
const m = connection._id.match(/^cloud\:\/\/(.+)\/(.+)$/);
|
||||
if (!m) {
|
||||
throw new Error('Invalid cloud connection ID format');
|
||||
}
|
||||
folid = m[1];
|
||||
cntid = m[2];
|
||||
}
|
||||
|
||||
if (!folid) {
|
||||
throw new Error('Missing cloud folder ID');
|
||||
}
|
||||
|
||||
const folderEncryptor = await getCloudFolderEncryptor(folid);
|
||||
const recryptedConn = encryptConnection(connection, folderEncryptor);
|
||||
const resp = await putCloudContent(
|
||||
folid,
|
||||
cntid,
|
||||
JSON.stringify(recryptedConn),
|
||||
getConnectionLabel(recryptedConn),
|
||||
'connection'
|
||||
);
|
||||
|
||||
if (resp.apiErrorMessage) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
removeCloudCachedConnection(folid, resp.cntid);
|
||||
cntid = resp.cntid;
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return {
|
||||
...recryptedConn,
|
||||
_id: `cloud://${folid}/${cntid}`,
|
||||
};
|
||||
},
|
||||
|
||||
duplicateConnection_meta: true,
|
||||
async duplicateConnection({ conid }) {
|
||||
const m = conid.match(/^cloud\:\/\/(.+)\/(.+)$/);
|
||||
if (!m) {
|
||||
throw new Error('Invalid cloud connection ID format');
|
||||
}
|
||||
const folid = m[1];
|
||||
const cntid = m[2];
|
||||
const respGet = await getCloudContent(folid, cntid);
|
||||
const conn = JSON.parse(respGet.content);
|
||||
const conn2 = {
|
||||
...conn,
|
||||
displayName: getConnectionLabel(conn) + ' - copy',
|
||||
};
|
||||
const respPut = await putCloudContent(folid, undefined, JSON.stringify(conn2), conn2.displayName, 'connection');
|
||||
return respPut;
|
||||
},
|
||||
|
||||
deleteConnection_meta: true,
|
||||
async deleteConnection({ conid }) {
|
||||
const m = conid.match(/^cloud\:\/\/(.+)\/(.+)$/);
|
||||
if (!m) {
|
||||
throw new Error('Invalid cloud connection ID format');
|
||||
}
|
||||
const folid = m[1];
|
||||
const cntid = m[2];
|
||||
const resp = await callCloudApiPost(`content/delete/${folid}/${cntid}`);
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
deleteContent_meta: true,
|
||||
async deleteContent({ folid, cntid }) {
|
||||
const resp = await callCloudApiPost(`content/delete/${folid}/${cntid}`);
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
renameContent_meta: true,
|
||||
async renameContent({ folid, cntid, name }) {
|
||||
const resp = await callCloudApiPost(`content/rename/${folid}/${cntid}`, { name });
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
saveFile_meta: true,
|
||||
async saveFile({ folid, cntid, fileName, data, contentFolder, format }) {
|
||||
const resp = await putCloudContent(folid, cntid, data, fileName, 'file', contentFolder, format);
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
copyFile_meta: true,
|
||||
async copyFile({ folid, cntid, name }) {
|
||||
const resp = await callCloudApiPost(`content/duplicate/${folid}/${cntid}`, { name });
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
},
|
||||
|
||||
exportFile_meta: true,
|
||||
async exportFile({ folid, cntid, filePath }, req) {
|
||||
const { content } = await getCloudContent(folid, cntid);
|
||||
if (!content) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
await fs.writeFile(filePath, content);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -239,6 +239,19 @@ module.exports = {
|
||||
return (await this.datastore.find()).filter(x => connectionHasPermission(x, req));
|
||||
},
|
||||
|
||||
async getUsedEngines() {
|
||||
const storage = require('./storage');
|
||||
|
||||
const storageEngines = await storage.getUsedEngines();
|
||||
if (storageEngines) {
|
||||
return storageEngines;
|
||||
}
|
||||
if (portalConnections) {
|
||||
return _.uniq(_.compact(portalConnections.map(x => x.engine)));
|
||||
}
|
||||
return _.uniq((await this.datastore.find()).map(x => x.engine));
|
||||
},
|
||||
|
||||
test_meta: true,
|
||||
test({ connection, requestDbList = false }) {
|
||||
const subprocess = fork(
|
||||
@@ -410,6 +423,13 @@ module.exports = {
|
||||
return volatile;
|
||||
}
|
||||
|
||||
const cloudMatch = conid.match(/^cloud\:\/\/(.+)\/(.+)$/);
|
||||
if (cloudMatch) {
|
||||
const { loadCachedCloudConnection } = require('../utility/cloudIntf');
|
||||
const conn = await loadCachedCloudConnection(cloudMatch[1], cloudMatch[2]);
|
||||
return conn;
|
||||
}
|
||||
|
||||
const storage = require('./storage');
|
||||
|
||||
const storageConnection = await storage.getConnection({ conid });
|
||||
|
||||
@@ -148,6 +148,9 @@ module.exports = {
|
||||
const existing = this.opened.find(x => x.conid == conid && x.database == database);
|
||||
if (existing) return existing;
|
||||
const connection = await connections.getCore({ conid });
|
||||
if (!connection) {
|
||||
throw new Error(`databaseConnections: Connection with conid="${conid}" not found`);
|
||||
}
|
||||
if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') {
|
||||
throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode });
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ module.exports = {
|
||||
if (existing) return existing;
|
||||
const connection = await connections.getCore({ conid });
|
||||
if (!connection) {
|
||||
throw new Error(`Connection with conid="${conid}" not found`);
|
||||
throw new Error(`serverConnections: Connection with conid="${conid}" not found`);
|
||||
}
|
||||
if (connection.singleDatabase) {
|
||||
return null;
|
||||
|
||||
@@ -32,4 +32,8 @@ module.exports = {
|
||||
},
|
||||
|
||||
startRefreshLicense() {},
|
||||
|
||||
async getUsedEngines() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ const plugins = require('./controllers/plugins');
|
||||
const files = require('./controllers/files');
|
||||
const scheduler = require('./controllers/scheduler');
|
||||
const queryHistory = require('./controllers/queryHistory');
|
||||
const cloud = require('./controllers/cloud');
|
||||
const onFinished = require('on-finished');
|
||||
const processArgs = require('./utility/processArgs');
|
||||
|
||||
@@ -39,6 +40,7 @@ const { getDefaultAuthProvider } = require('./auth/authProvider');
|
||||
const startCloudUpgradeTimer = require('./utility/cloudUpgrade');
|
||||
const { isProApp } = require('./utility/checkLicense');
|
||||
const { getHealthStatus, getHealthStatusSprinx } = require('./utility/healthStatus');
|
||||
const { startCloudFiles } = require('./utility/cloudIntf');
|
||||
|
||||
const logger = getLogger('main');
|
||||
|
||||
@@ -200,6 +202,8 @@ function start() {
|
||||
if (process.env.CLOUD_UPGRADE_FILE) {
|
||||
startCloudUpgradeTimer();
|
||||
}
|
||||
|
||||
startCloudFiles();
|
||||
}
|
||||
|
||||
function useAllControllers(app, electron) {
|
||||
@@ -220,6 +224,7 @@ function useAllControllers(app, electron) {
|
||||
useController(app, electron, '/query-history', queryHistory);
|
||||
useController(app, electron, '/apps', apps);
|
||||
useController(app, electron, '/auth', auth);
|
||||
useController(app, electron, '/cloud', cloud);
|
||||
}
|
||||
|
||||
function setElectronSender(electronSender) {
|
||||
|
||||
@@ -28,14 +28,7 @@ function start() {
|
||||
let version = {
|
||||
version: 'Unknown',
|
||||
};
|
||||
try {
|
||||
version = await driver.getVersion(dbhan);
|
||||
} catch (err) {
|
||||
logger.error(extractErrorLogData(err), 'Error getting DB server version');
|
||||
version = {
|
||||
version: 'Unknown',
|
||||
};
|
||||
}
|
||||
version = await driver.getVersion(dbhan);
|
||||
let databases = undefined;
|
||||
if (requestDbList) {
|
||||
databases = await driver.listDatabases(dbhan);
|
||||
|
||||
@@ -36,6 +36,10 @@ async function callRefactorSqlQueryApi(query, task, structure, dialect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLicenseHttpHeaders() {
|
||||
return {};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAuthProxySupported,
|
||||
authProxyGetRedirectUrl,
|
||||
@@ -47,4 +51,5 @@ module.exports = {
|
||||
callTextToSqlApi,
|
||||
callCompleteOnCursorApi,
|
||||
callRefactorSqlQueryApi,
|
||||
getLicenseHttpHeaders,
|
||||
};
|
||||
|
||||
380
packages/api/src/utility/cloudIntf.js
Normal file
380
packages/api/src/utility/cloudIntf.js
Normal file
@@ -0,0 +1,380 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs-extra');
|
||||
const _ = require('lodash');
|
||||
const path = require('path');
|
||||
const { getLicenseHttpHeaders } = require('./authProxy');
|
||||
const { getLogger, extractErrorLogData, jsonLinesParse } = require('dbgate-tools');
|
||||
const { datadir } = require('./directories');
|
||||
const platformInfo = require('./platformInfo');
|
||||
const connections = require('../controllers/connections');
|
||||
const { isProApp } = require('./checkLicense');
|
||||
const socket = require('./socket');
|
||||
const config = require('../controllers/config');
|
||||
const simpleEncryptor = require('simple-encryptor');
|
||||
const currentVersion = require('../currentVersion');
|
||||
const { getPublicIpInfo } = require('./hardwareFingerprint');
|
||||
|
||||
const logger = getLogger('cloudIntf');
|
||||
|
||||
let cloudFiles = null;
|
||||
|
||||
const DBGATE_IDENTITY_URL = process.env.LOCAL_DBGATE_IDENTITY
|
||||
? 'http://localhost:3103'
|
||||
: process.env.DEVWEB || process.env.DEVMODE
|
||||
? 'https://identity.dbgate.udolni.net'
|
||||
: 'https://identity.dbgate.io';
|
||||
|
||||
const DBGATE_CLOUD_URL = process.env.LOCAL_DBGATE_CLOUD
|
||||
? 'http://localhost:3110'
|
||||
: process.env.DEVWEB || process.env.DEVMODE
|
||||
? 'https://cloud.dbgate.udolni.net'
|
||||
: 'https://cloud.dbgate.io';
|
||||
|
||||
async function createDbGateIdentitySession(client) {
|
||||
const resp = await axios.default.post(
|
||||
`${DBGATE_IDENTITY_URL}/api/create-session`,
|
||||
{
|
||||
client,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...getLicenseHttpHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
return {
|
||||
sid: resp.data.sid,
|
||||
url: `${DBGATE_IDENTITY_URL}/api/signin/${resp.data.sid}`,
|
||||
};
|
||||
}
|
||||
|
||||
function startCloudTokenChecking(sid, callback) {
|
||||
const started = Date.now();
|
||||
const interval = setInterval(async () => {
|
||||
if (Date.now() - started > 60 * 1000) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log(`Checking cloud token for session: ${DBGATE_IDENTITY_URL}/api/get-token/${sid}`);
|
||||
const resp = await axios.default.get(`${DBGATE_IDENTITY_URL}/api/get-token/${sid}`, {
|
||||
headers: {
|
||||
...getLicenseHttpHeaders(),
|
||||
},
|
||||
});
|
||||
// console.log('CHECK RESP:', resp.data);
|
||||
|
||||
if (resp.data.email) {
|
||||
clearInterval(interval);
|
||||
callback(resp.data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(extractErrorLogData(err), 'Error checking cloud token');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function loadCloudFiles() {
|
||||
try {
|
||||
const fileContent = await fs.readFile(path.join(datadir(), 'cloud-files.jsonl'), 'utf-8');
|
||||
const parsedJson = jsonLinesParse(fileContent);
|
||||
cloudFiles = _.sortBy(parsedJson, x => `${x.folder}/${x.title}`);
|
||||
} catch (err) {
|
||||
cloudFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCloudFilesSearchTags() {
|
||||
const res = [];
|
||||
if (platformInfo.isElectron) {
|
||||
res.push('app');
|
||||
} else {
|
||||
res.push('web');
|
||||
}
|
||||
if (platformInfo.isWindows) {
|
||||
res.push('windows');
|
||||
}
|
||||
if (platformInfo.isMac) {
|
||||
res.push('mac');
|
||||
}
|
||||
if (platformInfo.isLinux) {
|
||||
res.push('linux');
|
||||
}
|
||||
if (platformInfo.isAwsUbuntuLayout) {
|
||||
res.push('aws');
|
||||
}
|
||||
if (platformInfo.isAzureUbuntuLayout) {
|
||||
res.push('azure');
|
||||
}
|
||||
if (platformInfo.isSnap) {
|
||||
res.push('snap');
|
||||
}
|
||||
if (platformInfo.isDocker) {
|
||||
res.push('docker');
|
||||
}
|
||||
if (platformInfo.isNpmDist) {
|
||||
res.push('npm');
|
||||
}
|
||||
const engines = await connections.getUsedEngines();
|
||||
const engineTags = engines.map(engine => engine.split('@')[0]);
|
||||
res.push(...engineTags);
|
||||
|
||||
// team-premium and trials will return the same cloud files as premium - no need to check
|
||||
res.push(isProApp() ? 'premium' : 'community');
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function getCloudSigninHolder() {
|
||||
const settingsValue = await config.getSettings();
|
||||
const holder = settingsValue['cloudSigninTokenHolder'];
|
||||
return holder;
|
||||
}
|
||||
|
||||
async function getCloudSigninHeaders(holder = null) {
|
||||
if (!holder) {
|
||||
holder = await getCloudSigninHolder();
|
||||
}
|
||||
if (holder) {
|
||||
return {
|
||||
'x-cloud-login': holder.token,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateCloudFiles(isRefresh) {
|
||||
let lastCloudFilesTags;
|
||||
try {
|
||||
lastCloudFilesTags = await fs.readFile(path.join(datadir(), 'cloud-files-tags.txt'), 'utf-8');
|
||||
} catch (err) {
|
||||
lastCloudFilesTags = '';
|
||||
}
|
||||
|
||||
const ipInfo = await getPublicIpInfo();
|
||||
|
||||
const tags = (await collectCloudFilesSearchTags()).join(',');
|
||||
let lastCheckedTm = 0;
|
||||
if (tags == lastCloudFilesTags && cloudFiles.length > 0) {
|
||||
lastCheckedTm = _.max(cloudFiles.map(x => parseInt(x.modifiedTm)));
|
||||
}
|
||||
|
||||
logger.info({ tags, lastCheckedTm }, 'Downloading cloud files');
|
||||
|
||||
const resp = await axios.default.get(
|
||||
`${DBGATE_CLOUD_URL}/public-cloud-updates?lastCheckedTm=${lastCheckedTm}&tags=${tags}&isRefresh=${
|
||||
isRefresh ? 1 : 0
|
||||
}&country=${ipInfo?.country || ''}`,
|
||||
{
|
||||
headers: {
|
||||
...getLicenseHttpHeaders(),
|
||||
...(await getCloudSigninHeaders()),
|
||||
'x-app-version': currentVersion.version,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`Downloaded ${resp.data.length} cloud files`);
|
||||
|
||||
const filesByPath = lastCheckedTm == 0 ? {} : _.keyBy(cloudFiles, 'path');
|
||||
for (const file of resp.data) {
|
||||
if (file.isDeleted) {
|
||||
delete filesByPath[file.path];
|
||||
} else {
|
||||
filesByPath[file.path] = file;
|
||||
}
|
||||
}
|
||||
|
||||
cloudFiles = Object.values(filesByPath);
|
||||
|
||||
await fs.writeFile(path.join(datadir(), 'cloud-files.jsonl'), cloudFiles.map(x => JSON.stringify(x)).join('\n'));
|
||||
await fs.writeFile(path.join(datadir(), 'cloud-files-tags.txt'), tags);
|
||||
|
||||
socket.emitChanged(`public-cloud-changed`);
|
||||
}
|
||||
|
||||
async function startCloudFiles() {
|
||||
loadCloudFiles();
|
||||
}
|
||||
|
||||
async function getPublicCloudFiles() {
|
||||
if (!loadCloudFiles) {
|
||||
await loadCloudFiles();
|
||||
}
|
||||
return cloudFiles;
|
||||
}
|
||||
|
||||
async function getPublicFileData(path) {
|
||||
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/public/${path}`, {
|
||||
headers: {
|
||||
...getLicenseHttpHeaders(),
|
||||
},
|
||||
});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
async function refreshPublicFiles(isRefresh) {
|
||||
if (!cloudFiles) {
|
||||
await loadCloudFiles();
|
||||
}
|
||||
try {
|
||||
await updateCloudFiles(isRefresh);
|
||||
} catch (err) {
|
||||
logger.error(extractErrorLogData(err), 'Error updating cloud files');
|
||||
}
|
||||
}
|
||||
|
||||
async function callCloudApiGet(endpoint, signinHolder = null, additionalHeaders = {}) {
|
||||
if (!signinHolder) {
|
||||
signinHolder = await getCloudSigninHolder();
|
||||
}
|
||||
if (!signinHolder) {
|
||||
return null;
|
||||
}
|
||||
const signinHeaders = await getCloudSigninHeaders(signinHolder);
|
||||
|
||||
const resp = await axios.default.get(`${DBGATE_CLOUD_URL}/${endpoint}`, {
|
||||
headers: {
|
||||
...getLicenseHttpHeaders(),
|
||||
...signinHeaders,
|
||||
...additionalHeaders,
|
||||
},
|
||||
validateStatus: status => status < 500,
|
||||
});
|
||||
const { errorMessage } = resp.data;
|
||||
if (errorMessage) {
|
||||
return { apiErrorMessage: errorMessage };
|
||||
}
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
async function callCloudApiPost(endpoint, body, signinHolder = null) {
|
||||
if (!signinHolder) {
|
||||
signinHolder = await getCloudSigninHolder();
|
||||
}
|
||||
if (!signinHolder) {
|
||||
return null;
|
||||
}
|
||||
const signinHeaders = await getCloudSigninHeaders(signinHolder);
|
||||
|
||||
const resp = await axios.default.post(`${DBGATE_CLOUD_URL}/${endpoint}`, body, {
|
||||
headers: {
|
||||
...getLicenseHttpHeaders(),
|
||||
...signinHeaders,
|
||||
},
|
||||
validateStatus: status => status < 500,
|
||||
});
|
||||
const { errorMessage, isLicenseLimit, limitedLicenseLimits } = resp.data;
|
||||
if (errorMessage) {
|
||||
return {
|
||||
apiErrorMessage: errorMessage,
|
||||
apiErrorIsLicenseLimit: isLicenseLimit,
|
||||
apiErrorLimitedLicenseLimits: limitedLicenseLimits,
|
||||
};
|
||||
}
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
async function getCloudFolderEncryptor(folid) {
|
||||
const { encryptionKey } = await callCloudApiGet(`folder-key/${folid}`);
|
||||
if (!encryptionKey) {
|
||||
throw new Error('No encryption key for folder: ' + folid);
|
||||
}
|
||||
return simpleEncryptor.createEncryptor(encryptionKey);
|
||||
}
|
||||
|
||||
async function getCloudContent(folid, cntid) {
|
||||
const signinHolder = await getCloudSigninHolder();
|
||||
if (!signinHolder) {
|
||||
throw new Error('No signed in');
|
||||
}
|
||||
|
||||
const encryptor = simpleEncryptor.createEncryptor(signinHolder.encryptionKey);
|
||||
|
||||
const { content, name, type, contentFolder, contentType, apiErrorMessage } = await callCloudApiGet(
|
||||
`content/${folid}/${cntid}`,
|
||||
signinHolder,
|
||||
{
|
||||
'x-kehid': signinHolder.kehid,
|
||||
}
|
||||
);
|
||||
|
||||
if (apiErrorMessage) {
|
||||
return { apiErrorMessage };
|
||||
}
|
||||
|
||||
return {
|
||||
content: encryptor.decrypt(content),
|
||||
name,
|
||||
type,
|
||||
contentFolder,
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns Promise<{ cntid: string } | { apiErrorMessage: string }>
|
||||
*/
|
||||
async function putCloudContent(folid, cntid, content, name, type, contentFolder = null, contentType = null) {
|
||||
const signinHolder = await getCloudSigninHolder();
|
||||
if (!signinHolder) {
|
||||
throw new Error('No signed in');
|
||||
}
|
||||
|
||||
const encryptor = simpleEncryptor.createEncryptor(signinHolder.encryptionKey);
|
||||
|
||||
const resp = await callCloudApiPost(
|
||||
`put-content`,
|
||||
{
|
||||
folid,
|
||||
cntid,
|
||||
name,
|
||||
type,
|
||||
kehid: signinHolder.kehid,
|
||||
content: encryptor.encrypt(content),
|
||||
contentFolder,
|
||||
contentType,
|
||||
},
|
||||
signinHolder
|
||||
);
|
||||
socket.emitChanged('cloud-content-changed');
|
||||
socket.emit('cloud-content-updated');
|
||||
return resp;
|
||||
}
|
||||
|
||||
const cloudConnectionCache = {};
|
||||
async function loadCachedCloudConnection(folid, cntid) {
|
||||
const cacheKey = `${folid}|${cntid}`;
|
||||
if (!cloudConnectionCache[cacheKey]) {
|
||||
const { content } = await getCloudContent(folid, cntid);
|
||||
cloudConnectionCache[cacheKey] = {
|
||||
...JSON.parse(content),
|
||||
_id: `cloud://${folid}/${cntid}`,
|
||||
};
|
||||
}
|
||||
return cloudConnectionCache[cacheKey];
|
||||
}
|
||||
|
||||
function removeCloudCachedConnection(folid, cntid) {
|
||||
const cacheKey = `${folid}|${cntid}`;
|
||||
delete cloudConnectionCache[cacheKey];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDbGateIdentitySession,
|
||||
startCloudTokenChecking,
|
||||
startCloudFiles,
|
||||
getPublicCloudFiles,
|
||||
getPublicFileData,
|
||||
refreshPublicFiles,
|
||||
callCloudApiGet,
|
||||
callCloudApiPost,
|
||||
getCloudFolderEncryptor,
|
||||
getCloudContent,
|
||||
loadCachedCloudConnection,
|
||||
putCloudContent,
|
||||
removeCloudCachedConnection,
|
||||
};
|
||||
@@ -81,11 +81,11 @@ function decryptPasswordString(password) {
|
||||
return password;
|
||||
}
|
||||
|
||||
function encryptObjectPasswordField(obj, field) {
|
||||
function encryptObjectPasswordField(obj, field, encryptor = null) {
|
||||
if (obj && obj[field] && !obj[field].startsWith('crypt:')) {
|
||||
return {
|
||||
...obj,
|
||||
[field]: 'crypt:' + getInternalEncryptor().encrypt(obj[field]),
|
||||
[field]: 'crypt:' + (encryptor || getInternalEncryptor()).encrypt(obj[field]),
|
||||
};
|
||||
}
|
||||
return obj;
|
||||
@@ -101,11 +101,11 @@ function decryptObjectPasswordField(obj, field) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
function encryptConnection(connection) {
|
||||
function encryptConnection(connection, encryptor = null) {
|
||||
if (connection.passwordMode != 'saveRaw') {
|
||||
connection = encryptObjectPasswordField(connection, 'password');
|
||||
connection = encryptObjectPasswordField(connection, 'sshPassword');
|
||||
connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword');
|
||||
connection = encryptObjectPasswordField(connection, 'password', encryptor);
|
||||
connection = encryptObjectPasswordField(connection, 'sshPassword', encryptor);
|
||||
connection = encryptObjectPasswordField(connection, 'sshKeyfilePassword', encryptor);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
@@ -87,4 +87,5 @@ module.exports = {
|
||||
getHardwareFingerprint,
|
||||
getHardwareFingerprintHash,
|
||||
getPublicHardwareFingerprint,
|
||||
getPublicIpInfo,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
:root {
|
||||
--dim-widget-icon-size: 60px;
|
||||
--dim-widget-icon-size: 50px;
|
||||
--dim-statusbar-height: 22px;
|
||||
--dim-left-panel-width: 300px;
|
||||
--dim-tabs-height: 33px;
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
// import { shouldWaitForElectronInitialize } from './utility/getElectron';
|
||||
import { subscribeConnectionPingers } from './utility/connectionsPinger';
|
||||
import { subscribePermissionCompiler } from './utility/hasPermission';
|
||||
import { apiCall, installNewVolatileConnectionListener } from './utility/api';
|
||||
import {
|
||||
apiCall,
|
||||
installNewCloudTokenListener,
|
||||
installNewVolatileConnectionListener,
|
||||
refreshPublicCloudFiles,
|
||||
} from './utility/api';
|
||||
import { getConfig, getSettings, getUsedApps } from './utility/metadataLoaders';
|
||||
import AppTitleProvider from './utility/AppTitleProvider.svelte';
|
||||
import getElectron from './utility/getElectron';
|
||||
@@ -23,6 +28,7 @@
|
||||
import { handleAuthOnStartup } from './clientAuth';
|
||||
import { initializeAppUpdates } from './utility/appUpdate';
|
||||
import { _t } from './translations';
|
||||
import { installCloudListeners } from './utility/cloudListeners';
|
||||
|
||||
export let isAdminPage = false;
|
||||
|
||||
@@ -51,9 +57,13 @@
|
||||
subscribeConnectionPingers();
|
||||
subscribePermissionCompiler();
|
||||
installNewVolatileConnectionListener();
|
||||
installNewCloudTokenListener();
|
||||
initializeAppUpdates();
|
||||
installCloudListeners();
|
||||
}
|
||||
|
||||
refreshPublicCloudFiles();
|
||||
|
||||
loadedApi = loadedApiValue;
|
||||
|
||||
if (!loadedApi) {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
export let groupFunc;
|
||||
export let items;
|
||||
export let groupIconFunc = plusExpandIcon;
|
||||
export let mapGroupTitle = undefined;
|
||||
export let module;
|
||||
export let checkedObjectsStore = null;
|
||||
export let disableContextMenu = false;
|
||||
@@ -63,7 +64,7 @@
|
||||
<FontIcon icon={groupIconFunc(isExpanded)} />
|
||||
</span>
|
||||
|
||||
{group}
|
||||
{mapGroupTitle ? mapGroupTitle(group) : group}
|
||||
{items && `(${countText})`}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
export let groupIconFunc = plusExpandIcon;
|
||||
export let groupFunc = undefined;
|
||||
export let mapGroupTitle = undefined;
|
||||
export let onDropOnGroup = undefined;
|
||||
export let emptyGroupNames = [];
|
||||
export let isExpandedBySearch = false;
|
||||
@@ -127,6 +128,7 @@
|
||||
{subItemsComponent}
|
||||
{checkedObjectsStore}
|
||||
{groupFunc}
|
||||
{mapGroupTitle}
|
||||
{disableContextMenu}
|
||||
{filter}
|
||||
{passProps}
|
||||
|
||||
139
packages/web/src/appobj/CloudContentAppObject.svelte
Normal file
139
packages/web/src/appobj/CloudContentAppObject.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" context="module">
|
||||
import { cloudConnectionsStore } from '../stores';
|
||||
import { apiCall } from '../utility/api';
|
||||
import AppObjectCore from './AppObjectCore.svelte';
|
||||
|
||||
export const extractKey = data => data.cntid;
|
||||
export const createMatcher =
|
||||
filter =>
|
||||
({ name }) =>
|
||||
filterName(filter, name);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { filterName, getConnectionLabel } from 'dbgate-tools';
|
||||
import ConnectionAppObject, { openConnection } from './ConnectionAppObject.svelte';
|
||||
import { _t } from '../translations';
|
||||
import openNewTab from '../utility/openNewTab';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import ConfirmModal from '../modals/ConfirmModal.svelte';
|
||||
import SavedFileAppObject from './SavedFileAppObject.svelte';
|
||||
|
||||
export let data;
|
||||
export let passProps;
|
||||
|
||||
function createMenu() {
|
||||
const res = [];
|
||||
switch (data.type) {
|
||||
case 'connection':
|
||||
res.push({
|
||||
text: _t('connection.connect', { defaultMessage: 'Connect' }),
|
||||
onClick: handleConnect,
|
||||
isBold: true,
|
||||
});
|
||||
res.push({ divider: true });
|
||||
res.push({
|
||||
text: _t('connection.edit', { defaultMessage: 'Edit' }),
|
||||
onClick: handleEditConnection,
|
||||
});
|
||||
res.push({
|
||||
text: _t('connection.delete', { defaultMessage: 'Delete' }),
|
||||
onClick: handleDeleteConnection,
|
||||
});
|
||||
res.push({
|
||||
text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }),
|
||||
onClick: handleDuplicateConnection,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
function handleEditConnection() {
|
||||
openNewTab({
|
||||
title: data.name,
|
||||
icon: 'img cloud-connection',
|
||||
tabComponent: 'ConnectionTab',
|
||||
props: {
|
||||
conid: data.conid,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteConnection() {
|
||||
showModal(ConfirmModal, {
|
||||
message: `Really delete connection ${data.name}?`,
|
||||
onConfirm: () => {
|
||||
apiCall('cloud/delete-connection', { conid: data.conid });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDuplicateConnection() {
|
||||
await apiCall('cloud/duplicate-connection', { conid: data.conid });
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
const conn = await apiCall('connections/get', { conid: data.conid });
|
||||
$cloudConnectionsStore = {
|
||||
...$cloudConnectionsStore,
|
||||
[data.conid]: conn,
|
||||
};
|
||||
openConnection(conn);
|
||||
}
|
||||
|
||||
async function handleOpenContent() {
|
||||
switch (data.type) {
|
||||
case 'connection':
|
||||
await handleConnect();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if data.conid && $cloudConnectionsStore[data.conid]}
|
||||
<ConnectionAppObject
|
||||
{...$$restProps}
|
||||
{passProps}
|
||||
data={{
|
||||
...$cloudConnectionsStore[data.conid],
|
||||
status: data.status,
|
||||
}}
|
||||
on:dblclick
|
||||
on:expand
|
||||
/>
|
||||
{:else if data.type == 'file'}
|
||||
<SavedFileAppObject
|
||||
{...$$restProps}
|
||||
{passProps}
|
||||
data={{
|
||||
file: data.name,
|
||||
folder: data.contentFolder,
|
||||
folid: data.folid,
|
||||
cntid: data.cntid,
|
||||
}}
|
||||
on:dblclick
|
||||
on:expand
|
||||
/>
|
||||
{:else}
|
||||
<AppObjectCore
|
||||
{...$$restProps}
|
||||
{data}
|
||||
icon={'img cloud-connection'}
|
||||
title={data.name}
|
||||
menu={createMenu}
|
||||
on:click={handleOpenContent}
|
||||
on:dblclick
|
||||
on:expand
|
||||
></AppObjectCore>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.info {
|
||||
margin-left: 30px;
|
||||
margin-right: 5px;
|
||||
color: var(--theme-font-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -108,6 +108,7 @@
|
||||
import _ from 'lodash';
|
||||
import AppObjectCore from './AppObjectCore.svelte';
|
||||
import {
|
||||
cloudSigninTokenHolder,
|
||||
currentDatabase,
|
||||
DEFAULT_CONNECTION_SEARCH_SETTINGS,
|
||||
expandedConnections,
|
||||
@@ -160,7 +161,7 @@
|
||||
const handleOpenConnectionTab = () => {
|
||||
openNewTab({
|
||||
title: getConnectionLabel(data),
|
||||
icon: 'img connection',
|
||||
icon: data._id.startsWith('cloud://') ? 'img cloud-connection' : 'img connection',
|
||||
tabComponent: 'ConnectionTab',
|
||||
props: {
|
||||
conid: data._id,
|
||||
@@ -261,11 +262,15 @@
|
||||
});
|
||||
};
|
||||
const handleDuplicate = () => {
|
||||
apiCall('connections/save', {
|
||||
...data,
|
||||
_id: undefined,
|
||||
displayName: `${getConnectionLabel(data)} - copy`,
|
||||
});
|
||||
if (data._id.startsWith('cloud://')) {
|
||||
apiCall('cloud/duplicate-connection', { conid: data._id });
|
||||
} else {
|
||||
apiCall('connections/save', {
|
||||
...data,
|
||||
_id: undefined,
|
||||
displayName: `${getConnectionLabel(data)} - copy`,
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleCreateDatabase = () => {
|
||||
showModal(InputTextModal, {
|
||||
@@ -332,6 +337,19 @@
|
||||
text: _t('connection.duplicate', { defaultMessage: 'Duplicate' }),
|
||||
onClick: handleDuplicate,
|
||||
},
|
||||
!$openedConnections.includes(data._id) &&
|
||||
$cloudSigninTokenHolder &&
|
||||
passProps?.cloudContentList?.length > 0 && {
|
||||
text: _t('connection.copyToCloudFolder', { defaultMessage: 'Copy to cloud folder' }),
|
||||
submenu: passProps?.cloudContentList
|
||||
?.filter(x => x.role == 'write' || x.role == 'admin')
|
||||
?.map(fld => ({
|
||||
text: fld.name,
|
||||
onClick: () => {
|
||||
apiCall('cloud/copy-connection-cloud', { conid: data._id, folid: fld.folid });
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
{ divider: true },
|
||||
!data.singleDatabase && [
|
||||
@@ -416,7 +434,7 @@
|
||||
{...$$restProps}
|
||||
{data}
|
||||
title={getConnectionLabel(data, { showUnsaved: true })}
|
||||
icon={data.singleDatabase ? 'img database' : 'img server'}
|
||||
icon={data._id.startsWith('cloud://') ? 'img cloud-connection' : data.singleDatabase ? 'img database' : 'img server'}
|
||||
isBold={data.singleDatabase
|
||||
? $currentDatabase?.connection?._id == data._id && $currentDatabase?.name == data.defaultDatabase
|
||||
: $currentDatabase?.connection?._id == data._id}
|
||||
|
||||
52
packages/web/src/appobj/PublicCloudFileAppObject.svelte
Normal file
52
packages/web/src/appobj/PublicCloudFileAppObject.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" context="module">
|
||||
import AppObjectCore from './AppObjectCore.svelte';
|
||||
|
||||
export const extractKey = data => data.path;
|
||||
export const createMatcher =
|
||||
filter =>
|
||||
({ title, description }) =>
|
||||
filterName(filter, title, description);
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { apiCall } from '../utility/api';
|
||||
import newQuery from '../query/newQuery';
|
||||
import { filterName } from 'dbgate-tools';
|
||||
|
||||
export let data;
|
||||
|
||||
async function handleOpenSqlFile() {
|
||||
const fileData = await apiCall('cloud/public-file-data', { path: data.path });
|
||||
newQuery({
|
||||
initialData: fileData.text,
|
||||
});
|
||||
}
|
||||
|
||||
function createMenu() {
|
||||
return [{ text: 'Open', onClick: handleOpenSqlFile }];
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppObjectCore
|
||||
{...$$restProps}
|
||||
{data}
|
||||
icon={'img sql-file'}
|
||||
title={data.title}
|
||||
menu={createMenu}
|
||||
on:click={handleOpenSqlFile}
|
||||
>
|
||||
{#if data.description}
|
||||
<div class="info">
|
||||
{data.description}
|
||||
</div>
|
||||
{/if}
|
||||
</AppObjectCore>
|
||||
|
||||
<style>
|
||||
.info {
|
||||
margin-left: 30px;
|
||||
margin-right: 5px;
|
||||
color: var(--theme-font-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -206,7 +206,14 @@
|
||||
showModal(ConfirmModal, {
|
||||
message: `Really delete file ${data.file}?`,
|
||||
onConfirm: () => {
|
||||
apiCall('files/delete', data);
|
||||
if (data.folid && data.cntid) {
|
||||
apiCall('cloud/delete-content', {
|
||||
folid: data.folid,
|
||||
cntid: data.cntid,
|
||||
});
|
||||
} else {
|
||||
apiCall('files/delete', data);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -217,7 +224,15 @@
|
||||
label: 'New file name',
|
||||
header: 'Rename file',
|
||||
onConfirm: newFile => {
|
||||
apiCall('files/rename', { ...data, newFile });
|
||||
if (data.folid && data.cntid) {
|
||||
apiCall('cloud/rename-content', {
|
||||
folid: data.folid,
|
||||
cntid: data.cntid,
|
||||
name: newFile,
|
||||
});
|
||||
} else {
|
||||
apiCall('files/rename', { ...data, newFile });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -226,9 +241,17 @@
|
||||
showModal(InputTextModal, {
|
||||
value: data.file,
|
||||
label: 'New file name',
|
||||
header: 'Rename file',
|
||||
header: 'Copy file',
|
||||
onConfirm: newFile => {
|
||||
apiCall('files/copy', { ...data, newFile });
|
||||
if (data.folid && data.cntid) {
|
||||
apiCall('cloud/copy-file', {
|
||||
folid: data.folid,
|
||||
cntid: data.cntid,
|
||||
name: newFile,
|
||||
});
|
||||
} else {
|
||||
apiCall('files/copy', { ...data, newFile });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -236,21 +259,38 @@
|
||||
const handleDownload = () => {
|
||||
saveFileToDisk(
|
||||
async filePath => {
|
||||
await apiCall('files/export-file', {
|
||||
folder,
|
||||
file: data.file,
|
||||
filePath,
|
||||
});
|
||||
if (data.folid && data.cntid) {
|
||||
await apiCall('cloud/export-file', {
|
||||
folid: data.folid,
|
||||
cntid: data.cntid,
|
||||
filePath,
|
||||
});
|
||||
} else {
|
||||
await apiCall('files/export-file', {
|
||||
folder,
|
||||
file: data.file,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ formatLabel: handler.label, formatExtension: handler.format, defaultFileName: data.file }
|
||||
);
|
||||
};
|
||||
|
||||
async function openTab() {
|
||||
const resp = await apiCall('files/load', { folder, file: data.file, format: handler.format });
|
||||
let dataContent;
|
||||
if (data.folid && data.cntid) {
|
||||
const resp = await apiCall('cloud/get-content', {
|
||||
folid: data.folid,
|
||||
cntid: data.cntid,
|
||||
});
|
||||
dataContent = resp.content;
|
||||
} else {
|
||||
dataContent = await apiCall('files/load', { folder, file: data.file, format: handler.format });
|
||||
}
|
||||
|
||||
const connProps: any = {};
|
||||
let tooltip = undefined;
|
||||
const connProps: any = {};
|
||||
|
||||
if (handler.currentConnection) {
|
||||
const connection = _.get($currentDatabase, 'connection') || {};
|
||||
@@ -270,10 +310,12 @@
|
||||
savedFile: data.file,
|
||||
savedFolder: handler.folder,
|
||||
savedFormat: handler.format,
|
||||
savedCloudFolderId: data.folid,
|
||||
savedCloudContentId: data.cntid,
|
||||
...connProps,
|
||||
},
|
||||
},
|
||||
{ editor: resp }
|
||||
{ editor: dataContent }
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
10
packages/web/src/appobj/SubCloudItemsList.svelte
Normal file
10
packages/web/src/appobj/SubCloudItemsList.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { cloudConnectionsStore } from '../stores';
|
||||
import SubDatabaseList from './SubDatabaseList.svelte';
|
||||
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
{#if data.conid && $cloudConnectionsStore[data.conid]}
|
||||
<SubDatabaseList {...$$props} data={$cloudConnectionsStore[data.conid]} />
|
||||
{/if}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
cloudSigninTokenHolder,
|
||||
currentDatabase,
|
||||
currentTheme,
|
||||
emptyConnectionGroupNames,
|
||||
@@ -123,6 +124,27 @@ registerCommand({
|
||||
},
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'new.connectionOnCloud',
|
||||
toolbar: true,
|
||||
icon: 'img cloud-connection',
|
||||
toolbarName: 'Add connection on cloud',
|
||||
category: 'New',
|
||||
toolbarOrder: 1,
|
||||
name: 'Connection on Cloud',
|
||||
testEnabled: () => !getCurrentConfig()?.runAsPortal && !getCurrentConfig()?.storageDatabase && isProApp(),
|
||||
onClick: () => {
|
||||
openNewTab({
|
||||
title: 'New Connection on Cloud',
|
||||
icon: 'img cloud-connection',
|
||||
tabComponent: 'ConnectionTab',
|
||||
props: {
|
||||
saveOnCloud: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'new.connection.folder',
|
||||
toolbar: true,
|
||||
@@ -662,6 +684,15 @@ if (hasPermission('settings/change')) {
|
||||
});
|
||||
}
|
||||
|
||||
registerCommand({
|
||||
id: 'cloud.logout',
|
||||
category: 'Cloud',
|
||||
name: 'Logout',
|
||||
onClick: () => {
|
||||
cloudSigninTokenHolder.set(null);
|
||||
},
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'file.exit',
|
||||
category: 'File',
|
||||
|
||||
27
packages/web/src/forms/FormCloudFolderSelect.svelte
Normal file
27
packages/web/src/forms/FormCloudFolderSelect.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { useCloudContentList } from '../utility/metadataLoaders';
|
||||
|
||||
import FormSelectField from './FormSelectField.svelte';
|
||||
|
||||
export let name;
|
||||
export let requiredRoleVariants = ['read', 'write', 'admin'];
|
||||
|
||||
export let prependFolders = [];
|
||||
|
||||
const cloudContentList = useCloudContentList();
|
||||
|
||||
$: folderOptions = [
|
||||
...prependFolders.map(folder => ({
|
||||
value: folder.folid,
|
||||
label: folder.name,
|
||||
})),
|
||||
...($cloudContentList || [])
|
||||
.filter(folder => requiredRoleVariants.find(role => folder.role == role))
|
||||
.map(folder => ({
|
||||
value: folder.folid,
|
||||
label: folder.name,
|
||||
})),
|
||||
];
|
||||
</script>
|
||||
|
||||
<FormSelectField {...$$props} options={folderOptions} />
|
||||
@@ -39,6 +39,9 @@
|
||||
'icon minus-thick': 'mdi mdi-minus-thick',
|
||||
'icon invisible-box': 'mdi mdi-minus-box-outline icon-invisible',
|
||||
'icon cloud-upload': 'mdi mdi-cloud-upload',
|
||||
'icon cloud': 'mdi mdi-cloud',
|
||||
'icon cloud-public': 'mdi mdi-cloud-search',
|
||||
'icon cloud-private': 'mdi mdi-cloud-key',
|
||||
'icon import': 'mdi mdi-application-import',
|
||||
'icon export': 'mdi mdi-application-export',
|
||||
'icon new-connection': 'mdi mdi-database-plus',
|
||||
@@ -112,6 +115,9 @@
|
||||
'icon square': 'mdi mdi-square',
|
||||
'icon data-deploy': 'mdi mdi-database-settings',
|
||||
|
||||
'icon cloud-account': 'mdi mdi-account-remove-outline',
|
||||
'icon cloud-account-connected': 'mdi mdi-account-check-outline',
|
||||
|
||||
'icon edit': 'mdi mdi-pencil',
|
||||
'icon delete': 'mdi mdi-delete',
|
||||
'icon arrow-up': 'mdi mdi-arrow-up',
|
||||
@@ -264,6 +270,7 @@
|
||||
'img role': 'mdi mdi-account-group color-icon-blue',
|
||||
'img admin': 'mdi mdi-security color-icon-blue',
|
||||
'img auth': 'mdi mdi-account-key color-icon-blue',
|
||||
'img cloud-connection': 'mdi mdi-cloud-lock color-icon-blue',
|
||||
|
||||
'img add': 'mdi mdi-plus-circle color-icon-green',
|
||||
'img minus': 'mdi mdi-minus-circle color-icon-red',
|
||||
|
||||
40
packages/web/src/modals/ChooseCloudFolderModal.svelte
Normal file
40
packages/web/src/modals/ChooseCloudFolderModal.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import FormStyledButton from '../buttons/FormStyledButton.svelte';
|
||||
import FormCloudFolderSelect from '../forms/FormCloudFolderSelect.svelte';
|
||||
|
||||
import FormProvider from '../forms/FormProvider.svelte';
|
||||
import FormSubmit from '../forms/FormSubmit.svelte';
|
||||
import { useCloudContentList } from '../utility/metadataLoaders';
|
||||
import ModalBase from './ModalBase.svelte';
|
||||
import { closeCurrentModal } from './modalTools';
|
||||
|
||||
export let message = '';
|
||||
export let onConfirm;
|
||||
export let requiredRoleVariants;
|
||||
|
||||
const cloudContentList = useCloudContentList();
|
||||
</script>
|
||||
|
||||
{#if $cloudContentList}
|
||||
<FormProvider initialValues={{ cloudFolder: $cloudContentList?.find(x => x.isPrivate)?.folid }}>
|
||||
<ModalBase {...$$restProps}>
|
||||
<svelte:fragment slot="header">Choose cloud folder</svelte:fragment>
|
||||
|
||||
<div>{message}</div>
|
||||
|
||||
<FormCloudFolderSelect label="Cloud folder" name="cloudFolder" isNative {requiredRoleVariants} />
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<FormSubmit
|
||||
value="OK"
|
||||
on:click={e => {
|
||||
closeCurrentModal();
|
||||
console.log('onConfirm', e.detail);
|
||||
onConfirm(e.detail.cloudFolder);
|
||||
}}
|
||||
/>
|
||||
<FormStyledButton type="button" value="Close" on:click={closeCurrentModal} />
|
||||
</svelte:fragment>
|
||||
</ModalBase>
|
||||
</FormProvider>
|
||||
{/if}
|
||||
71
packages/web/src/modals/LicenseLimitMessageModal.svelte
Normal file
71
packages/web/src/modals/LicenseLimitMessageModal.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script>
|
||||
import FormStyledButton from '../buttons/FormStyledButton.svelte';
|
||||
import FormProvider from '../forms/FormProvider.svelte';
|
||||
import FormSubmit from '../forms/FormSubmit.svelte';
|
||||
import FontIcon from '../icons/FontIcon.svelte';
|
||||
import { isProApp } from '../utility/proTools';
|
||||
import { openWebLink } from '../utility/simpleTools';
|
||||
|
||||
import ModalBase from './ModalBase.svelte';
|
||||
import { closeCurrentModal } from './modalTools';
|
||||
|
||||
export let message;
|
||||
export let licenseLimits;
|
||||
</script>
|
||||
|
||||
<FormProvider>
|
||||
<ModalBase {...$$restProps}>
|
||||
<div slot="header">License limit error</div>
|
||||
|
||||
<div class="wrapper">
|
||||
<div class="icon">
|
||||
<FontIcon icon="img error" />
|
||||
</div>
|
||||
<div data-testid="LicenseLimitMessageModal_message">
|
||||
<p>
|
||||
Cloud operation ended with error:<br />
|
||||
{message}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
This is a limitation of the free version of DbGate. To continue using cloud operations, please {#if !isProApp()}download
|
||||
and{/if} purchase DbGate Premium.
|
||||
</p>
|
||||
<p>Free version limit:</p>
|
||||
<ul>
|
||||
{#each licenseLimits || [] as limit}
|
||||
<li>{limit}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div slot="footer">
|
||||
<FormSubmit value="Close" on:click={closeCurrentModal} data-testid="LicenseLimitMessageModal_closeButton" />
|
||||
{#if !isProApp()}
|
||||
<FormStyledButton
|
||||
value="Download DbGate Premium"
|
||||
on:click={() => openWebLink('https://dbgate.io/download/')}
|
||||
skipWidth
|
||||
/>
|
||||
{/if}
|
||||
<FormStyledButton
|
||||
value="Purchase DbGate Premium"
|
||||
on:click={() => openWebLink('https://dbgate.io/purchase/premium/')}
|
||||
skipWidth
|
||||
/>
|
||||
</div>
|
||||
</ModalBase>
|
||||
</FormProvider>
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 10px;
|
||||
font-size: 20pt;
|
||||
padding-top: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,18 @@
|
||||
<script lang="ts">
|
||||
import FormStyledButton from '../buttons/FormStyledButton.svelte';
|
||||
|
||||
import FormProvider from '../forms/FormProvider.svelte';
|
||||
import FormProviderCore from '../forms/FormProviderCore.svelte';
|
||||
import FormSubmit from '../forms/FormSubmit.svelte';
|
||||
import FormTextField from '../forms/FormTextField.svelte';
|
||||
import { cloudSigninTokenHolder } from '../stores';
|
||||
import { _t } from '../translations';
|
||||
import { apiCall } from '../utility/api';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import getElectron from '../utility/getElectron';
|
||||
import ModalBase from './ModalBase.svelte';
|
||||
import { closeCurrentModal } from './modalTools';
|
||||
import { closeCurrentModal, showModal } from './modalTools';
|
||||
import FormCloudFolderSelect from '../forms/FormCloudFolderSelect.svelte';
|
||||
|
||||
export let data;
|
||||
export let name;
|
||||
@@ -18,19 +21,48 @@
|
||||
export let fileExtension;
|
||||
export let filePath;
|
||||
export let onSave = undefined;
|
||||
export let folid;
|
||||
// export let cntid;
|
||||
|
||||
const values = writable({ name, cloudFolder: folid ?? '__local' });
|
||||
|
||||
const electron = getElectron();
|
||||
|
||||
const handleSubmit = async e => {
|
||||
const { name } = e.detail;
|
||||
await apiCall('files/save', { folder, file: name, data, format });
|
||||
closeCurrentModal();
|
||||
if (onSave) {
|
||||
onSave(name, {
|
||||
savedFile: name,
|
||||
savedFolder: folder,
|
||||
savedFilePath: null,
|
||||
const { name, cloudFolder } = e.detail;
|
||||
if (cloudFolder === '__local') {
|
||||
await apiCall('files/save', { folder, file: name, data, format });
|
||||
closeCurrentModal();
|
||||
if (onSave) {
|
||||
onSave(name, {
|
||||
savedFile: name,
|
||||
savedFolder: folder,
|
||||
savedFilePath: null,
|
||||
savedCloudFolderId: null,
|
||||
savedCloudContentId: null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const resp = await apiCall('cloud/save-file', {
|
||||
folid: cloudFolder,
|
||||
fileName: name,
|
||||
data,
|
||||
contentFolder: folder,
|
||||
format,
|
||||
// cntid,
|
||||
});
|
||||
if (resp.cntid) {
|
||||
closeCurrentModal();
|
||||
if (onSave) {
|
||||
onSave(name, {
|
||||
savedFile: name,
|
||||
savedFolder: folder,
|
||||
savedFilePath: null,
|
||||
savedCloudFolderId: cloudFolder,
|
||||
// savedCloudContentId: resp.cntid,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -47,15 +79,32 @@
|
||||
savedFile: null,
|
||||
savedFolder: null,
|
||||
savedFilePath: filePath,
|
||||
savedCloudFolderId: null,
|
||||
savedCloudContentId: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormProvider initialValues={{ name }}>
|
||||
<FormProviderCore {values}>
|
||||
<ModalBase {...$$restProps}>
|
||||
<svelte:fragment slot="header">Save file</svelte:fragment>
|
||||
<FormTextField label="File name" name="name" focused />
|
||||
{#if $cloudSigninTokenHolder}
|
||||
<FormCloudFolderSelect
|
||||
label="Choose cloud folder"
|
||||
name="cloudFolder"
|
||||
isNative
|
||||
requiredRoleVariants={['write', 'admin']}
|
||||
prependFolders={[
|
||||
{
|
||||
folid: '__local',
|
||||
name: "Local folder (don't store on cloud)",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<FormSubmit value={_t('common.save', { defaultMessage: 'Save' })} on:click={handleSubmit} />
|
||||
{#if electron}
|
||||
@@ -79,4 +128,4 @@
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</ModalBase>
|
||||
</FormProvider>
|
||||
</FormProviderCore>
|
||||
|
||||
@@ -182,6 +182,10 @@ export const focusedConnectionOrDatabase = writable<{ conid: string; database?:
|
||||
|
||||
export const focusedTreeDbKey = writable<{ key: string; root: string; type: string; text: string }>(null);
|
||||
|
||||
export const cloudSigninTokenHolder = writableSettingsValue(null, 'cloudSigninTokenHolder');
|
||||
|
||||
export const cloudConnectionsStore = writable({});
|
||||
|
||||
export const DEFAULT_OBJECT_SEARCH_SETTINGS = {
|
||||
pureName: true,
|
||||
schemaName: false,
|
||||
@@ -453,4 +457,10 @@ focusedTreeDbKey.subscribe(value => {
|
||||
});
|
||||
export const getFocusedTreeDbKey = () => focusedTreeDbKeyValue;
|
||||
|
||||
let cloudConnectionsStoreValue = {};
|
||||
cloudConnectionsStore.subscribe(value => {
|
||||
cloudConnectionsStoreValue = value;
|
||||
});
|
||||
export const getCloudConnectionsStore = () => cloudConnectionsStoreValue;
|
||||
|
||||
window['__changeCurrentTheme'] = theme => currentTheme.set(theme);
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
import ConnectionAdvancedDriverFields from '../settings/ConnectionAdvancedDriverFields.svelte';
|
||||
import DatabaseLoginModal from '../modals/DatabaseLoginModal.svelte';
|
||||
import { _t } from '../translations';
|
||||
import ChooseCloudFolderModal from '../modals/ChooseCloudFolderModal.svelte';
|
||||
|
||||
export let connection;
|
||||
export let tabid;
|
||||
@@ -44,6 +45,7 @@
|
||||
export let inlineTabs = false;
|
||||
|
||||
export let onlyTestButton;
|
||||
export let saveOnCloud = false;
|
||||
|
||||
let isTesting;
|
||||
let sqlConnectResult;
|
||||
@@ -157,43 +159,96 @@
|
||||
$: currentConnection = getCurrentConnectionCore($values, driver);
|
||||
|
||||
async function handleSave() {
|
||||
let connection = getCurrentConnection();
|
||||
connection = {
|
||||
...connection,
|
||||
unsaved: false,
|
||||
};
|
||||
const saved = await apiCall('connections/save', connection);
|
||||
$values = {
|
||||
...$values,
|
||||
_id: saved._id,
|
||||
unsaved: false,
|
||||
};
|
||||
changeTab(tabid, tab => ({
|
||||
...tab,
|
||||
title: getConnectionLabel(saved),
|
||||
props: {
|
||||
...tab.props,
|
||||
conid: saved._id,
|
||||
},
|
||||
}));
|
||||
showSnackbarSuccess('Connection saved');
|
||||
if (saveOnCloud && !getCurrentConnection()?._id) {
|
||||
showModal(ChooseCloudFolderModal, {
|
||||
requiredRoleVariants: ['write', 'admin'],
|
||||
message: 'Choose cloud folder to saved connection',
|
||||
onConfirm: async folid => {
|
||||
let connection = getCurrentConnection();
|
||||
const saved = await apiCall('cloud/save-connection', { folid, connection });
|
||||
if (saved?._id) {
|
||||
$values = {
|
||||
...$values,
|
||||
_id: saved._id,
|
||||
unsaved: false,
|
||||
};
|
||||
changeTab(tabid, tab => ({
|
||||
...tab,
|
||||
title: getConnectionLabel(saved),
|
||||
props: {
|
||||
...tab.props,
|
||||
conid: saved._id,
|
||||
},
|
||||
}));
|
||||
showSnackbarSuccess('Connection saved');
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
// @ts-ignore
|
||||
getCurrentConnection()?._id?.startsWith('cloud://')
|
||||
) {
|
||||
let connection = getCurrentConnection();
|
||||
const resp = await apiCall('cloud/save-connection', { connection });
|
||||
if (resp?._id) {
|
||||
showSnackbarSuccess('Connection saved');
|
||||
changeTab(tabid, tab => ({
|
||||
...tab,
|
||||
title: getConnectionLabel(connection),
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
let connection = getCurrentConnection();
|
||||
connection = {
|
||||
...connection,
|
||||
unsaved: false,
|
||||
};
|
||||
const saved = await apiCall('connections/save', connection);
|
||||
$values = {
|
||||
...$values,
|
||||
_id: saved._id,
|
||||
unsaved: false,
|
||||
};
|
||||
changeTab(tabid, tab => ({
|
||||
...tab,
|
||||
title: getConnectionLabel(saved),
|
||||
props: {
|
||||
...tab.props,
|
||||
conid: saved._id,
|
||||
},
|
||||
}));
|
||||
showSnackbarSuccess('Connection saved');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConnect() {
|
||||
let connection = getCurrentConnection();
|
||||
if (!connection._id) {
|
||||
connection = {
|
||||
...connection,
|
||||
unsaved: true,
|
||||
|
||||
if (
|
||||
// @ts-ignore
|
||||
connection?._id?.startsWith('cloud://')
|
||||
) {
|
||||
const saved = await apiCall('cloud/save-connection', { connection });
|
||||
changeTab(tabid, tab => ({
|
||||
...tab,
|
||||
title: getConnectionLabel(connection),
|
||||
}));
|
||||
openConnection(saved);
|
||||
} else {
|
||||
if (!connection._id) {
|
||||
connection = {
|
||||
...connection,
|
||||
unsaved: true,
|
||||
};
|
||||
}
|
||||
const saved = await apiCall('connections/save', connection);
|
||||
$values = {
|
||||
...$values,
|
||||
unsaved: connection.unsaved,
|
||||
_id: saved._id,
|
||||
};
|
||||
openConnection(saved);
|
||||
}
|
||||
const saved = await apiCall('connections/save', connection);
|
||||
$values = {
|
||||
...$values,
|
||||
unsaved: connection.unsaved,
|
||||
_id: saved._id,
|
||||
};
|
||||
openConnection(saved);
|
||||
// closeMultipleTabs(x => x.tabid == tabid, true);
|
||||
}
|
||||
|
||||
@@ -287,7 +342,9 @@
|
||||
{:else if isConnected}
|
||||
<FormButton value="Disconnect" on:click={handleDisconnect} data-testid="ConnectionTab_buttonDisconnect" />
|
||||
{:else}
|
||||
<FormButton value="Connect" on:click={handleConnect} data-testid="ConnectionTab_buttonConnect" />
|
||||
{#if $values._id || !saveOnCloud}
|
||||
<FormButton value="Connect" on:click={handleConnect} data-testid="ConnectionTab_buttonConnect" />
|
||||
{/if}
|
||||
{#if isTesting}
|
||||
<FormButton value="Cancel test" on:click={handleCancelTest} />
|
||||
{:else}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { batchDispatchCacheTriggers, dispatchCacheChange } from './cache';
|
||||
import { isAdminPage, isOneOfPage } from './pageDefs';
|
||||
import { openWebLink } from './simpleTools';
|
||||
import { serializeJsTypesReplacer } from 'dbgate-tools';
|
||||
import { cloudSigninTokenHolder } from '../stores';
|
||||
import LicenseLimitMessageModal from '../modals/LicenseLimitMessageModal.svelte';
|
||||
|
||||
export const strmid = uuidv1();
|
||||
|
||||
@@ -120,7 +122,14 @@ async function processApiResponse(route, args, resp) {
|
||||
// missingCredentials: true,
|
||||
// };
|
||||
} else if (resp?.apiErrorMessage) {
|
||||
showSnackbarError('API error:' + resp?.apiErrorMessage);
|
||||
if (resp?.apiErrorIsLicenseLimit) {
|
||||
showModal(LicenseLimitMessageModal, {
|
||||
message: resp.apiErrorMessage,
|
||||
licenseLimits: resp.apiErrorLimitedLicenseLimits,
|
||||
});
|
||||
} else {
|
||||
showSnackbarError('API error:' + resp?.apiErrorMessage);
|
||||
}
|
||||
return {
|
||||
errorMessage: resp.apiErrorMessage,
|
||||
};
|
||||
@@ -279,6 +288,13 @@ export function installNewVolatileConnectionListener() {
|
||||
});
|
||||
}
|
||||
|
||||
export function installNewCloudTokenListener() {
|
||||
apiOn('got-cloud-token', async tokenHolder => {
|
||||
console.log('HOLDER', tokenHolder);
|
||||
cloudSigninTokenHolder.set(tokenHolder);
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthCategory(config) {
|
||||
if (config.isBasicAuth) {
|
||||
return 'basic';
|
||||
@@ -292,6 +308,15 @@ export function getAuthCategory(config) {
|
||||
return 'token';
|
||||
}
|
||||
|
||||
export function refreshPublicCloudFiles() {
|
||||
if (sessionStorage.getItem('publicCloudFilesLoaded')) {
|
||||
return;
|
||||
}
|
||||
|
||||
apiCall('cloud/refresh-public-files');
|
||||
sessionStorage.setItem('publicCloudFilesLoaded', 'true');
|
||||
}
|
||||
|
||||
function enableApiLog() {
|
||||
apiLogging = true;
|
||||
console.log('API loggin enabled');
|
||||
|
||||
57
packages/web/src/utility/cloudListeners.ts
Normal file
57
packages/web/src/utility/cloudListeners.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { derived } from 'svelte/store';
|
||||
import {
|
||||
cloudConnectionsStore,
|
||||
currentDatabase,
|
||||
getCloudConnectionsStore,
|
||||
openedConnections,
|
||||
openedSingleDatabaseConnections,
|
||||
} from '../stores';
|
||||
import { apiCall, apiOn } from './api';
|
||||
import _ from 'lodash';
|
||||
|
||||
export const possibleCloudConnectionSources = derived(
|
||||
[currentDatabase, openedSingleDatabaseConnections, openedConnections],
|
||||
([$currentDatabase, $openedSingleDatabaseConnections, $openedConnections]) => {
|
||||
const conids = new Set<string>();
|
||||
if ($currentDatabase?.connection?._id) {
|
||||
conids.add($currentDatabase.connection._id);
|
||||
}
|
||||
$openedSingleDatabaseConnections.forEach(x => conids.add(x));
|
||||
$openedConnections.forEach(x => conids.add(x));
|
||||
return Array.from(conids).filter(x => x?.startsWith('cloud://'));
|
||||
}
|
||||
);
|
||||
|
||||
async function loadCloudConnection(conid) {
|
||||
const conn = await apiCall('connections/get', { conid });
|
||||
cloudConnectionsStore.update(store => ({
|
||||
...store,
|
||||
[conid]: conn,
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureCloudConnectionsLoaded(...conids) {
|
||||
const conns = getCloudConnectionsStore();
|
||||
|
||||
cloudConnectionsStore.update(store => _.pick(store, conids));
|
||||
|
||||
conids.forEach(conid => {
|
||||
if (!conns[conid]) {
|
||||
loadCloudConnection(conid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installCloudListeners() {
|
||||
possibleCloudConnectionSources.subscribe(conids => {
|
||||
ensureCloudConnectionsLoaded(...conids);
|
||||
});
|
||||
|
||||
apiOn('cloud-content-updated', () => {
|
||||
const conids = Object.keys(getCloudConnectionsStore());
|
||||
cloudConnectionsStore.set({});
|
||||
for (const conn of conids) {
|
||||
loadCloudConnection(conn);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -166,6 +166,17 @@ const authTypesLoader = ({ engine }) => ({
|
||||
errorValue: null,
|
||||
});
|
||||
|
||||
const publicCloudFilesLoader = () => ({
|
||||
url: 'cloud/public-files',
|
||||
params: {},
|
||||
reloadTrigger: { key: `public-cloud-changed` },
|
||||
});
|
||||
const cloudContentListLoader = () => ({
|
||||
url: 'cloud/content-list',
|
||||
params: {},
|
||||
reloadTrigger: { key: `cloud-content-changed` },
|
||||
});
|
||||
|
||||
async function getCore(loader, args) {
|
||||
const { url, params, reloadTrigger, transform, onLoaded, errorValue } = loader(args);
|
||||
const key = stableStringify({ url, ...params });
|
||||
@@ -456,3 +467,17 @@ export function getSchemaList(args) {
|
||||
export function useSchemaList(args) {
|
||||
return useCore(schemaListLoader, args);
|
||||
}
|
||||
|
||||
export function getPublicCloudFiles(args) {
|
||||
return getCore(publicCloudFilesLoader, args);
|
||||
}
|
||||
export function usePublicCloudFiles(args = {}) {
|
||||
return useCore(publicCloudFilesLoader, args);
|
||||
}
|
||||
|
||||
export function getCloudContentList(args) {
|
||||
return getCore(cloudContentListLoader, args);
|
||||
}
|
||||
export function useCloudContentList(args = {}) {
|
||||
return useCore(cloudContentListLoader, args);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ async function openSavedElectronFile(filePath, parsed, folder) {
|
||||
props: {
|
||||
savedFile: null,
|
||||
savedFolder: null,
|
||||
savedCloudFolderId: null,
|
||||
savedCloudContentId: null,
|
||||
savedFilePath: filePath,
|
||||
savedFormat: handler.format,
|
||||
...connProps,
|
||||
|
||||
@@ -30,7 +30,8 @@ export default async function openNewTab(newTab, initialData: any = undefined, o
|
||||
};
|
||||
}
|
||||
|
||||
const { savedFile, savedFolder, savedFilePath, conid, database } = newTab.props || {};
|
||||
const { savedFile, savedFolder, savedFilePath, savedCloudFolderId, savedCloudContentId, conid, database } =
|
||||
newTab.props || {};
|
||||
|
||||
if (conid && database) {
|
||||
const connection = await getConnectionInfo({ conid });
|
||||
@@ -49,7 +50,9 @@ export default async function openNewTab(newTab, initialData: any = undefined, o
|
||||
x.closedTime == null &&
|
||||
x.props.savedFile == savedFile &&
|
||||
x.props.savedFolder == savedFolder &&
|
||||
x.props.savedFilePath == savedFilePath
|
||||
x.props.savedFilePath == savedFilePath &&
|
||||
x.props.savedCloudFolderId == savedCloudFolderId &&
|
||||
x.props.savedCloudContentId == savedCloudContentId
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,31 @@ export default async function saveTabFile(editor, saveMode, folder, format, file
|
||||
const tabs = get(openedTabs);
|
||||
const tabid = editor.activator.tabid;
|
||||
const data = editor.getData();
|
||||
const { savedFile, savedFilePath, savedFolder } = tabs.find(x => x.tabid == tabid).props || {};
|
||||
const { savedFile, savedFilePath, savedFolder, savedCloudFolderId, savedCloudContentId } =
|
||||
tabs.find(x => x.tabid == tabid).props || {};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (savedFile) {
|
||||
await apiCall('files/save', { folder: savedFolder || folder, file: savedFile, data, format });
|
||||
if (savedCloudFolderId && savedCloudContentId) {
|
||||
const resp = await apiCall('cloud/save-file', {
|
||||
folid: savedCloudFolderId,
|
||||
fileName: savedFile,
|
||||
data,
|
||||
contentFolder: folder,
|
||||
format,
|
||||
cntid: savedCloudContentId,
|
||||
});
|
||||
if (resp.cntid) {
|
||||
markTabSaved(tabid);
|
||||
}
|
||||
} else {
|
||||
if (savedFile) {
|
||||
await apiCall('files/save', { folder: savedFolder || folder, file: savedFile, data, format });
|
||||
}
|
||||
if (savedFilePath) {
|
||||
await apiCall('files/save-as', { filePath: savedFilePath, data, format });
|
||||
}
|
||||
markTabSaved(tabid);
|
||||
}
|
||||
if (savedFilePath) {
|
||||
await apiCall('files/save-as', { filePath: savedFilePath, data, format });
|
||||
}
|
||||
markTabSaved(tabid);
|
||||
};
|
||||
|
||||
const onSave = (title, newProps) => {
|
||||
@@ -60,6 +75,8 @@ export default async function saveTabFile(editor, saveMode, folder, format, file
|
||||
savedFile: null,
|
||||
savedFolder: null,
|
||||
savedFilePath: file,
|
||||
savedCloudFolderId: null,
|
||||
savedCloudContentId: null,
|
||||
});
|
||||
}
|
||||
} else if ((savedFile || savedFilePath) && saveMode == 'save') {
|
||||
@@ -73,6 +90,8 @@ export default async function saveTabFile(editor, saveMode, folder, format, file
|
||||
name: savedFile || 'newFile',
|
||||
filePath: savedFilePath,
|
||||
onSave,
|
||||
folid: savedCloudFolderId,
|
||||
// cntid: savedCloudContentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import getElectron from './getElectron';
|
||||
|
||||
export function openWebLink(href) {
|
||||
export function openWebLink(href, usePopup = false) {
|
||||
const electron = getElectron();
|
||||
|
||||
if (electron) {
|
||||
electron.send('open-link', href);
|
||||
} else {
|
||||
window.open(href, '_blank');
|
||||
if (usePopup) {
|
||||
const w = 500;
|
||||
const h = 650;
|
||||
|
||||
const dualScreenLeft = window.screenLeft ?? window.screenX; // X of parent
|
||||
const dualScreenTop = window.screenTop ?? window.screenY; // Y of parent
|
||||
|
||||
// 2. How big is the parent window?
|
||||
const parentWidth = window.outerWidth;
|
||||
const parentHeight = window.outerHeight;
|
||||
|
||||
// 3. Centre the popup inside that rectangle
|
||||
const left = dualScreenLeft + (parentWidth - w) / 2;
|
||||
const top = dualScreenTop + (parentHeight - h) / 2;
|
||||
|
||||
const features = [
|
||||
`width=${w}`,
|
||||
`height=${h}`,
|
||||
`left=${left}`,
|
||||
`top=${top}`,
|
||||
'scrollbars=yes',
|
||||
'resizable=yes',
|
||||
'noopener',
|
||||
'noreferrer',
|
||||
];
|
||||
|
||||
window.open(href, 'dbgateCloudLoginPopup', features.join(','));
|
||||
} else {
|
||||
window.open(href, '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
isExpandable={data => $openedConnections.includes(data._id) && !data.singleDatabase}
|
||||
{filter}
|
||||
passProps={{
|
||||
...passProps,
|
||||
connectionColorFactory: $connectionColorFactory,
|
||||
showPinnedInsteadOfUnpin: true,
|
||||
searchSettings: $connectionAppObjectSearchSettings,
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { findEngineDriver } from 'dbgate-tools';
|
||||
import { currentDatabase, extensions, pinnedDatabases, pinnedTables } from '../stores';
|
||||
import { useConfig, useConnectionInfo } from '../utility/metadataLoaders';
|
||||
import { useCloudContentList, useConfig, useConnectionInfo } from '../utility/metadataLoaders';
|
||||
|
||||
import ConnectionList from './ConnectionList.svelte';
|
||||
import PinnedObjectsList from './PinnedObjectsList.svelte';
|
||||
import ErrorInfo from '../elements/ErrorInfo.svelte';
|
||||
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
|
||||
|
||||
import WidgetColumnBar from './WidgetColumnBar.svelte';
|
||||
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
|
||||
import SqlObjectList from './SqlObjectList.svelte';
|
||||
import DbKeysTree from './DbKeysTree.svelte';
|
||||
import SingleConnectionDatabaseList from './SingleConnectionDatabaseList.svelte';
|
||||
import _ from 'lodash';
|
||||
import FocusedConnectionInfoWidget from './FocusedConnectionInfoWidget.svelte';
|
||||
import { _t } from '../translations';
|
||||
import DatabaseWidgetDetailContent from './DatabaseWidgetDetailContent.svelte';
|
||||
|
||||
export let hidden = false;
|
||||
let domSqlObjectList = null;
|
||||
|
||||
$: conid = $currentDatabase?.connection?._id;
|
||||
$: connection = useConnectionInfo({ conid });
|
||||
$: driver = findEngineDriver($connection, $extensions);
|
||||
$: config = useConfig();
|
||||
$: singleDatabase = $currentDatabase?.connection?.singleDatabase;
|
||||
$: database = $currentDatabase?.name;
|
||||
$: cloudContentList = useCloudContentList();
|
||||
</script>
|
||||
|
||||
<WidgetColumnBar {hidden}>
|
||||
@@ -45,69 +34,14 @@
|
||||
height="35%"
|
||||
storageName="connectionsWidget"
|
||||
>
|
||||
<ConnectionList passProps={{ onFocusSqlObjectList: () => domSqlObjectList.focus() }} />
|
||||
<ConnectionList
|
||||
passProps={{
|
||||
onFocusSqlObjectList: () => domSqlObjectList.focus(),
|
||||
cloudContentList: $cloudContentList,
|
||||
}}
|
||||
/>
|
||||
</WidgetColumnBarItem>
|
||||
{/if}
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.pinned', { defaultMessage: 'Pinned' })}
|
||||
name="pinned"
|
||||
height="15%"
|
||||
storageName="pinnedItemsWidget"
|
||||
skip={!_.compact($pinnedDatabases).length &&
|
||||
!$pinnedTables.some(x => x && x.conid == conid && x.database == $currentDatabase?.name)}
|
||||
>
|
||||
<PinnedObjectsList />
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={driver?.databaseEngineTypes?.includes('document')
|
||||
? (driver?.collectionPluralLabel ?? 'Collections/containers')
|
||||
: _t('widget.tablesViewsFunctions', { defaultMessage: 'Tables, views, functions' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={!(
|
||||
conid &&
|
||||
(database || singleDatabase) &&
|
||||
(driver?.databaseEngineTypes?.includes('sql') || driver?.databaseEngineTypes?.includes('document'))
|
||||
)}
|
||||
>
|
||||
<SqlObjectList {conid} {database} bind:this={domSqlObjectList} />
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.keys', { defaultMessage: 'Keys' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={!(conid && (database || singleDatabase) && driver?.databaseEngineTypes?.includes('keyvalue'))}
|
||||
>
|
||||
<DbKeysTree {conid} {database} treeKeySeparator={$connection?.treeKeySeparator || ':'} />
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={conid && (database || singleDatabase)}
|
||||
>
|
||||
<WidgetsInnerContainer>
|
||||
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
|
||||
|
||||
<ErrorInfo message="Database not selected" icon="img alert" />
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={!(conid && (database || singleDatabase) && !driver)}
|
||||
>
|
||||
<WidgetsInnerContainer>
|
||||
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
|
||||
|
||||
<ErrorInfo
|
||||
message={_t('error.driverNotFound', { defaultMessage: 'Invalid database connection, driver not found' })}
|
||||
/>
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
<DatabaseWidgetDetailContent bind:domSqlObjectList showCloudConnection={false} />
|
||||
</WidgetColumnBar>
|
||||
|
||||
135
packages/web/src/widgets/DatabaseWidgetDetailContent.svelte
Normal file
135
packages/web/src/widgets/DatabaseWidgetDetailContent.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { findEngineDriver } from 'dbgate-tools';
|
||||
import { currentDatabase, extensions, pinnedDatabases, pinnedTables, selectedWidget } from '../stores';
|
||||
import { useConnectionInfo } from '../utility/metadataLoaders';
|
||||
|
||||
import PinnedObjectsList from './PinnedObjectsList.svelte';
|
||||
import ErrorInfo from '../elements/ErrorInfo.svelte';
|
||||
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
|
||||
|
||||
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
|
||||
import SqlObjectList from './SqlObjectList.svelte';
|
||||
import DbKeysTree from './DbKeysTree.svelte';
|
||||
import _ from 'lodash';
|
||||
import FocusedConnectionInfoWidget from './FocusedConnectionInfoWidget.svelte';
|
||||
import { _t } from '../translations';
|
||||
import FormStyledButton from '../buttons/FormStyledButton.svelte';
|
||||
|
||||
export let domSqlObjectList = null;
|
||||
export let showCloudConnection;
|
||||
|
||||
$: conid = $currentDatabase?.connection?._id;
|
||||
$: connection = useConnectionInfo({ conid });
|
||||
$: driver = findEngineDriver($connection, $extensions);
|
||||
$: singleDatabase = $currentDatabase?.connection?.singleDatabase;
|
||||
$: database = $currentDatabase?.name;
|
||||
|
||||
$: correctCloudStatus =
|
||||
!conid ||
|
||||
(!showCloudConnection && !conid?.startsWith('cloud://')) ||
|
||||
(showCloudConnection && conid?.startsWith('cloud://'));
|
||||
</script>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.pinned', { defaultMessage: 'Pinned' })}
|
||||
name="pinned"
|
||||
height="15%"
|
||||
storageName="pinnedItemsWidget"
|
||||
skip={!_.compact($pinnedDatabases).length &&
|
||||
!$pinnedTables.some(x => x && x.conid == conid && x.database == $currentDatabase?.name)}
|
||||
positiveCondition={correctCloudStatus}
|
||||
>
|
||||
<PinnedObjectsList />
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={driver?.databaseEngineTypes?.includes('document')
|
||||
? (driver?.collectionPluralLabel ?? 'Collections/containers')
|
||||
: _t('widget.tablesViewsFunctions', { defaultMessage: 'Tables, views, functions' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={!(
|
||||
conid &&
|
||||
(database || singleDatabase) &&
|
||||
(driver?.databaseEngineTypes?.includes('sql') || driver?.databaseEngineTypes?.includes('document'))
|
||||
)}
|
||||
positiveCondition={correctCloudStatus}
|
||||
>
|
||||
<SqlObjectList {conid} {database} bind:this={domSqlObjectList} />
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.keys', { defaultMessage: 'Keys' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={!(conid && (database || singleDatabase) && driver?.databaseEngineTypes?.includes('keyvalue'))}
|
||||
positiveCondition={correctCloudStatus}
|
||||
>
|
||||
<DbKeysTree {conid} {database} treeKeySeparator={$connection?.treeKeySeparator || ':'} />
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={conid && (database || singleDatabase)}
|
||||
positiveCondition={correctCloudStatus}
|
||||
>
|
||||
<WidgetsInnerContainer>
|
||||
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
|
||||
|
||||
<ErrorInfo message="Database not selected" icon="img alert" />
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
|
||||
name="dbObjects"
|
||||
storageName="dbObjectsWidget"
|
||||
skip={!(conid && (database || singleDatabase) && !driver)}
|
||||
positiveCondition={correctCloudStatus}
|
||||
>
|
||||
<WidgetsInnerContainer>
|
||||
<FocusedConnectionInfoWidget {conid} {database} connection={$connection} />
|
||||
|
||||
<ErrorInfo
|
||||
message={_t('error.driverNotFound', { defaultMessage: 'Invalid database connection, driver not found' })}
|
||||
/>
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<WidgetColumnBarItem
|
||||
title={_t('widget.databaseContent', { defaultMessage: 'Database content' })}
|
||||
name="incorrectClaudStatus"
|
||||
height="15%"
|
||||
storageName="incorrectClaudStatusWidget"
|
||||
skip={correctCloudStatus}
|
||||
>
|
||||
<WidgetsInnerContainer>
|
||||
<ErrorInfo
|
||||
message={showCloudConnection
|
||||
? _t('error.selectedNotCloudConnection', { defaultMessage: 'Selected connection is not from DbGate cloud' })
|
||||
: _t('error.selectedCloudConnection', { defaultMessage: 'Selected connection is from DbGate cloud' })}
|
||||
/>
|
||||
|
||||
<div class="incorrect-cloud-status-wrapper">
|
||||
<FormStyledButton
|
||||
value={`Show database content`}
|
||||
skipWidth
|
||||
on:click={() => {
|
||||
$selectedWidget = conid?.startsWith('cloud://') ? 'cloud-private' : 'database';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<style>
|
||||
.incorrect-cloud-status-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
249
packages/web/src/widgets/PrivateCloudWidget.svelte
Normal file
249
packages/web/src/widgets/PrivateCloudWidget.svelte
Normal file
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import WidgetColumnBar from './WidgetColumnBar.svelte';
|
||||
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
|
||||
|
||||
import AppObjectList from '../appobj/AppObjectList.svelte';
|
||||
import * as cloudContentAppObject from '../appobj/CloudContentAppObject.svelte';
|
||||
import { useCloudContentList, usePublicCloudFiles, useServerStatus } from '../utility/metadataLoaders';
|
||||
import { _t } from '../translations';
|
||||
|
||||
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
|
||||
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
|
||||
import SearchInput from '../elements/SearchInput.svelte';
|
||||
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
|
||||
import InlineButton from '../buttons/InlineButton.svelte';
|
||||
import FontIcon from '../icons/FontIcon.svelte';
|
||||
import { apiCall } from '../utility/api';
|
||||
import {
|
||||
cloudConnectionsStore,
|
||||
cloudSigninTokenHolder,
|
||||
currentDatabase,
|
||||
expandedConnections,
|
||||
openedConnections,
|
||||
openedSingleDatabaseConnections,
|
||||
} from '../stores';
|
||||
import _ from 'lodash';
|
||||
import { plusExpandIcon } from '../icons/expandIcons';
|
||||
import { volatileConnectionMapStore } from '../utility/api';
|
||||
import SubCloudItemsList from '../appobj/SubCloudItemsList.svelte';
|
||||
import DatabaseWidgetDetailContent from './DatabaseWidgetDetailContent.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import DropDownButton from '../buttons/DropDownButton.svelte';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import InputTextModal from '../modals/InputTextModal.svelte';
|
||||
import ConfirmModal from '../modals/ConfirmModal.svelte';
|
||||
import { showSnackbarInfo } from '../utility/snackbar';
|
||||
|
||||
let filter = '';
|
||||
let domSqlObjectList = null;
|
||||
|
||||
const cloudContentList = useCloudContentList();
|
||||
const serverStatus = useServerStatus();
|
||||
|
||||
$: emptyCloudContent = ($cloudContentList || []).filter(x => !x.items?.length).map(x => x.folid);
|
||||
$: cloudContentFlat = _.sortBy(
|
||||
($cloudContentList || [])
|
||||
.flatMap(fld => fld.items ?? [])
|
||||
.map(data => {
|
||||
if (data.type == 'connection') {
|
||||
const conid = `cloud://${data.folid}/${data.cntid}`;
|
||||
const status = $serverStatus ? $serverStatus[$volatileConnectionMapStore[conid] || conid] : undefined;
|
||||
|
||||
return {
|
||||
...data,
|
||||
conid,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}),
|
||||
'name'
|
||||
);
|
||||
$: contentGroupMap = _.keyBy($cloudContentList || [], x => x.folid);
|
||||
|
||||
// $: console.log('cloudContentFlat', cloudContentFlat);
|
||||
// $: console.log('contentGroupMap', contentGroupMap);
|
||||
|
||||
async function handleRefreshContent() {
|
||||
await apiCall('cloud/refresh-content');
|
||||
}
|
||||
|
||||
// async function loadCloudConnection(conid) {
|
||||
// const conn = await apiCall('connections/get', { conid });
|
||||
// $cloudConnectionsStore = {
|
||||
// ...$cloudConnectionsStore,
|
||||
// [conid]: conn,
|
||||
// };
|
||||
// }
|
||||
|
||||
// function ensureCloudConnectionsLoaded(...conids) {
|
||||
// _.uniq(conids).forEach(conid => {
|
||||
// if (conid?.startsWith('cloud://') && !$cloudConnectionsStore[conid]) {
|
||||
// loadCloudConnection(conid);
|
||||
// }
|
||||
// });
|
||||
// }
|
||||
|
||||
// $: ensureCloudConnectionsLoaded(
|
||||
// $currentDatabase?.connection?._id,
|
||||
// ...$openedSingleDatabaseConnections,
|
||||
// ...$openedConnections
|
||||
// );
|
||||
|
||||
// onMount(() => {
|
||||
// const currentConid = $currentDatabase?.connection?._id;
|
||||
// if (currentConid?.startsWith('cloud://') && !$cloudConnectionsStore[currentConid]) {
|
||||
// loadCloudConnection(currentConid);
|
||||
// }
|
||||
// });
|
||||
|
||||
function createAddMenu() {
|
||||
return [
|
||||
{
|
||||
text: 'New shared folder',
|
||||
onClick: () => {
|
||||
showModal(InputTextModal, {
|
||||
label: 'New folder name',
|
||||
header: 'New shared folder',
|
||||
onConfirm: async newFolder => {
|
||||
apiCall('cloud/create-folder', {
|
||||
name: newFolder,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Add existing shared folder',
|
||||
onClick: () => {
|
||||
showModal(InputTextModal, {
|
||||
label: 'Your invite link (in form dbgate://folder/xxx)',
|
||||
header: 'Add existing shared folder',
|
||||
onConfirm: async newFolder => {
|
||||
apiCall('cloud/grant-folder', {
|
||||
inviteLink: newFolder,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
command: 'new.connectionOnCloud',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createGroupContextMenu(folder) {
|
||||
const handleRename = () => {
|
||||
showModal(InputTextModal, {
|
||||
value: contentGroupMap[folder]?.name,
|
||||
label: 'New folder name',
|
||||
header: 'Rename folder',
|
||||
onConfirm: async name => {
|
||||
apiCall('cloud/rename-folder', {
|
||||
folid: folder,
|
||||
name,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
showModal(ConfirmModal, {
|
||||
message: `Really delete folder ${contentGroupMap[folder]?.name}? All folder content will be deleted!`,
|
||||
header: 'Delete folder',
|
||||
onConfirm: () => {
|
||||
apiCall('cloud/delete-folder', {
|
||||
folid: folder,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = async role => {
|
||||
const { inviteToken } = await apiCall(`cloud/get-invite-token`, {
|
||||
folid: folder,
|
||||
role,
|
||||
});
|
||||
const inviteLink = `dbgate://folder/v1/${inviteToken}?mode=${role}`;
|
||||
navigator.clipboard.writeText(inviteLink);
|
||||
showSnackbarInfo(`Invite link (${role}) copied to clipboard`);
|
||||
};
|
||||
|
||||
return [
|
||||
contentGroupMap[folder]?.role == 'admin' && [
|
||||
{ text: 'Rename', onClick: handleRename },
|
||||
{ text: 'Delete', onClick: handleDelete },
|
||||
],
|
||||
contentGroupMap[folder]?.role == 'admin' &&
|
||||
!contentGroupMap[folder]?.isPrivate && {
|
||||
text: 'Copy invite link',
|
||||
submenu: [
|
||||
{
|
||||
text: 'Admin',
|
||||
onClick: () => handleCopyInviteLink('admin'),
|
||||
},
|
||||
{
|
||||
text: 'Write',
|
||||
onClick: () => handleCopyInviteLink('write'),
|
||||
},
|
||||
{
|
||||
text: 'Read',
|
||||
onClick: () => handleCopyInviteLink('read'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<WidgetColumnBar>
|
||||
<WidgetColumnBarItem
|
||||
title="DbGate Cloud"
|
||||
name="privateCloud"
|
||||
height="50%"
|
||||
storageName="privateCloudItems"
|
||||
skip={!$cloudSigninTokenHolder}
|
||||
>
|
||||
<SearchBoxWrapper>
|
||||
<SearchInput placeholder="Search cloud connections and files" bind:value={filter} />
|
||||
<CloseSearchButton bind:filter />
|
||||
<DropDownButton icon="icon plus-thick" menu={createAddMenu} />
|
||||
<InlineButton
|
||||
on:click={handleRefreshContent}
|
||||
title="Refresh files"
|
||||
data-testid="CloudItemsWidget_buttonRefreshContent"
|
||||
>
|
||||
<FontIcon icon="icon refresh" />
|
||||
</InlineButton>
|
||||
</SearchBoxWrapper>
|
||||
<WidgetsInnerContainer>
|
||||
<AppObjectList
|
||||
list={cloudContentFlat || []}
|
||||
module={cloudContentAppObject}
|
||||
emptyGroupNames={emptyCloudContent}
|
||||
groupFunc={data => data.folid}
|
||||
mapGroupTitle={folid => `${contentGroupMap[folid]?.name} - ${contentGroupMap[folid]?.role}`}
|
||||
{filter}
|
||||
subItemsComponent={() => SubCloudItemsList}
|
||||
expandIconFunc={plusExpandIcon}
|
||||
isExpandable={data =>
|
||||
data.conid &&
|
||||
$cloudConnectionsStore[data.conid] &&
|
||||
!$cloudConnectionsStore[data.conid].singleDatabase &&
|
||||
$openedConnections.includes(data.conid)}
|
||||
getIsExpanded={data => $expandedConnections.includes(data.conid)}
|
||||
setIsExpanded={(data, value) => {
|
||||
expandedConnections.update(old => (value ? [...old, data.conid] : old.filter(x => x != data.conid)));
|
||||
}}
|
||||
passProps={{
|
||||
onFocusSqlObjectList: () => domSqlObjectList.focus(),
|
||||
}}
|
||||
groupContextMenu={createGroupContextMenu}
|
||||
/>
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
|
||||
<DatabaseWidgetDetailContent bind:domSqlObjectList showCloudConnection={true} />
|
||||
</WidgetColumnBar>
|
||||
53
packages/web/src/widgets/PublicCloudWidget.svelte
Normal file
53
packages/web/src/widgets/PublicCloudWidget.svelte
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import SavedFilesList from './SavedFilesList.svelte';
|
||||
|
||||
import WidgetColumnBar from './WidgetColumnBar.svelte';
|
||||
import WidgetColumnBarItem from './WidgetColumnBarItem.svelte';
|
||||
|
||||
import AppObjectList from '../appobj/AppObjectList.svelte';
|
||||
import * as publicCloudFileAppObject from '../appobj/PublicCloudFileAppObject.svelte';
|
||||
import * as cloudContentAppObject from '../appobj/CloudContentAppObject.svelte';
|
||||
import { useCloudContentList, usePublicCloudFiles, useServerStatus } from '../utility/metadataLoaders';
|
||||
import { _t } from '../translations';
|
||||
|
||||
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
|
||||
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
|
||||
import SearchInput from '../elements/SearchInput.svelte';
|
||||
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
|
||||
import InlineButton from '../buttons/InlineButton.svelte';
|
||||
import FontIcon from '../icons/FontIcon.svelte';
|
||||
import { apiCall } from '../utility/api';
|
||||
import _ from 'lodash';
|
||||
let filter = '';
|
||||
|
||||
const publicFiles = usePublicCloudFiles();
|
||||
|
||||
async function handleRefreshPublic() {
|
||||
await apiCall('cloud/refresh-public-files?isRefresh=1');
|
||||
}
|
||||
</script>
|
||||
|
||||
<WidgetColumnBar>
|
||||
<WidgetColumnBarItem title="Public Knowledge Base" name="publicCloud" storageName="publicCloudItems">
|
||||
<WidgetsInnerContainer>
|
||||
<SearchBoxWrapper>
|
||||
<SearchInput placeholder="Search public files" bind:value={filter} />
|
||||
<CloseSearchButton bind:filter />
|
||||
<InlineButton
|
||||
on:click={handleRefreshPublic}
|
||||
title="Refresh files"
|
||||
data-testid="CloudItemsWidget_buttonRefreshPublic"
|
||||
>
|
||||
<FontIcon icon="icon refresh" />
|
||||
</InlineButton>
|
||||
</SearchBoxWrapper>
|
||||
|
||||
<AppObjectList
|
||||
list={$publicFiles || []}
|
||||
module={publicCloudFileAppObject}
|
||||
groupFunc={data => data.folder || undefined}
|
||||
{filter}
|
||||
/>
|
||||
</WidgetsInnerContainer>
|
||||
</WidgetColumnBarItem>
|
||||
</WidgetColumnBar>
|
||||
@@ -74,24 +74,24 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<WidgetsInnerContainer>
|
||||
<SearchBoxWrapper>
|
||||
<SearchInput placeholder="Search saved files" bind:value={filter} />
|
||||
<CloseSearchButton bind:filter />
|
||||
<InlineUploadButton
|
||||
filters={[
|
||||
{
|
||||
name: `All supported files`,
|
||||
extensions: ['sql'],
|
||||
},
|
||||
{ name: `SQL files`, extensions: ['sql'] },
|
||||
]}
|
||||
onProcessFile={handleUploadedFile}
|
||||
/>
|
||||
<InlineButton on:click={handleRefreshFiles} title="Refresh files" data-testid="SavedFileList_buttonRefresh">
|
||||
<FontIcon icon="icon refresh" />
|
||||
</InlineButton>
|
||||
</SearchBoxWrapper>
|
||||
<SearchBoxWrapper>
|
||||
<SearchInput placeholder="Search saved files" bind:value={filter} />
|
||||
<CloseSearchButton bind:filter />
|
||||
<InlineUploadButton
|
||||
filters={[
|
||||
{
|
||||
name: `All supported files`,
|
||||
extensions: ['sql'],
|
||||
},
|
||||
{ name: `SQL files`, extensions: ['sql'] },
|
||||
]}
|
||||
onProcessFile={handleUploadedFile}
|
||||
/>
|
||||
<InlineButton on:click={handleRefreshFiles} title="Refresh files" data-testid="SavedFileList_buttonRefresh">
|
||||
<FontIcon icon="icon refresh" />
|
||||
</InlineButton>
|
||||
</SearchBoxWrapper>
|
||||
|
||||
<WidgetsInnerContainer>
|
||||
<AppObjectList list={files} module={savedFileAppObject} groupFunc={data => dataFolderTitle(data.folder)} {filter} />
|
||||
</WidgetsInnerContainer>
|
||||
|
||||
@@ -163,6 +163,8 @@
|
||||
($focusedConnectionOrDatabase.conid != conid ||
|
||||
($focusedConnectionOrDatabase?.database &&
|
||||
$focusedConnectionOrDatabase?.database != extractDbNameFromComposite(database)));
|
||||
|
||||
// $: console.log('STATUS', $status);
|
||||
</script>
|
||||
|
||||
{#if $status && $status.name == 'error'}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import {
|
||||
activeTabId,
|
||||
appUpdateStatus,
|
||||
cloudSigninTokenHolder,
|
||||
currentArchive,
|
||||
currentDatabase,
|
||||
currentThemeDefinition,
|
||||
@@ -154,7 +155,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $currentArchive}
|
||||
{#if $currentArchive && $currentArchive != 'default'}
|
||||
<div
|
||||
class="item flex clickable"
|
||||
title="Current archive"
|
||||
@@ -184,6 +185,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $cloudSigninTokenHolder?.email}
|
||||
<div class="item">
|
||||
<FontIcon icon="icon cloud" padRight />
|
||||
{$cloudSigninTokenHolder?.email}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $appUpdateStatus}
|
||||
<div class="item">
|
||||
<FontIcon icon={$appUpdateStatus.icon} padRight />
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
const visibleItemsCount = defs.filter(x => !x.collapsed && !x.skip).length;
|
||||
for (let index = 0; index < defs.length; index++) {
|
||||
const definition = defs[index];
|
||||
const splitterVisible = !!defs.slice(index + 1).find(x => x && !x.collapsed && !x.skip);
|
||||
const splitterVisible = !!defs.slice(index + 1).find(x => x && !x.collapsed && !x.skip && x.positiveCondition);
|
||||
dynamicPropsCollection[index].set({ splitterVisible, visibleItemsCount });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
export let title;
|
||||
export let name;
|
||||
export let skip = false;
|
||||
export let positiveCondition = true;
|
||||
export let height = null;
|
||||
export let collapsed = null;
|
||||
|
||||
@@ -33,11 +34,12 @@
|
||||
collapsed,
|
||||
height,
|
||||
skip,
|
||||
positiveCondition,
|
||||
},
|
||||
dynamicProps
|
||||
);
|
||||
|
||||
$: updateWidgetItemDefinition(widgetItemIndex, { collapsed: !visible, height, skip });
|
||||
$: updateWidgetItemDefinition(widgetItemIndex, { collapsed: !visible, height, skip, positiveCondition });
|
||||
|
||||
$: setInitialSize(height, $widgetColumnBarHeight);
|
||||
|
||||
@@ -67,7 +69,7 @@
|
||||
$: collapsible = $dynamicProps.visibleItemsCount != 1 || !visible;
|
||||
</script>
|
||||
|
||||
{#if !skip}
|
||||
{#if !skip && positiveCondition}
|
||||
<WidgetTitle
|
||||
clickable={collapsible}
|
||||
on:click={collapsible ? () => (visible = !visible) : null}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import AppWidget from './AppWidget.svelte';
|
||||
import AdminMenuWidget from './AdminMenuWidget.svelte';
|
||||
import AdminPremiumPromoWidget from './AdminPremiumPromoWidget.svelte';
|
||||
import PublicCloudWidget from './PublicCloudWidget.svelte';
|
||||
import PrivateCloudWidget from './PrivateCloudWidget.svelte';
|
||||
</script>
|
||||
|
||||
<DatabaseWidget hidden={$visibleSelectedWidget != 'database'} />
|
||||
@@ -37,3 +39,9 @@
|
||||
{#if $visibleSelectedWidget == 'premium'}
|
||||
<AdminPremiumPromoWidget />
|
||||
{/if}
|
||||
{#if $visibleSelectedWidget == 'cloud-public'}
|
||||
<PublicCloudWidget />
|
||||
{/if}
|
||||
{#if $visibleSelectedWidget == 'cloud-private'}
|
||||
<PrivateCloudWidget />
|
||||
{/if}
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
visibleHamburgerMenuWidget,
|
||||
lockedDatabaseMode,
|
||||
getCurrentConfig,
|
||||
cloudSigninTokenHolder,
|
||||
} from '../stores';
|
||||
import mainMenuDefinition from '../../../../app/src/mainMenuDefinition';
|
||||
import hasPermission from '../utility/hasPermission';
|
||||
import { isProApp } from '../utility/proTools';
|
||||
import { openWebLink } from '../utility/simpleTools';
|
||||
import { apiCall } from '../utility/api';
|
||||
import getElectron from '../utility/getElectron';
|
||||
|
||||
let domSettings;
|
||||
let domCloudAccount;
|
||||
let domMainMenu;
|
||||
|
||||
const widgets = [
|
||||
@@ -28,6 +33,12 @@
|
||||
name: 'database',
|
||||
title: 'Database connections',
|
||||
},
|
||||
{
|
||||
name: 'cloud-private',
|
||||
title: 'DbGate Cloud',
|
||||
icon: 'icon cloud-private',
|
||||
},
|
||||
|
||||
// {
|
||||
// icon: 'fa-table',
|
||||
// name: 'table',
|
||||
@@ -58,9 +69,9 @@
|
||||
title: 'Selected cell data detail view',
|
||||
},
|
||||
{
|
||||
icon: 'icon app',
|
||||
name: 'app',
|
||||
title: 'Application layers',
|
||||
name: 'cloud-public',
|
||||
title: 'DbGate Cloud',
|
||||
icon: 'icon cloud-public',
|
||||
},
|
||||
{
|
||||
icon: 'icon premium',
|
||||
@@ -92,7 +103,26 @@
|
||||
const rect = domSettings.getBoundingClientRect();
|
||||
const left = rect.right;
|
||||
const top = rect.bottom;
|
||||
const items = [{ command: 'settings.show' }, { command: 'theme.changeTheme' }, { command: 'settings.commands' }];
|
||||
const items = [
|
||||
{ command: 'settings.show' },
|
||||
{ command: 'theme.changeTheme' },
|
||||
{ command: 'settings.commands' },
|
||||
{
|
||||
text: 'View applications',
|
||||
onClick: () => {
|
||||
$selectedWidget = 'app';
|
||||
$visibleWidgetSideBar = true;
|
||||
},
|
||||
},
|
||||
];
|
||||
currentDropDownMenu.set({ left, top, items });
|
||||
}
|
||||
|
||||
function handleCloudAccountMenu() {
|
||||
const rect = domCloudAccount.getBoundingClientRect();
|
||||
const left = rect.right;
|
||||
const top = rect.bottom;
|
||||
const items = [{ command: 'cloud.logout' }];
|
||||
currentDropDownMenu.set({ left, top, items });
|
||||
}
|
||||
|
||||
@@ -103,6 +133,11 @@
|
||||
const items = mainMenuDefinition({ editMenu: false });
|
||||
currentDropDownMenu.set({ left, top, items });
|
||||
}
|
||||
|
||||
async function handleOpenCloudLogin() {
|
||||
const { url, sid } = await apiCall('auth/create-cloud-login-session', { client: getElectron() ? 'app' : 'web' });
|
||||
openWebLink(url, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="main">
|
||||
@@ -113,7 +148,8 @@
|
||||
{/if}
|
||||
{#each widgets
|
||||
.filter(x => x && hasPermission(`widgets/${x.name}`))
|
||||
.filter(x => !x.isPremiumPromo || !isProApp()) as item}
|
||||
.filter(x => !x.isPremiumPromo || !isProApp())
|
||||
.filter(x => x.name != 'cloud-private' || $cloudSigninTokenHolder) as item}
|
||||
<div
|
||||
class="wrapper"
|
||||
class:selected={item.name == $visibleSelectedWidget}
|
||||
@@ -129,7 +165,7 @@
|
||||
|
||||
<div class="flex1"> </div>
|
||||
|
||||
<div
|
||||
<!-- <div
|
||||
class="wrapper"
|
||||
title={`Toggle whether tabs from all databases are visible. Currently - ${$lockedDatabaseMode ? 'NO' : 'YES'}`}
|
||||
on:click={() => {
|
||||
@@ -138,7 +174,22 @@
|
||||
data-testid="WidgetIconPanel_lockDb"
|
||||
>
|
||||
<FontIcon icon={$lockedDatabaseMode ? 'icon locked-database-mode' : 'icon unlocked-database-mode'} />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
{#if $cloudSigninTokenHolder}
|
||||
<div
|
||||
class="wrapper"
|
||||
on:click={handleCloudAccountMenu}
|
||||
bind:this={domCloudAccount}
|
||||
data-testid="WidgetIconPanel_cloudAccount"
|
||||
>
|
||||
<FontIcon icon="icon cloud-account-connected" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="wrapper" on:click={handleOpenCloudLogin} data-testid="WidgetIconPanel_cloudAccount">
|
||||
<FontIcon icon="icon cloud-account" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="wrapper" on:click={handleSettingsMenu} bind:this={domSettings} data-testid="WidgetIconPanel_settings">
|
||||
<FontIcon icon="icon settings" />
|
||||
@@ -147,8 +198,8 @@
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
font-size: 23pt;
|
||||
height: 60px;
|
||||
font-size: 20pt;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
Reference in New Issue
Block a user