Compare commits

..

17 Commits

Author SHA1 Message Date
Faruk AYDIN
40636119fb feat: Move canConnect logic into the database column for AppConfig 2024-03-28 00:49:20 +01:00
Faruk AYDIN
beb3b2cf45 chore: Correct the folder of get auth client mock 2024-03-27 14:04:16 +01:00
Faruk AYDIN
c2375ed3d4 chore: Remove old app auth client routers 2024-03-27 14:01:20 +01:00
Faruk AYDIN
4942cf8dae feat: Implement new admin get app auth client API endpoint 2024-03-27 14:00:16 +01:00
Faruk AYDIN
8f444eafa7 chore: Remove old admin app auth clients API endpoint 2024-03-27 13:44:01 +01:00
Faruk AYDIN
2484a0e631 feat: Implement new admin get auth clients API endpoint 2024-03-27 13:42:41 +01:00
Faruk AYDIN
d3747ad050 fix: Typo for the get auth clients test file 2024-03-27 13:42:18 +01:00
Faruk AYDIN
bb68a75636 feat: Implement new get app auth clients API endpoint 2024-03-26 23:58:35 +01:00
Faruk AYDIN
98131d633e refactor: Remove redundant appConfigId from get auth clients mock 2024-03-26 21:27:38 +01:00
Faruk AYDIN
e8193e0e17 feat: Make appKey column of app auth clients not nullable 2024-03-26 21:23:22 +01:00
Faruk AYDIN
74b7dd8f34 chore: Remove old app auth clients API endpoint 2024-03-26 21:19:44 +01:00
Faruk AYDIN
4f500e2d04 feat: Implement new get auth clients api endpoint 2024-03-26 21:17:58 +01:00
Faruk AYDIN
b53ddca8ce feat: Remove app config relation from app auth clients 2024-03-26 21:00:26 +01:00
Faruk AYDIN
70f30034ab feat: Remove app auth clients relation from app configs 2024-03-26 20:59:33 +01:00
Faruk AYDIN
fcd83909f7 feat: Remove app config id from app auth clients 2024-03-26 20:58:51 +01:00
Faruk AYDIN
eadb472af9 feat: Migrate app config id to app key 2024-03-26 20:56:50 +01:00
Faruk AYDIN
600316577e feat: Add appKey to app auth clients 2024-03-26 20:49:53 +01:00
160 changed files with 1718 additions and 2678 deletions

View File

@@ -67,7 +67,6 @@
"pluralize": "^8.0.0",
"raw-body": "^2.5.2",
"showdown": "^2.1.0",
"ssh2-sftp-client": "^10.0.3",
"winston": "^3.7.1",
"xmlrpc": "^1.3.2"
},
@@ -96,7 +95,6 @@
"url": "https://github.com/automatisch/automatisch/issues"
},
"devDependencies": {
"node-gyp": "^10.1.0",
"nodemon": "^2.0.13",
"supertest": "^6.3.3",
"vitest": "^1.1.3"

View File

@@ -90,7 +90,7 @@ export default defineAction({
async run($) {
const method = $.step.parameters.method;
const data = $.step.parameters.data || null;
const data = $.step.parameters.data;
const url = $.step.parameters.url;
const headers = $.step.parameters.headers;
@@ -108,17 +108,14 @@ export default defineAction({
return result;
}, {});
let expectedResponseContentType = headersObject.accept;
let contentType = headersObject['content-type'];
// in case HEAD request is not supported by the URL
try {
const metadataResponse = await $.http.head(url, {
headers: headersObject,
});
if (!expectedResponseContentType) {
expectedResponseContentType = metadataResponse.headers['content-type'];
}
contentType = metadataResponse.headers['content-type'];
throwIfFileSizeExceedsLimit(metadataResponse.headers['content-length']);
// eslint-disable-next-line no-empty
@@ -131,7 +128,7 @@ export default defineAction({
headers: headersObject,
};
if (!isPossiblyTextBased(expectedResponseContentType)) {
if (!isPossiblyTextBased(contentType)) {
requestData.responseType = 'arraybuffer';
}
@@ -141,7 +138,7 @@ export default defineAction({
let responseData = response.data;
if (!isPossiblyTextBased(expectedResponseContentType)) {
if (!isPossiblyTextBased(contentType)) {
responseData = Buffer.from(responseData).toString('base64');
}

View File

@@ -64,18 +64,33 @@ export default defineAction({
value: '1',
description:
'The ID of the stage this deal will be added to. If omitted, the deal will be placed in the first stage of the default pipeline.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
options: [
{
name: 'key',
value: 'listStages',
label: 'Qualified (Pipeline)',
value: 1,
},
{
label: 'Contact Made (Pipeline)',
value: 2,
},
{
label: 'Prospect Qualified (Pipeline)',
value: 3,
},
{
label: 'Needs Defined (Pipeline)',
value: 4,
},
{
label: 'Proposal Made (Pipeline)',
value: 5,
},
{
label: 'Negotiations Started (Pipeline)',
value: 6,
},
],
},
},
{
label: 'Owner',
key: 'userId',

View File

@@ -1,25 +1,23 @@
import listActivityTypes from './list-activity-types/index.js';
import listCurrencies from './list-currencies/index.js';
import listDeals from './list-deals/index.js';
import listLeadLabels from './list-lead-labels/index.js';
import listLeads from './list-leads/index.js';
import listOrganizationLabelField from './list-organization-label-field/index.js';
import listLeadLabels from './list-lead-labels/index.js';
import listOrganizations from './list-organizations/index.js';
import listOrganizationLabelField from './list-organization-label-field/index.js';
import listPersonLabelField from './list-person-label-field/index.js';
import listPersons from './list-persons/index.js';
import listStages from './list-stages/index.js';
import listUsers from './list-users/index.js';
export default [
listActivityTypes,
listCurrencies,
listDeals,
listLeadLabels,
listLeads,
listOrganizationLabelField,
listLeadLabels,
listOrganizations,
listOrganizationLabelField,
listPersonLabelField,
listPersons,
listStages,
listUsers,
];

View File

@@ -1,23 +0,0 @@
export default {
name: 'List stages',
key: 'listStages',
async run($) {
const stages = {
data: [],
};
const { data } = await $.http.get('/api/v1/stages');
if (data.data?.length) {
for (const stage of data.data) {
stages.data.push({
value: stage.id,
name: stage.name,
});
}
}
return stages;
},
};

View File

@@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 103.95"><title>sftp</title><path d="M21.56,47.27l6.21-.39a4.56,4.56,0,0,0,.82,2.29,3.45,3.45,0,0,0,2.88,1.28,3.16,3.16,0,0,0,2.15-.65,2,2,0,0,0,.76-1.52,2,2,0,0,0-.71-1.48,8.45,8.45,0,0,0-3.34-1.22A14.06,14.06,0,0,1,24.21,43a5.14,5.14,0,0,1-1.85-4.07,5.44,5.44,0,0,1,.95-3.07,6.19,6.19,0,0,1,2.84-2.28,13.26,13.26,0,0,1,5.19-.83,10.58,10.58,0,0,1,6.18,1.51,6.53,6.53,0,0,1,2.53,4.8l-6.14.37a3.38,3.38,0,0,0-1-2.1,3.34,3.34,0,0,0-2.17-.65,2.63,2.63,0,0,0-1.72.48,1.49,1.49,0,0,0-.58,1.18,1.19,1.19,0,0,0,.47.9,5.23,5.23,0,0,0,2.18.78,27.38,27.38,0,0,1,6.13,1.87,6.38,6.38,0,0,1,2.7,2.35,5.94,5.94,0,0,1,.84,3.13,6.73,6.73,0,0,1-1.12,3.75,7.1,7.1,0,0,1-3.15,2.6,12.64,12.64,0,0,1-5.08.89c-3.6,0-6.09-.7-7.47-2.08a8.18,8.18,0,0,1-2.35-5.27ZM9.07,15.63h3.09V8.2A1.87,1.87,0,0,1,14,6.33h3.32V1.87A1.88,1.88,0,0,1,19.23,0H49.57a1.87,1.87,0,0,1,1.87,1.87V6.33h59.5a1.87,1.87,0,0,1,1.87,1.87v7.43h3.41a1.87,1.87,0,0,1,1.87,1.87,1.75,1.75,0,0,1,0,.4L111.51,66a7.12,7.12,0,0,1-2,4.07,5.62,5.62,0,0,1-4,1.69H69.25v5.57h.17A2.67,2.67,0,0,1,72.08,80v4.81h13a2.67,2.67,0,0,1,2.54,1.87h27.84a7,7,0,0,1,6.85,4.48,8.05,8.05,0,0,1,.57,3,8.14,8.14,0,0,1-.57,3,7,7,0,0,1-6.85,4.47H87.73A2.67,2.67,0,0,1,85.08,104H39.92a2.67,2.67,0,0,1-2.65-2.37H7.42A7,7,0,0,1,.57,97.11a8.14,8.14,0,0,1-.57-3,8.05,8.05,0,0,1,.57-3,7,7,0,0,1,6.85-4.48h30a2.67,2.67,0,0,1,2.54-1.87H50.68V80a2.67,2.67,0,0,1,2.66-2.66h1V71.75H18A5.69,5.69,0,0,1,13.89,70,6.84,6.84,0,0,1,12,65.92L6.92,17.69a1.83,1.83,0,0,1,.37-1.33,1.86,1.86,0,0,1,1.29-.71,4,4,0,0,1,.49,0ZM57.86,77.32h7.86v-5.5H57.86v5.5ZM87.74,90.19v7.86h27.72A3.63,3.63,0,0,0,119,95.8a4.68,4.68,0,0,0,.31-1.68,4.63,4.63,0,0,0-.31-1.67,3.63,3.63,0,0,0-3.58-2.26ZM37.26,98.05V90.19H7.42a3.63,3.63,0,0,0-3.58,2.26,4.63,4.63,0,0,0-.31,1.67,4.73,4.73,0,0,0,.31,1.68,3.63,3.63,0,0,0,3.58,2.25ZM10.85,19.37l4.84,46.17a3.14,3.14,0,0,0,.83,1.86A2.07,2.07,0,0,0,18,68h87.54a2,2,0,0,0,1.41-.6,3.28,3.28,0,0,0,.88-1.92l6.28-46.12Zm5.05-9.3v5.55l93.17-.79V10.07H49.57A1.87,1.87,0,0,1,47.69,8.2V3.74H21.1V8.2a1.87,1.87,0,0,1-1.87,1.87Zm28.3,23H60.35v4.54H50.76v3.7H59v4.28H50.76v8.62H44.2V33.12Zm18.35,0H82.42v5.22H75.75V54.26H69.22V38.34H62.55V33.12Zm22.93,0H96.35a7.48,7.48,0,0,1,5.32,1.68,6.36,6.36,0,0,1,1.77,4.82,6.55,6.55,0,0,1-1.93,5q-1.94,1.82-5.89,1.81H92v7.82H85.48V33.12Zm6.56,9h1.61a4.14,4.14,0,0,0,2.67-.66,2.13,2.13,0,0,0,.77-1.68,2.37,2.37,0,0,0-.67-1.69,3.49,3.49,0,0,0-2.51-.69H92v4.72Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,69 +0,0 @@
import verifyCredentials from './verify-credentials.js';
import isStillVerified from './is-still-verified.js';
export default {
fields: [
{
key: 'screenName',
label: 'Screen Name',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description:
'Screen name of your connection to be used on Automatisch UI.',
clickToCopy: false,
},
{
key: 'host',
label: 'Host',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'The host information Automatisch will connect to.',
docUrl: 'https://automatisch.io/docs/ftp#host',
clickToCopy: false,
},
{
key: 'username',
label: 'Username',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: 'Your SFTP login credentials.',
docUrl: 'https://automatisch.io/docs/sftp#username',
clickToCopy: false,
},
{
key: 'password',
label: 'Password',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
docUrl: 'https://automatisch.io/docs/sftp#password',
clickToCopy: false,
},
{
key: 'port',
label: 'Port',
type: 'string',
required: false,
readOnly: false,
value: '21',
placeholder: null,
description: null,
docUrl: 'https://automatisch.io/docs/sftp#port',
clickToCopy: false,
},
],
verifyCredentials,
isStillVerified,
};

View File

@@ -1,8 +0,0 @@
import verifyCredentials from './verify-credentials.js';
const isStillVerified = async ($) => {
await verifyCredentials($);
return true;
};
export default isStillVerified;

View File

@@ -1,23 +0,0 @@
import Client from 'ssh2-sftp-client';
const verifyCredentials = async ($) => {
const sftpClient = new Client();
try {
await sftpClient.connect({
host: $.auth.data.host,
port: $.auth.data.port,
username: $.auth.data.username,
password: $.auth.data.password,
});
} catch (err) {
console.log(err);
}
await sftpClient.end();
await $.auth.set({
screenName: $.auth.data.screenName,
});
};
export default verifyCredentials;

View File

@@ -1,14 +0,0 @@
import defineApp from '../../helpers/define-app.js';
import auth from './auth/index.js';
export default defineApp({
name: 'SFTP',
key: 'sftp',
iconUrl: '{BASE_URL}/apps/sftp/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/sftp/connection',
supportsConnections: true,
baseUrl: '',
apiBaseUrl: '',
primaryColor: '000000',
auth,
});

View File

@@ -3,9 +3,6 @@ import AppConfig from '../../../../models/app-config.js';
export default async (request, response) => {
const appConfig = await AppConfig.query()
.withGraphFetched({
appAuthClients: true,
})
.findOne({
key: request.params.appKey,
})

View File

@@ -1,24 +0,0 @@
import { renderObject } from '../../../../helpers/renderer.js';
import App from '../../../../models/app.js';
export default async (request, response) => {
const app = await App.findOneByKey(request.params.appKey);
const connections = await request.currentUser.authorizedConnections
.clone()
.select('connections.*')
.withGraphFetched({
appConfig: true,
appAuthClient: true,
})
.fullOuterJoinRelated('steps')
.where({
'connections.key': app.key,
'connections.draft': false,
})
.countDistinct('steps.flow_id as flowCount')
.groupBy('connections.id')
.orderBy('created_at', 'desc');
renderObject(response, connections);
};

View File

@@ -1,101 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js';
import { createUser } from '../../../../../test/factories/user.js';
import { createConnection } from '../../../../../test/factories/connection.js';
import { createPermission } from '../../../../../test/factories/permission.js';
import getConnectionsMock from '../../../../../test/mocks/rest/api/v1/apps/get-connections.js';
describe('GET /api/v1/apps/:appKey/connections', () => {
let currentUser, currentUserRole, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the connections data of specified app for current user', async () => {
const currentUserConnectionOne = await createConnection({
userId: currentUser.id,
key: 'deepl',
draft: false,
});
const currentUserConnectionTwo = await createConnection({
userId: currentUser.id,
key: 'deepl',
draft: false,
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.get('/api/v1/apps/deepl/connections')
.set('Authorization', token)
.expect(200);
const expectedPayload = await getConnectionsMock([
currentUserConnectionTwo,
currentUserConnectionOne,
]);
expect(response.body).toEqual(expectedPayload);
});
it('should return the connections data of specified app for another user', async () => {
const anotherUser = await createUser();
const anotherUserConnectionOne = await createConnection({
userId: anotherUser.id,
key: 'deepl',
draft: false,
});
const anotherUserConnectionTwo = await createConnection({
userId: anotherUser.id,
key: 'deepl',
draft: false,
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.get('/api/v1/apps/deepl/connections')
.set('Authorization', token)
.expect(200);
const expectedPayload = await getConnectionsMock([
anotherUserConnectionTwo,
anotherUserConnectionOne,
]);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for invalid connection UUID', async () => {
await createPermission({
action: 'update',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await request(app)
.get('/api/v1/connections/invalid-connection-id/connections')
.set('Authorization', token)
.expect(404);
});
});

View File

@@ -1,14 +0,0 @@
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
let connection = await request.currentUser.authorizedConnections
.clone()
.findOne({
id: request.params.connectionId,
})
.throwIfNotFound();
connection = await connection.testAndUpdateConnection();
renderObject(response, connection);
};

View File

@@ -1,123 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import Crypto from 'crypto';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js';
import { createUser } from '../../../../../test/factories/user.js';
import { createConnection } from '../../../../../test/factories/connection.js';
import { createPermission } from '../../../../../test/factories/permission.js';
describe('POST /api/v1/connections/:connectionId/test', () => {
let currentUser, currentUserRole, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
token = createAuthTokenByUserId(currentUser.id);
});
it('should update the connection as not verified for current user', async () => {
const currentUserConnection = await createConnection({
userId: currentUser.id,
key: 'deepl',
verified: true,
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.post(`/api/v1/connections/${currentUserConnection.id}/test`)
.set('Authorization', token)
.expect(200);
expect(response.body.data.verified).toEqual(false);
});
it('should update the connection as not verified for another user', async () => {
const anotherUser = await createUser();
const anotherUserConnection = await createConnection({
userId: anotherUser.id,
key: 'deepl',
verified: true,
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'update',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.post(`/api/v1/connections/${anotherUserConnection.id}/test`)
.set('Authorization', token)
.expect(200);
expect(response.body.data.verified).toEqual(false);
});
it('should return not found response for not existing connection UUID', async () => {
const notExistingConnectionUUID = Crypto.randomUUID();
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await request(app)
.post(`/api/v1/connections/${notExistingConnectionUUID}/test`)
.set('Authorization', token)
.expect(404);
});
it('should return bad request response for invalid UUID', async () => {
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await request(app)
.post('/api/v1/connections/invalidConnectionUUID/test')
.set('Authorization', token)
.expect(400);
});
});

View File

@@ -1,18 +0,0 @@
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const step = await request.currentUser.authorizedSteps
.clone()
.where('steps.id', request.params.stepId)
.whereNotNull('steps.app_key')
.whereNotNull('steps.connection_id')
.first()
.throwIfNotFound();
const dynamicData = await step.createDynamicData(
request.body.dynamicDataKey,
request.body.parameters
);
renderObject(response, dynamicData);
};

View File

@@ -1,244 +0,0 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import Crypto from 'crypto';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import { createConnection } from '../../../../../test/factories/connection';
import { createFlow } from '../../../../../test/factories/flow';
import { createStep } from '../../../../../test/factories/step';
import { createPermission } from '../../../../../test/factories/permission';
import listRepos from '../../../../apps/github/dynamic-data/list-repos/index.js';
import HttpError from '../../../../errors/http.js';
describe('POST /api/v1/steps/:stepId/dynamic-data', () => {
let currentUser, currentUserRole, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
token = createAuthTokenByUserId(currentUser.id);
});
describe('should return dynamically created data', () => {
let repositories;
beforeEach(async () => {
repositories = [
{
value: 'automatisch/automatisch',
name: 'automatisch/automatisch',
},
{
value: 'automatisch/sample',
name: 'automatisch/sample',
},
];
vi.spyOn(listRepos, 'run').mockImplementation(async () => {
return {
data: repositories,
};
});
});
it('of the current users step', async () => {
const currentUserFlow = await createFlow({ userId: currentUser.id });
const connection = await createConnection({ userId: currentUser.id });
const actionStep = await createStep({
flowId: currentUserFlow.id,
connectionId: connection.id,
type: 'action',
appKey: 'github',
key: 'createIssue',
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.post(`/api/v1/steps/${actionStep.id}/dynamic-data`)
.set('Authorization', token)
.send({
dynamicDataKey: 'listRepos',
parameters: {},
})
.expect(200);
expect(response.body.data).toEqual(repositories);
});
it('of the another users step', async () => {
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({ userId: anotherUser.id });
const connection = await createConnection({ userId: anotherUser.id });
const actionStep = await createStep({
flowId: anotherUserFlow.id,
connectionId: connection.id,
type: 'action',
appKey: 'github',
key: 'createIssue',
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.post(`/api/v1/steps/${actionStep.id}/dynamic-data`)
.set('Authorization', token)
.send({
dynamicDataKey: 'listRepos',
parameters: {},
})
.expect(200);
expect(response.body.data).toEqual(repositories);
});
});
describe('should return error for dynamically created data', () => {
let errors;
beforeEach(async () => {
errors = {
message: 'Not Found',
documentation_url:
'https://docs.github.com/rest/users/users#get-a-user',
};
vi.spyOn(listRepos, 'run').mockImplementation(async () => {
throw new HttpError({ message: errors });
});
});
it('of the current users step', async () => {
const currentUserFlow = await createFlow({ userId: currentUser.id });
const connection = await createConnection({ userId: currentUser.id });
const actionStep = await createStep({
flowId: currentUserFlow.id,
connectionId: connection.id,
type: 'action',
appKey: 'github',
key: 'createIssue',
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.post(`/api/v1/steps/${actionStep.id}/dynamic-data`)
.set('Authorization', token)
.send({
dynamicDataKey: 'listRepos',
parameters: {},
})
.expect(200);
expect(response.body.errors).toEqual(errors);
});
});
it('should return not found response for not existing step UUID', async () => {
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const notExistingStepUUID = Crypto.randomUUID();
await request(app)
.get(`/api/v1/steps/${notExistingStepUUID}/dynamic-data`)
.set('Authorization', token)
.expect(404);
});
it('should return not found response for existing step UUID without app key', async () => {
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const step = await createStep({ appKey: null });
await request(app)
.get(`/api/v1/steps/${step.id}/dynamic-data`)
.set('Authorization', token)
.expect(404);
});
it('should return bad request response for invalid UUID', async () => {
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await request(app)
.post('/api/v1/steps/invalidStepUUID/dynamic-fields')
.set('Authorization', token)
.expect(400);
});
});

View File

@@ -1,7 +0,0 @@
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const apps = await request.currentUser.getApps(request.query.name);
renderObject(response, apps, { serializer: 'App' });
};

View File

@@ -1,210 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../../../test/factories/role';
import { createUser } from '../../../../../test/factories/user';
import { createPermission } from '../../../../../test/factories/permission.js';
import { createFlow } from '../../../../../test/factories/flow.js';
import { createStep } from '../../../../../test/factories/step.js';
import { createConnection } from '../../../../../test/factories/connection.js';
import getAppsMock from '../../../../../test/mocks/rest/api/v1/users/get-apps.js';
describe('GET /api/v1/users/:userId/apps', () => {
let currentUser, currentUserRole, token;
beforeEach(async () => {
currentUserRole = await createRole();
currentUser = await createUser({ roleId: currentUserRole.id });
token = createAuthTokenByUserId(currentUser.id);
});
it('should return all apps of the current user', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const flowOne = await createFlow({ userId: currentUser.id });
await createStep({
flowId: flowOne.id,
appKey: 'webhook',
});
const flowOneActionStepConnection = await createConnection({
userId: currentUser.id,
key: 'deepl',
draft: false,
});
await createStep({
connectionId: flowOneActionStepConnection.id,
flowId: flowOne.id,
appKey: 'deepl',
});
const flowTwo = await createFlow({ userId: currentUser.id });
const flowTwoTriggerStepConnection = await createConnection({
userId: currentUser.id,
key: 'github',
draft: false,
});
await createStep({
connectionId: flowTwoTriggerStepConnection.id,
flowId: flowTwo.id,
appKey: 'github',
});
await createStep({
flowId: flowTwo.id,
appKey: 'slack',
});
const response = await request(app)
.get(`/api/v1/users/${currentUser.id}/apps`)
.set('Authorization', token)
.expect(200);
const expectedPayload = getAppsMock();
expect(response.body).toEqual(expectedPayload);
});
it('should return all apps of the another user', async () => {
const anotherUser = await createUser();
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: [],
});
const flowOne = await createFlow({ userId: anotherUser.id });
await createStep({
flowId: flowOne.id,
appKey: 'webhook',
});
const flowOneActionStepConnection = await createConnection({
userId: anotherUser.id,
key: 'deepl',
draft: false,
});
await createStep({
connectionId: flowOneActionStepConnection.id,
flowId: flowOne.id,
appKey: 'deepl',
});
const flowTwo = await createFlow({ userId: anotherUser.id });
const flowTwoTriggerStepConnection = await createConnection({
userId: anotherUser.id,
key: 'github',
draft: false,
});
await createStep({
connectionId: flowTwoTriggerStepConnection.id,
flowId: flowTwo.id,
appKey: 'github',
});
await createStep({
flowId: flowTwo.id,
appKey: 'slack',
});
const response = await request(app)
.get(`/api/v1/users/${currentUser.id}/apps`)
.set('Authorization', token)
.expect(200);
const expectedPayload = getAppsMock();
expect(response.body).toEqual(expectedPayload);
});
it('should return specified app of the current user', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'read',
subject: 'Connection',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const flowOne = await createFlow({ userId: currentUser.id });
await createStep({
flowId: flowOne.id,
appKey: 'webhook',
});
const flowOneActionStepConnection = await createConnection({
userId: currentUser.id,
key: 'deepl',
draft: false,
});
await createStep({
connectionId: flowOneActionStepConnection.id,
flowId: flowOne.id,
appKey: 'deepl',
});
const flowTwo = await createFlow({ userId: currentUser.id });
const flowTwoTriggerStepConnection = await createConnection({
userId: currentUser.id,
key: 'github',
draft: false,
});
await createStep({
connectionId: flowTwoTriggerStepConnection.id,
flowId: flowTwo.id,
appKey: 'github',
});
await createStep({
flowId: flowTwo.id,
appKey: 'slack',
});
const response = await request(app)
.get(`/api/v1/users/${currentUser.id}/apps?name=deepl`)
.set('Authorization', token)
.expect(200);
expect(response.body.data.length).toEqual(1);
expect(response.body.data[0].key).toEqual('deepl');
});
});

View File

@@ -6,6 +6,10 @@ export async function up(knex) {
export async function down(knex) {
await knex.schema.table('app_auth_clients', (table) => {
table.uuid('app_config_id').references('id').inTable('app_configs');
table
.uuid('app_config_id')
.notNullable()
.references('id')
.inTable('app_configs');
});
}

View File

@@ -0,0 +1,11 @@
export async function up(knex) {
await knex.schema.table('app_configs', (table) => {
table.boolean('can_connect').defaultTo(false);
});
}
export async function down(knex) {
await knex.schema.table('app_configs', (table) => {
table.dropColumn('can_connect');
});
}

View File

@@ -0,0 +1,24 @@
import AppAuthClient from '../../models/app-auth-client.js';
const getAppAuthClient = async (_parent, params, context) => {
let canSeeAllClients = false;
try {
context.currentUser.can('read', 'App');
canSeeAllClients = true;
} catch {
// void
}
const appAuthClient = AppAuthClient.query()
.findById(params.id)
.throwIfNotFound();
if (!canSeeAllClients) {
appAuthClient.where({ active: true });
}
return await appAuthClient;
};
export default getAppAuthClient;

View File

@@ -0,0 +1,33 @@
import AppConfig from '../../models/app-config.js';
const getAppAuthClients = async (_parent, params, context) => {
let canSeeAllClients = false;
try {
context.currentUser.can('read', 'App');
canSeeAllClients = true;
} catch {
// void
}
const appConfig = await AppConfig.query()
.findOne({
key: params.appKey,
})
.throwIfNotFound();
const appAuthClients = appConfig
.$relatedQuery('appAuthClients')
.where({ active: params.active })
.skipUndefined();
if (!canSeeAllClients) {
appAuthClients.where({
active: true,
});
}
return await appAuthClients;
};
export default getAppAuthClients;

View File

@@ -0,0 +1,41 @@
import App from '../../models/app.js';
import Connection from '../../models/connection.js';
const getApp = async (_parent, params, context) => {
const conditions = context.currentUser.can('read', 'Connection');
const userConnections = context.currentUser.$relatedQuery('connections');
const allConnections = Connection.query();
const connectionBaseQuery = conditions.isCreator
? userConnections
: allConnections;
const app = await App.findOneByKey(params.key);
if (context.currentUser) {
const connections = await connectionBaseQuery
.clone()
.select('connections.*')
.withGraphFetched({
appConfig: true,
appAuthClient: true,
})
.fullOuterJoinRelated('steps')
.where({
'connections.key': params.key,
'connections.draft': false,
})
.countDistinct('steps.flow_id as flowCount')
.groupBy('connections.id')
.orderBy('created_at', 'desc');
return {
...app,
connections,
};
}
return app;
};
export default getApp;

View File

@@ -0,0 +1,101 @@
import { DateTime } from 'luxon';
import Billing from '../../helpers/billing/index.ee.js';
import ExecutionStep from '../../models/execution-step.js';
const getBillingAndUsage = async (_parent, _params, context) => {
const persistedSubscription = await context.currentUser.$relatedQuery(
'currentSubscription'
);
const subscription = persistedSubscription
? paidSubscription(persistedSubscription)
: freeTrialSubscription();
return {
subscription,
usage: {
task: executionStepCount(context),
},
};
};
const paidSubscription = (subscription) => {
const currentPlan = Billing.paddlePlans.find(
(plan) => plan.productId === subscription.paddlePlanId
);
return {
status: subscription.status,
monthlyQuota: {
title: currentPlan.limit,
action: {
type: 'link',
text: 'Cancel plan',
src: subscription.cancelUrl,
},
},
nextBillAmount: {
title: subscription.nextBillAmount
? '€' + subscription.nextBillAmount
: '---',
action: {
type: 'link',
text: 'Update payment method',
src: subscription.updateUrl,
},
},
nextBillDate: {
title: subscription.nextBillDate ? subscription.nextBillDate : '---',
action: {
type: 'text',
text: '(monthly payment)',
},
},
};
};
const freeTrialSubscription = () => {
return {
status: null,
monthlyQuota: {
title: 'Free Trial',
action: {
type: 'link',
text: 'Upgrade plan',
src: '/settings/billing/upgrade',
},
},
nextBillAmount: {
title: '---',
action: null,
},
nextBillDate: {
title: '---',
action: null,
},
};
};
const executionIds = async (context) => {
return (
await context.currentUser
.$relatedQuery('executions')
.select('executions.id')
).map((execution) => execution.id);
};
const executionStepCount = async (context) => {
const executionStepCount = await ExecutionStep.query()
.whereIn('execution_id', await executionIds(context))
.andWhere(
'created_at',
'>=',
DateTime.now().minus({ days: 30 }).toISODate()
)
.count()
.first();
return executionStepCount.count;
};
export default getBillingAndUsage;

View File

@@ -0,0 +1,67 @@
import App from '../../models/app.js';
import Flow from '../../models/flow.js';
import Connection from '../../models/connection.js';
const getConnectedApps = async (_parent, params, context) => {
const conditions = context.currentUser.can('read', 'Connection');
const userConnections = context.currentUser.$relatedQuery('connections');
const allConnections = Connection.query();
const connectionBaseQuery = conditions.isCreator
? userConnections
: allConnections;
const userFlows = context.currentUser.$relatedQuery('flows');
const allFlows = Flow.query();
const flowBaseQuery = conditions.isCreator ? userFlows : allFlows;
let apps = await App.findAll(params.name);
const connections = await connectionBaseQuery
.clone()
.select('connections.key')
.where({ draft: false })
.count('connections.id as count')
.groupBy('connections.key');
const flows = await flowBaseQuery
.clone()
.withGraphJoined('steps')
.orderBy('created_at', 'desc');
const duplicatedUsedApps = flows
.map((flow) => flow.steps.map((step) => step.appKey))
.flat()
.filter(Boolean);
const connectionKeys = connections.map((connection) => connection.key);
const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])];
apps = apps
.filter((app) => {
return usedApps.includes(app.key);
})
.map((app) => {
const connection = connections.find(
(connection) => connection.key === app.key
);
app.connectionCount = connection?.count || 0;
app.flowCount = 0;
flows.forEach((flow) => {
const usedFlow = flow.steps.find((step) => step.appKey === app.key);
if (usedFlow) {
app.flowCount += 1;
}
});
return app;
})
.sort((appA, appB) => appA.name.localeCompare(appB.name));
return apps;
};
export default getConnectedApps;

View File

@@ -0,0 +1,65 @@
import App from '../../models/app.js';
import Step from '../../models/step.js';
import ExecutionStep from '../../models/execution-step.js';
import globalVariable from '../../helpers/global-variable.js';
import computeParameters from '../../helpers/compute-parameters.js';
const getDynamicData = async (_parent, params, context) => {
const conditions = context.currentUser.can('update', 'Flow');
const userSteps = context.currentUser.$relatedQuery('steps');
const allSteps = Step.query();
const stepBaseQuery = conditions.isCreator ? userSteps : allSteps;
const step = await stepBaseQuery
.clone()
.withGraphFetched({
connection: true,
flow: true,
})
.findById(params.stepId);
if (!step) return null;
const connection = step.connection;
if (!connection || !step.appKey) return null;
const flow = step.flow;
const app = await App.findOneByKey(step.appKey);
const $ = await globalVariable({ connection, app, flow, step });
const command = app.dynamicData.find((data) => data.key === params.key);
// apply run-time parameters that're not persisted yet
for (const parameterKey in params.parameters) {
const parameterValue = params.parameters[parameterKey];
$.step.parameters[parameterKey] = parameterValue;
}
const lastExecution = await flow.$relatedQuery('lastExecution');
const lastExecutionId = lastExecution?.id;
const priorExecutionSteps = lastExecutionId
? await ExecutionStep.query().where({
execution_id: lastExecutionId,
})
: [];
// compute variables in parameters
const computedParameters = computeParameters(
$.step.parameters,
priorExecutionSteps
);
$.step.parameters = computedParameters;
const fetchedData = await command.run($);
if (fetchedData.error) {
throw new Error(JSON.stringify(fetchedData.error));
}
return fetchedData.data;
};
export default getDynamicData;

View File

@@ -0,0 +1,19 @@
import Flow from '../../models/flow.js';
const getFlow = async (_parent, params, context) => {
const conditions = context.currentUser.can('read', 'Flow');
const userFlows = context.currentUser.$relatedQuery('flows');
const allFlows = Flow.query();
const baseQuery = conditions.isCreator ? userFlows : allFlows;
const flow = await baseQuery
.clone()
.withGraphJoined('[steps.[connection]]')
.orderBy('steps.position', 'asc')
.findOne({ 'flows.id': params.id })
.throwIfNotFound();
return flow;
};
export default getFlow;

View File

@@ -0,0 +1,240 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../app';
import appConfig from '../../config/app';
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id';
import { createRole } from '../../../test/factories/role';
import { createPermission } from '../../../test/factories/permission';
import { createUser } from '../../../test/factories/user';
import { createFlow } from '../../../test/factories/flow';
import { createStep } from '../../../test/factories/step';
import { createConnection } from '../../../test/factories/connection';
describe('graphQL getFlow query', () => {
const query = (flowId) => {
return `
query {
getFlow(id: "${flowId}") {
id
name
active
status
steps {
id
type
key
appKey
iconUrl
webhookUrl
status
position
connection {
id
verified
createdAt
}
parameters
}
}
}
`;
};
describe('and without permissions', () => {
it('should throw not authorized error', async () => {
const userWithoutPermissions = await createUser();
const token = createAuthTokenByUserId(userWithoutPermissions.id);
const flow = await createFlow();
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(flow.id) })
.expect(200);
expect(response.body.errors).toBeDefined();
expect(response.body.errors[0].message).toEqual('Not authorized!');
});
});
describe('and with correct permission', () => {
let currentUser, currentUserRole, currentUserFlow;
beforeEach(async () => {
currentUserRole = await createRole();
currentUser = await createUser({ roleId: currentUserRole.id });
currentUserFlow = await createFlow({ userId: currentUser.id });
});
describe('and with isCreator condition', () => {
it('should return executions data of the current user', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const triggerStep = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
key: 'catchRawWebhook',
webhookPath: `/webhooks/flows/${currentUserFlow.id}`,
});
const actionConnection = await createConnection({
userId: currentUser.id,
formattedData: {
screenName: 'Test',
authenticationKey: 'test key',
},
});
const actionStep = await createStep({
flowId: currentUserFlow.id,
type: 'action',
connectionId: actionConnection.id,
key: 'translateText',
});
const token = createAuthTokenByUserId(currentUser.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(currentUserFlow.id) })
.expect(200);
const expectedResponsePayload = {
data: {
getFlow: {
active: currentUserFlow.active,
id: currentUserFlow.id,
name: currentUserFlow.name,
status: 'draft',
steps: [
{
appKey: triggerStep.appKey,
connection: null,
iconUrl: `${appConfig.baseUrl}/apps/${triggerStep.appKey}/assets/favicon.svg`,
id: triggerStep.id,
key: 'catchRawWebhook',
parameters: {},
position: 1,
status: triggerStep.status,
type: 'trigger',
webhookUrl: `${appConfig.baseUrl}/webhooks/flows/${currentUserFlow.id}`,
},
{
appKey: actionStep.appKey,
connection: {
createdAt: actionConnection.createdAt.getTime().toString(),
id: actionConnection.id,
verified: actionConnection.verified,
},
iconUrl: `${appConfig.baseUrl}/apps/${actionStep.appKey}/assets/favicon.svg`,
id: actionStep.id,
key: 'translateText',
parameters: {},
position: 2,
status: actionStep.status,
type: 'action',
webhookUrl: 'http://localhost:3000/null',
},
],
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and without isCreator condition', () => {
it('should return executions data of all users', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({ userId: anotherUser.id });
const triggerStep = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
key: 'catchRawWebhook',
webhookPath: `/webhooks/flows/${anotherUserFlow.id}`,
});
const actionConnection = await createConnection({
userId: anotherUser.id,
formattedData: {
screenName: 'Test',
authenticationKey: 'test key',
},
});
const actionStep = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
connectionId: actionConnection.id,
key: 'translateText',
});
const token = createAuthTokenByUserId(currentUser.id);
const response = await request(app)
.post('/graphql')
.set('Authorization', token)
.send({ query: query(anotherUserFlow.id) })
.expect(200);
const expectedResponsePayload = {
data: {
getFlow: {
active: anotherUserFlow.active,
id: anotherUserFlow.id,
name: anotherUserFlow.name,
status: 'draft',
steps: [
{
appKey: triggerStep.appKey,
connection: null,
iconUrl: `${appConfig.baseUrl}/apps/${triggerStep.appKey}/assets/favicon.svg`,
id: triggerStep.id,
key: 'catchRawWebhook',
parameters: {},
position: 1,
status: triggerStep.status,
type: 'trigger',
webhookUrl: `${appConfig.baseUrl}/webhooks/flows/${anotherUserFlow.id}`,
},
{
appKey: actionStep.appKey,
connection: {
createdAt: actionConnection.createdAt.getTime().toString(),
id: actionConnection.id,
verified: actionConnection.verified,
},
iconUrl: `${appConfig.baseUrl}/apps/${actionStep.appKey}/assets/favicon.svg`,
id: actionStep.id,
key: 'translateText',
parameters: {},
position: 2,
status: actionStep.status,
type: 'action',
webhookUrl: 'http://localhost:3000/null',
},
],
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
});
});

View File

@@ -0,0 +1,34 @@
import { ref } from 'objection';
import ExecutionStep from '../../models/execution-step.js';
import Step from '../../models/step.js';
const getStepWithTestExecutions = async (_parent, params, context) => {
const conditions = context.currentUser.can('update', 'Flow');
const userSteps = context.currentUser.$relatedQuery('steps');
const allSteps = Step.query();
const stepBaseQuery = conditions.isCreator ? userSteps : allSteps;
const step = await stepBaseQuery
.clone()
.findOne({ 'steps.id': params.stepId })
.throwIfNotFound();
const previousStepsWithCurrentStep = await stepBaseQuery
.clone()
.withGraphJoined('executionSteps')
.where('flow_id', '=', step.flowId)
.andWhere('position', '<', step.position)
.andWhere(
'executionSteps.created_at',
'=',
ExecutionStep.query()
.max('created_at')
.where('step_id', '=', ref('steps.id'))
.andWhere('status', 'success')
)
.orderBy('steps.position', 'asc');
return previousStepsWithCurrentStep;
};
export default getStepWithTestExecutions;

View File

@@ -0,0 +1,38 @@
import App from '../../models/app.js';
import Connection from '../../models/connection.js';
import globalVariable from '../../helpers/global-variable.js';
const testConnection = async (_parent, params, context) => {
const conditions = context.currentUser.can('update', 'Connection');
const userConnections = context.currentUser.$relatedQuery('connections');
const allConnections = Connection.query();
const connectionBaseQuery = conditions.isCreator
? userConnections
: allConnections;
let connection = await connectionBaseQuery
.clone()
.findOne({
id: params.id,
})
.throwIfNotFound();
const app = await App.findOneByKey(connection.key, false);
const $ = await globalVariable({ connection, app });
let isStillVerified;
try {
isStillVerified = !!(await app.auth.isStillVerified($));
} catch {
isStillVerified = false;
}
connection = await connection.$query().patchAndFetch({
formattedData: connection.formattedData,
verified: isStillVerified,
});
return connection;
};
export default testConnection;

View File

@@ -0,0 +1,23 @@
import getApp from './queries/get-app.js';
import getAppAuthClient from './queries/get-app-auth-client.ee.js';
import getAppAuthClients from './queries/get-app-auth-clients.ee.js';
import getBillingAndUsage from './queries/get-billing-and-usage.ee.js';
import getConnectedApps from './queries/get-connected-apps.js';
import getDynamicData from './queries/get-dynamic-data.js';
import getFlow from './queries/get-flow.js';
import getStepWithTestExecutions from './queries/get-step-with-test-executions.js';
import testConnection from './queries/test-connection.js';
const queryResolvers = {
getApp,
getAppAuthClient,
getAppAuthClients,
getBillingAndUsage,
getConnectedApps,
getDynamicData,
getFlow,
getStepWithTestExecutions,
testConnection,
};
export default queryResolvers;

View File

@@ -1,6 +1,8 @@
import mutationResolvers from './mutation-resolvers.js';
import queryResolvers from './query-resolvers.js';
const resolvers = {
Query: queryResolvers,
Mutation: mutationResolvers,
};

View File

@@ -1,6 +1,19 @@
type Query {
placeholderQuery(name: String): Boolean
getApp(key: String!): App
getAppAuthClient(id: String!): AppAuthClient
getAppAuthClients(appKey: String!, active: Boolean): [AppAuthClient]
getConnectedApps(name: String): [App]
testConnection(id: String!): Connection
getFlow(id: String!): Flow
getStepWithTestExecutions(stepId: String!): [Step]
getDynamicData(
stepId: String!
key: String!
parameters: JSONObject
): JSONObject
getBillingAndUsage: GetBillingAndUsage
}
type Mutation {
createAppConfig(input: CreateAppConfigInput): AppConfig
createAppAuthClient(input: CreateAppAuthClientInput): AppAuthClient
@@ -550,6 +563,43 @@ type License {
verified: Boolean
}
type GetBillingAndUsage {
subscription: Subscription
usage: Usage
}
type MonthlyQuota {
title: String
action: BillingCardAction
}
type NextBillAmount {
title: String
action: BillingCardAction
}
type NextBillDate {
title: String
action: BillingCardAction
}
type BillingCardAction {
type: String
text: String
src: String
}
type Subscription {
status: String
monthlyQuota: MonthlyQuota
nextBillAmount: NextBillAmount
nextBillDate: NextBillDate
}
type Usage {
task: Int
}
type Permission {
id: String
action: String

View File

@@ -40,6 +40,9 @@ export const authenticateUser = async (request, response, next) => {
const isAuthenticatedRule = rule()(isAuthenticated);
export const authenticationRules = {
Query: {
'*': isAuthenticatedRule,
},
Mutation: {
'*': isAuthenticatedRule,
forgotPassword: allow,

View File

@@ -42,7 +42,6 @@ describe('authentication rules', () => {
const { queries, mutations } = getQueryAndMutationNames(authenticationRules);
if (queries.length) {
describe('for queries', () => {
queries.forEach((query) => {
it(`should apply correct rule for query: ${query}`, () => {
@@ -56,7 +55,6 @@ describe('authentication rules', () => {
});
});
});
}
describe('for mutations', () => {
mutations.forEach((mutation) => {

View File

@@ -7,10 +7,6 @@ const authorizationList = {
action: 'read',
subject: 'User',
},
'GET /api/v1/users/:userId/apps': {
action: 'read',
subject: 'Connection',
},
'GET /api/v1/flows/:flowId': {
action: 'read',
subject: 'Flow',
@@ -31,26 +27,14 @@ const authorizationList = {
action: 'update',
subject: 'Flow',
},
'POST /api/v1/steps/:stepId/dynamic-data': {
action: 'update',
subject: 'Flow',
},
'GET /api/v1/connections/:connectionId/flows': {
action: 'read',
subject: 'Flow',
},
'POST /api/v1/connections/:connectionId/test': {
action: 'update',
subject: 'Connection',
},
'GET /api/v1/apps/:appKey/flows': {
action: 'read',
subject: 'Flow',
},
'GET /api/v1/apps/:appKey/connections': {
action: 'read',
subject: 'Connection',
},
'GET /api/v1/executions/:executionId': {
action: 'read',
subject: 'Execution',

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
const config = axios.defaults;
const config = {};
const httpProxyUrl = process.env.http_proxy;
const httpsProxyUrl = process.env.https_proxy;
const supportsProxy = httpProxyUrl || httpsProxyUrl;

View File

@@ -2,7 +2,6 @@ import logger from './logger.js';
import objection from 'objection';
import * as Sentry from './sentry.ee.js';
const { NotFoundError, DataError } = objection;
import HttpError from '../errors/http.js';
// Do not remove `next` argument as the function signature will not fit for an error handler middleware
// eslint-disable-next-line no-unused-vars
@@ -19,17 +18,6 @@ const errorHandler = (error, request, response, next) => {
response.status(400).end();
}
if (error instanceof HttpError) {
const httpErrorPayload = {
errors: JSON.parse(error.message),
meta: {
type: 'HttpError',
},
};
response.status(200).json(httpErrorPayload);
}
const statusCode = error.statusCode || 500;
logger.error(request.method + ' ' + request.url + ' ' + statusCode);
@@ -49,7 +37,7 @@ const errorHandler = (error, request, response, next) => {
const notFoundAppError = (error) => {
return (
error.message.includes('An application with the') &&
error.message.includes('An application with the') ||
error.message.includes("key couldn't be found.")
);
};

View File

@@ -1,6 +1,7 @@
import AES from 'crypto-js/aes.js';
import enc from 'crypto-js/enc-utf8.js';
import appConfig from '../config/app.js';
import AppConfig from './app-config.js';
import Base from './base.js';
class AppAuthClient extends Base {
@@ -59,6 +60,21 @@ class AppAuthClient extends Base {
this.encryptData();
}
async assignCanConnectForAppConfig() {
const appConfig = await AppConfig.query().findOne({ key: this.appKey });
await appConfig?.assignCanConnect();
}
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
await this.assignCanConnectForAppConfig();
}
async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext);
await this.assignCanConnectForAppConfig();
}
async $afterFind() {
this.decryptData();
}

View File

@@ -15,45 +15,48 @@ class AppConfig extends Base {
allowCustomConnection: { type: 'boolean', default: false },
shared: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
canConnect: { type: 'boolean', default: false },
},
};
static relationMappings = () => ({
appAuthClients: {
relation: Base.HasManyRelation,
modelClass: AppAuthClient,
join: {
from: 'app_configs.key',
to: 'app_auth_clients.app_key',
},
},
});
static get virtualAttributes() {
return ['canConnect', 'canCustomConnect'];
}
get canCustomConnect() {
return !this.disabled && this.allowCustomConnection;
}
get canConnect() {
const hasSomeActiveAppAuthClients = !!this.appAuthClients?.some(
(appAuthClient) => appAuthClient.active
);
const shared = this.shared;
const active = this.disabled === false;
const conditions = [hasSomeActiveAppAuthClients, shared, active];
return conditions.every(Boolean);
}
async getApp() {
if (!this.key) return null;
return await App.findOneByKey(this.key);
}
async hasActiveAppAuthClients() {
const appAuthClients = await AppAuthClient.query().where({
appKey: this.key,
});
const hasSomeActiveAppAuthClients = !!appAuthClients?.some(
(appAuthClient) => appAuthClient.active
);
return hasSomeActiveAppAuthClients;
}
async assignCanConnect() {
const shared = this.shared;
const active = this.disabled === false;
const hasSomeActiveAppAuthClients = await this.hasActiveAppAuthClients();
const conditions = [hasSomeActiveAppAuthClients, shared, active];
const canConnect = conditions.every(Boolean);
this.canConnect = canConnect;
}
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
await this.assignCanConnect();
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
await this.assignCanConnect();
}
}
export default AppConfig;

View File

@@ -153,24 +153,6 @@ class Connection extends Base {
return await App.findOneByKey(this.key);
}
async testAndUpdateConnection() {
const app = await this.getApp();
const $ = await globalVariable({ connection: this, app });
let isStillVerified;
try {
isStillVerified = !!(await app.auth.isStillVerified($));
} catch {
isStillVerified = false;
}
return await this.$query().patchAndFetch({
formattedData: this.formattedData,
verified: isStillVerified,
});
}
async verifyWebhook(request) {
if (!this.key) return true;

View File

@@ -160,7 +160,7 @@ class Flow extends Base {
}
async isPaused() {
const user = await this.$relatedQuery('user').withSoftDeleted();
const user = await this.$relatedQuery('user');
const allowedToRunFlows = await user.isAllowedToRunFlows();
return allowedToRunFlows ? false : true;
}

View File

@@ -8,7 +8,6 @@ import ExecutionStep from './execution-step.js';
import Telemetry from '../helpers/telemetry/index.js';
import appConfig from '../config/app.js';
import globalVariable from '../helpers/global-variable.js';
import computeParameters from '../helpers/compute-parameters.js';
class Step extends Base {
static tableName = 'steps';
@@ -218,39 +217,6 @@ class Step extends Base {
return dynamicFields;
}
async createDynamicData(dynamicDataKey, parameters) {
const connection = await this.$relatedQuery('connection');
const flow = await this.$relatedQuery('flow');
const app = await this.getApp();
const $ = await globalVariable({ connection, app, flow, step: this });
const command = app.dynamicData.find((data) => data.key === dynamicDataKey);
for (const parameterKey in parameters) {
const parameterValue = parameters[parameterKey];
$.step.parameters[parameterKey] = parameterValue;
}
const lastExecution = await flow.$relatedQuery('lastExecution');
const lastExecutionId = lastExecution?.id;
const priorExecutionSteps = lastExecutionId
? await ExecutionStep.query().where({
execution_id: lastExecutionId,
})
: [];
const computedParameters = computeParameters(
$.step.parameters,
priorExecutionSteps
);
$.step.parameters = computedParameters;
const dynamicData = (await command.run($)).data;
return dynamicData;
}
async updateWebhookUrl() {
if (this.isAction) return this;

View File

@@ -7,7 +7,6 @@ import { hasValidLicense } from '../helpers/license.ee.js';
import userAbility from '../helpers/user-ability.js';
import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js';
import Base from './base.js';
import App from './app.js';
import Connection from './connection.js';
import Execution from './execution.js';
import Flow from './flow.js';
@@ -156,13 +155,6 @@ class User extends Base {
return conditions.isCreator ? this.$relatedQuery('steps') : Step.query();
}
get authorizedConnections() {
const conditions = this.can('read', 'Connection');
return conditions.isCreator
? this.$relatedQuery('connections')
: Connection.query();
}
get authorizedExecutions() {
const conditions = this.can('read', 'Execution');
return conditions.isCreator
@@ -314,56 +306,6 @@ class User extends Base {
return invoices;
}
async getApps(name) {
const connections = await this.authorizedConnections
.clone()
.select('connections.key')
.where({ draft: false })
.count('connections.id as count')
.groupBy('connections.key');
const flows = await this.authorizedFlows
.clone()
.withGraphJoined('steps')
.orderBy('created_at', 'desc');
const duplicatedUsedApps = flows
.map((flow) => flow.steps.map((step) => step.appKey))
.flat()
.filter(Boolean);
const connectionKeys = connections.map((connection) => connection.key);
const usedApps = [...new Set([...duplicatedUsedApps, ...connectionKeys])];
let apps = await App.findAll(name);
apps = apps
.filter((app) => {
return usedApps.includes(app.key);
})
.map((app) => {
const connection = connections.find(
(connection) => connection.key === app.key
);
app.connectionCount = connection?.count || 0;
app.flowCount = 0;
flows.forEach((flow) => {
const usedFlow = flow.steps.find((step) => step.appKey === app.key);
if (usedFlow) {
app.flowCount += 1;
}
});
return app;
})
.sort((appA, appB) => appA.name.localeCompare(appB.name));
return apps;
}
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);

View File

@@ -6,7 +6,6 @@ import { checkIsEnterprise } from '../../../helpers/check-is-enterprise.js';
import getAppAction from '../../../controllers/api/v1/apps/get-app.js';
import getAppsAction from '../../../controllers/api/v1/apps/get-apps.js';
import getAuthAction from '../../../controllers/api/v1/apps/get-auth.js';
import getConnectionsAction from '../../../controllers/api/v1/apps/get-connections.js';
import getConfigAction from '../../../controllers/api/v1/apps/get-config.ee.js';
import getAuthClientsAction from '../../../controllers/api/v1/apps/get-auth-clients.ee.js';
import getAuthClientAction from '../../../controllers/api/v1/apps/get-auth-client.ee.js';
@@ -22,13 +21,6 @@ router.get('/', authenticateUser, asyncHandler(getAppsAction));
router.get('/:appKey', authenticateUser, asyncHandler(getAppAction));
router.get('/:appKey/auth', authenticateUser, asyncHandler(getAuthAction));
router.get(
'/:appKey/connections',
authenticateUser,
authorizeUser,
asyncHandler(getConnectionsAction)
);
router.get(
'/:appKey/config',
authenticateUser,

View File

@@ -3,7 +3,6 @@ import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.js';
import { authorizeUser } from '../../../helpers/authorization.js';
import getFlowsAction from '../../../controllers/api/v1/connections/get-flows.js';
import createTestAction from '../../../controllers/api/v1/connections/create-test.js';
const router = Router();
@@ -14,11 +13,4 @@ router.get(
asyncHandler(getFlowsAction)
);
router.post(
'/:connectionId/test',
authenticateUser,
authorizeUser,
asyncHandler(createTestAction)
);
export default router;

View File

@@ -5,7 +5,6 @@ import { authorizeUser } from '../../../helpers/authorization.js';
import getConnectionAction from '../../../controllers/api/v1/steps/get-connection.js';
import getPreviousStepsAction from '../../../controllers/api/v1/steps/get-previous-steps.js';
import createDynamicFieldsAction from '../../../controllers/api/v1/steps/create-dynamic-fields.js';
import createDynamicDataAction from '../../../controllers/api/v1/steps/create-dynamic-data.js';
const router = Router();
@@ -30,11 +29,4 @@ router.post(
asyncHandler(createDynamicFieldsAction)
);
router.post(
'/:stepId/dynamic-data',
authenticateUser,
authorizeUser,
asyncHandler(createDynamicDataAction)
);
export default router;

View File

@@ -1,11 +1,9 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.js';
import { authorizeUser } from '../../../helpers/authorization.js';
import checkIsCloud from '../../../helpers/check-is-cloud.js';
import getCurrentUserAction from '../../../controllers/api/v1/users/get-current-user.js';
import getUserTrialAction from '../../../controllers/api/v1/users/get-user-trial.ee.js';
import getAppsAction from '../../../controllers/api/v1/users/get-apps.js';
import getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js';
import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.ee.js';
import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-and-usage.ee.js';
@@ -13,14 +11,6 @@ import getPlanAndUsageAction from '../../../controllers/api/v1/users/get-plan-an
const router = Router();
router.get('/me', authenticateUser, asyncHandler(getCurrentUserAction));
router.get(
'/:userId/apps',
authenticateUser,
authorizeUser,
asyncHandler(getAppsAction)
);
router.get(
'/invoices',
authenticateUser,

View File

@@ -1,22 +1,12 @@
const appSerializer = (app) => {
let appData = {
key: app.key,
return {
name: app.name,
key: app.key,
iconUrl: app.iconUrl,
primaryColor: app.primaryColor,
authDocUrl: app.authDocUrl,
supportsConnections: app.supportsConnections,
primaryColor: app.primaryColor,
};
if (app.connectionCount) {
appData.connectionCount = app.connectionCount;
}
if (app.flowCount) {
appData.flowCount = app.flowCount;
}
return appData;
};
export default appSerializer;

View File

@@ -6,8 +6,6 @@ const flowSerializer = (flow) => {
name: flow.name,
active: flow.active,
status: flow.status,
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
};
if (flow.steps?.length > 0) {

View File

@@ -27,8 +27,6 @@ describe('flowSerializer', () => {
name: flow.name,
active: flow.active,
status: flow.status,
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
};
expect(flowSerializer(flow)).toEqual(expectedPayload);

View File

@@ -19,8 +19,6 @@ export const createStep = async (params = {}) => {
params.appKey =
params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook');
params.parameters = params?.parameters || {};
const step = await Step.query().insertAndFetch(params);
return step;

View File

@@ -1,25 +0,0 @@
const getConnectionsMock = (connections) => {
return {
data: connections.map((connection) => ({
id: connection.id,
key: connection.key,
reconnectable: connection.reconnectable,
verified: connection.verified,
appAuthClientId: connection.appAuthClientId,
formattedData: {
screenName: connection.formattedData.screenName,
},
createdAt: connection.createdAt.getTime(),
updatedAt: connection.updatedAt.getTime(),
})),
meta: {
count: connections.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Connection',
},
};
};
export default getConnectionsMock;

View File

@@ -9,8 +9,6 @@ const getExecutionMock = async (execution, flow, steps) => {
name: flow.name,
active: flow.active,
status: flow.active ? 'published' : 'draft',
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: steps.map((step) => ({
id: step.id,
type: step.type,

View File

@@ -10,8 +10,6 @@ const getExecutionsMock = async (executions, flow, steps) => {
name: flow.name,
active: flow.active,
status: flow.active ? 'published' : 'draft',
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: steps.map((step) => ({
id: step.id,
type: step.type,

View File

@@ -4,8 +4,6 @@ const getFlowMock = async (flow, steps) => {
id: flow.id,
name: flow.name,
status: flow.active ? 'published' : 'draft',
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: steps.map((step) => ({
appKey: step.appKey,
iconUrl: step.iconUrl,

View File

@@ -7,8 +7,6 @@ const getFlowsMock = async (flows, steps) => {
id: flow.id,
name: flow.name,
status: flow.active ? 'published' : 'draft',
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: flowSteps.map((step) => ({
appKey: step.appKey,
iconUrl: step.iconUrl,

View File

@@ -1,55 +0,0 @@
const getAppsMock = () => {
const appsData = [
{
authDocUrl: 'https://automatisch.io/docs/apps/deepl/connection',
connectionCount: 1,
flowCount: 1,
iconUrl: 'http://localhost:3000/apps/deepl/assets/favicon.svg',
key: 'deepl',
name: 'DeepL',
primaryColor: '0d2d45',
supportsConnections: true,
},
{
authDocUrl: 'https://automatisch.io/docs/apps/github/connection',
connectionCount: 1,
flowCount: 1,
iconUrl: 'http://localhost:3000/apps/github/assets/favicon.svg',
key: 'github',
name: 'GitHub',
primaryColor: '000000',
supportsConnections: true,
},
{
authDocUrl: 'https://automatisch.io/docs/apps/slack/connection',
flowCount: 1,
iconUrl: 'http://localhost:3000/apps/slack/assets/favicon.svg',
key: 'slack',
name: 'Slack',
primaryColor: '4a154b',
supportsConnections: true,
},
{
authDocUrl: 'https://automatisch.io/docs/apps/webhook/connection',
flowCount: 1,
iconUrl: 'http://localhost:3000/apps/webhook/assets/favicon.svg',
key: 'webhook',
name: 'Webhook',
primaryColor: '0059F7',
supportsConnections: false,
},
];
return {
data: appsData,
meta: {
count: appsData.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Object',
},
};
};
export default getAppsMock;

View File

@@ -1,7 +1,6 @@
import { Model } from 'objection';
import { client as knex } from '../../src/config/database.js';
import logger from '../../src/helpers/logger.js';
import { vi } from 'vitest';
global.beforeAll(async () => {
global.knex = null;
@@ -23,8 +22,8 @@ global.afterEach(async () => {
await global.knex.rollback();
Model.knex(knex);
vi.restoreAllMocks();
vi.clearAllMocks();
// jest.restoreAllMocks();
// jest.clearAllMocks();
});
global.afterAll(async () => {

View File

@@ -345,12 +345,6 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/scheduler/connection' },
],
},
{
text: 'SFTP',
collapsible: true,
collapsed: true,
items: [{ text: 'Connection', link: '/apps/sftp/connection' }],
},
{
text: 'SignalWire',
collapsible: true,

View File

@@ -1,15 +0,0 @@
# SFTP
:::info
This page explains the steps you need to follow to set up the SFTP connection in Automatisch. If any of the steps are outdated, please let us know!
:::
SSH File Transfer Protocol (also known as Secure File Transfer Protocol or SFTP) is a network protocol that provides file access, file transfer, and file management over any reliable data stream. You need to provide the following information from Automatisch by using SFTP connection.
1. Write any screen name to be displayed in Automatisch.
2. Fill host address field with the SFTP host address.
3. Fill username field with the SFTP username.
4. Fill password field with the SFTP password.
5. Fill port field with the SFTP port. Default is 21.
6. Click **Submit** button on Automatisch.
7. Now, you can start using the SFTP connection with Automatisch.

View File

@@ -1 +0,0 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 103.95"><title>sftp</title><path d="M21.56,47.27l6.21-.39a4.56,4.56,0,0,0,.82,2.29,3.45,3.45,0,0,0,2.88,1.28,3.16,3.16,0,0,0,2.15-.65,2,2,0,0,0,.76-1.52,2,2,0,0,0-.71-1.48,8.45,8.45,0,0,0-3.34-1.22A14.06,14.06,0,0,1,24.21,43a5.14,5.14,0,0,1-1.85-4.07,5.44,5.44,0,0,1,.95-3.07,6.19,6.19,0,0,1,2.84-2.28,13.26,13.26,0,0,1,5.19-.83,10.58,10.58,0,0,1,6.18,1.51,6.53,6.53,0,0,1,2.53,4.8l-6.14.37a3.38,3.38,0,0,0-1-2.1,3.34,3.34,0,0,0-2.17-.65,2.63,2.63,0,0,0-1.72.48,1.49,1.49,0,0,0-.58,1.18,1.19,1.19,0,0,0,.47.9,5.23,5.23,0,0,0,2.18.78,27.38,27.38,0,0,1,6.13,1.87,6.38,6.38,0,0,1,2.7,2.35,5.94,5.94,0,0,1,.84,3.13,6.73,6.73,0,0,1-1.12,3.75,7.1,7.1,0,0,1-3.15,2.6,12.64,12.64,0,0,1-5.08.89c-3.6,0-6.09-.7-7.47-2.08a8.18,8.18,0,0,1-2.35-5.27ZM9.07,15.63h3.09V8.2A1.87,1.87,0,0,1,14,6.33h3.32V1.87A1.88,1.88,0,0,1,19.23,0H49.57a1.87,1.87,0,0,1,1.87,1.87V6.33h59.5a1.87,1.87,0,0,1,1.87,1.87v7.43h3.41a1.87,1.87,0,0,1,1.87,1.87,1.75,1.75,0,0,1,0,.4L111.51,66a7.12,7.12,0,0,1-2,4.07,5.62,5.62,0,0,1-4,1.69H69.25v5.57h.17A2.67,2.67,0,0,1,72.08,80v4.81h13a2.67,2.67,0,0,1,2.54,1.87h27.84a7,7,0,0,1,6.85,4.48,8.05,8.05,0,0,1,.57,3,8.14,8.14,0,0,1-.57,3,7,7,0,0,1-6.85,4.47H87.73A2.67,2.67,0,0,1,85.08,104H39.92a2.67,2.67,0,0,1-2.65-2.37H7.42A7,7,0,0,1,.57,97.11a8.14,8.14,0,0,1-.57-3,8.05,8.05,0,0,1,.57-3,7,7,0,0,1,6.85-4.48h30a2.67,2.67,0,0,1,2.54-1.87H50.68V80a2.67,2.67,0,0,1,2.66-2.66h1V71.75H18A5.69,5.69,0,0,1,13.89,70,6.84,6.84,0,0,1,12,65.92L6.92,17.69a1.83,1.83,0,0,1,.37-1.33,1.86,1.86,0,0,1,1.29-.71,4,4,0,0,1,.49,0ZM57.86,77.32h7.86v-5.5H57.86v5.5ZM87.74,90.19v7.86h27.72A3.63,3.63,0,0,0,119,95.8a4.68,4.68,0,0,0,.31-1.68,4.63,4.63,0,0,0-.31-1.67,3.63,3.63,0,0,0-3.58-2.26ZM37.26,98.05V90.19H7.42a3.63,3.63,0,0,0-3.58,2.26,4.63,4.63,0,0,0-.31,1.67,4.73,4.73,0,0,0,.31,1.68,3.63,3.63,0,0,0,3.58,2.25ZM10.85,19.37l4.84,46.17a3.14,3.14,0,0,0,.83,1.86A2.07,2.07,0,0,0,18,68h87.54a2,2,0,0,0,1.41-.6,3.28,3.28,0,0,0,.88-1.92l6.28-46.12Zm5.05-9.3v5.55l93.17-.79V10.07H49.57A1.87,1.87,0,0,1,47.69,8.2V3.74H21.1V8.2a1.87,1.87,0,0,1-1.87,1.87Zm28.3,23H60.35v4.54H50.76v3.7H59v4.28H50.76v8.62H44.2V33.12Zm18.35,0H82.42v5.22H75.75V54.26H69.22V38.34H62.55V33.12Zm22.93,0H96.35a7.48,7.48,0,0,1,5.32,1.68,6.36,6.36,0,0,1,1.77,4.82,6.55,6.55,0,0,1-1.93,5q-1.94,1.82-5.89,1.81H92v7.82H85.48V33.12Zm6.56,9h1.61a4.14,4.14,0,0,0,2.67-.66,2.13,2.13,0,0,0,.77-1.68,2.37,2.37,0,0,0-.67-1.69,3.49,3.49,0,0,0-2.51-.69H92v4.72Z"/></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -15,7 +15,7 @@ function AccountDropdownMenu(props) {
const navigate = useNavigate();
const { open, onClose, anchorEl, id } = props;
const logout = async () => {
authentication.removeToken();
authentication.updateToken('');
await apolloClient.clearStore();
onClose();
navigate(URLS.LOGIN);

View File

@@ -17,7 +17,6 @@ import useFormatMessage from 'hooks/useFormatMessage';
import { generateExternalLink } from 'helpers/translationValues';
import { Form } from './style';
import useAppAuth from 'hooks/useAppAuth';
import { useQueryClient } from '@tanstack/react-query';
function AddAppConnection(props) {
const { application, connectionId, onClose } = props;
@@ -37,7 +36,6 @@ function AddAppConnection(props) {
appAuthClientId,
useShared: !!appAuthClientId,
});
const queryClient = useQueryClient();
React.useEffect(function relayProviderData() {
if (window.opener) {
@@ -80,10 +78,6 @@ function AddAppConnection(props) {
const response = await authenticate({
fields: data,
});
await queryClient.invalidateQueries({
queryKey: ['apps', key, 'connections'],
});
onClose(response);
} catch (err) {
const error = err;

View File

@@ -10,18 +10,16 @@ import Chip from '@mui/material/Chip';
import Button from '@mui/material/Button';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useAdminAppAuthClients from 'hooks/useAdminAppAuthClients';
import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import NoResultFound from 'components/NoResultFound';
function AdminApplicationAuthClients(props) {
const { appKey } = props;
const formatMessage = useFormatMessage();
const { data: appAuthClients, isLoading } = useAdminAppAuthClients(appKey);
if (isLoading)
const { appAuthClients, loading } = useAppAuthClients({ appKey });
if (loading)
return <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />;
if (!appAuthClients?.data.length) {
if (!appAuthClients?.length) {
return (
<NoResultFound
to={URLS.ADMIN_APP_AUTH_CLIENTS_CREATE(appKey)}
@@ -29,8 +27,7 @@ function AdminApplicationAuthClients(props) {
/>
);
}
const sortedAuthClients = appAuthClients.data.slice().sort((a, b) => {
const sortedAuthClients = appAuthClients.slice().sort((a, b) => {
if (a.id < b.id) {
return -1;
}
@@ -39,7 +36,6 @@ function AdminApplicationAuthClients(props) {
}
return 0;
});
return (
<div>
{sortedAuthClients.map((client) => (

View File

@@ -6,33 +6,29 @@ import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import * as React from 'react';
import useAppAuthClients from 'hooks/useAppAuthClients';
import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import useFormatMessage from 'hooks/useFormatMessage';
function AppAuthClientsDialog(props) {
const { appKey, onClientClick, onClose } = props;
const { data: appAuthClients } = useAppAuthClients(appKey);
const { appAuthClients } = useAppAuthClients({ appKey, active: true });
const formatMessage = useFormatMessage();
React.useEffect(
function autoAuthenticateSingleClient() {
if (appAuthClients?.data.length === 1) {
onClientClick(appAuthClients.data[0].id);
if (appAuthClients?.length === 1) {
onClientClick(appAuthClients[0].id);
}
},
[appAuthClients?.data],
[appAuthClients],
);
if (!appAuthClients?.data.length || appAuthClients?.data.length === 1)
if (!appAuthClients?.length || appAuthClients?.length === 1)
return <React.Fragment />;
return (
<Dialog onClose={onClose} open={true}>
<DialogTitle>{formatMessage('appAuthClientsDialog.title')}</DialogTitle>
<List sx={{ pt: 0 }}>
{appAuthClients.data.map((appAuthClient) => (
{appAuthClients.map((appAuthClient) => (
<ListItem disableGutters key={appAuthClient.id}>
<ListItemButton onClick={() => onClientClick(appAuthClient.id)}>
<ListItemText primary={appAuthClient.name} />

View File

@@ -7,7 +7,6 @@ import MenuItem from '@mui/material/MenuItem';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import { ConnectionPropType } from 'propTypes/propTypes';
import { useQueryClient } from '@tanstack/react-query';
function ContextMenu(props) {
const {
@@ -19,24 +18,15 @@ function ContextMenu(props) {
disableReconnection,
} = props;
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const createActionHandler = React.useCallback(
(action) => {
return async function clickHandler(event) {
return function clickHandler(event) {
onMenuItemClick(event, action);
if (['test', 'reconnect', 'delete'].includes(action.type)) {
await queryClient.invalidateQueries({
queryKey: ['apps', appKey, 'connections'],
});
}
onClose();
};
},
[onMenuItemClick, onClose, queryClient],
[onMenuItemClick, onClose],
);
return (
<Menu
open={true}

View File

@@ -1,24 +1,21 @@
import * as React from 'react';
import { useMutation } from '@apollo/client';
import { useLazyQuery, useMutation } from '@apollo/client';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/Error';
import Skeleton from '@mui/material/Skeleton';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import { DateTime } from 'luxon';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import { DateTime } from 'luxon';
import * as React from 'react';
import ConnectionContextMenu from 'components/AppConnectionContextMenu';
import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection';
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
import useFormatMessage from 'hooks/useFormatMessage';
import { ConnectionPropType } from 'propTypes/propTypes';
import { CardContent, Typography } from './style';
import useConnectionFlows from 'hooks/useConnectionFlows';
import useTestConnection from 'hooks/useTestConnection';
const countTranslation = (value) => (
<>
@@ -26,39 +23,36 @@ const countTranslation = (value) => (
<br />
</>
);
function AppConnectionRow(props) {
const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const { id, key, formattedData, verified, createdAt, reconnectable } =
props.connection;
const [verificationVisible, setVerificationVisible] = React.useState(false);
const contextButtonRef = React.useRef(null);
const [anchorEl, setAnchorEl] = React.useState(null);
const [deleteConnection] = useMutation(DELETE_CONNECTION);
const { mutate: testConnection, isPending: isTestConnectionPending } =
useTestConnection(
{ connectionId: id },
{
onSettled: () => {
const [testConnection, { called: testCalled, loading: testLoading }] =
useLazyQuery(TEST_CONNECTION, {
fetchPolicy: 'network-only',
onCompleted: () => {
setTimeout(() => setVerificationVisible(false), 3000);
},
onError: () => {
setTimeout(() => setVerificationVisible(false), 3000);
},
);
});
const [deleteConnection] = useMutation(DELETE_CONNECTION);
const formatMessage = useFormatMessage();
const {
id,
key,
formattedData,
verified,
createdAt,
flowCount,
reconnectable,
} = props.connection;
const contextButtonRef = React.useRef(null);
const [anchorEl, setAnchorEl] = React.useState(null);
const handleClose = () => {
setAnchorEl(null);
};
const { data, isLoading: isConnectionFlowsLoading } = useConnectionFlows({
connectionId: id,
});
const flowCount = data?.meta?.count;
const onContextMenuClick = () => setAnchorEl(contextButtonRef.current);
const onContextMenuAction = React.useCallback(
async (event, action) => {
if (action.type === 'delete') {
@@ -74,7 +68,6 @@ function AppConnectionRow(props) {
});
},
});
enqueueSnackbar(formatMessage('connection.deletedMessage'), {
variant: 'success',
SnackbarProps: {
@@ -88,11 +81,9 @@ function AppConnectionRow(props) {
},
[deleteConnection, id, testConnection, formatMessage, enqueueSnackbar],
);
const relativeCreatedAt = DateTime.fromMillis(
parseInt(createdAt, 10),
).toRelative();
return (
<>
<Card sx={{ my: 2 }} data-test="app-connection-row">
@@ -112,7 +103,7 @@ function AppConnectionRow(props) {
<Box>
<Stack direction="row" alignItems="center" spacing={1}>
{verificationVisible && isTestConnectionPending && (
{verificationVisible && testCalled && testLoading && (
<>
<CircularProgress size={16} />
<Typography variant="caption">
@@ -121,7 +112,8 @@ function AppConnectionRow(props) {
</>
)}
{verificationVisible &&
!isTestConnectionPending &&
testCalled &&
!testLoading &&
verified && (
<>
<CheckCircleIcon fontSize="small" color="success" />
@@ -131,7 +123,8 @@ function AppConnectionRow(props) {
</>
)}
{verificationVisible &&
!isTestConnectionPending &&
testCalled &&
!testLoading &&
!verified && (
<>
<ErrorIcon fontSize="small" color="error" />
@@ -150,13 +143,7 @@ function AppConnectionRow(props) {
sx={{ display: ['none', 'inline-block'] }}
>
{formatMessage('connection.flowCount', {
count: countTranslation(
isConnectionFlowsLoading ? (
<Skeleton variant="text" width={15} />
) : (
flowCount
),
),
count: countTranslation(flowCount),
})}
</Typography>
</Box>

View File

@@ -1,19 +1,20 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { useQuery } from '@apollo/client';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
import AppConnectionRow from 'components/AppConnectionRow';
import NoResultFound from 'components/NoResultFound';
import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls';
import useAppConnections from 'hooks/useAppConnections';
function AppConnections(props) {
const { appKey } = props;
const formatMessage = useFormatMessage();
const { data } = useAppConnections(appKey);
const appConnections = data?.data || [];
const { data } = useQuery(GET_APP_CONNECTIONS, {
variables: { key: appKey },
});
const appConnections = data?.getApp?.connections || [];
const hasConnections = appConnections?.length;
if (!hasConnections) {
return (
<NoResultFound

View File

@@ -47,7 +47,7 @@ function AppFlows(props) {
return (
<>
{flows?.map((appFlow) => (
<AppFlowRow key={appFlow.id} flow={appFlow} appKey={appKey} />
<AppFlowRow key={appFlow.id} flow={appFlow} />
))}
{pageInfo && pageInfo.totalPages > 1 && (

View File

@@ -2,7 +2,6 @@ import * as React from 'react';
import { useLocation } from 'react-router-dom';
import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import useFormatMessage from 'hooks/useFormatMessage';
export default function CheckoutCompletedAlert() {
@@ -10,9 +9,7 @@ export default function CheckoutCompletedAlert() {
const location = useLocation();
const state = location.state;
const checkoutCompleted = state?.checkoutCompleted;
if (!checkoutCompleted) return <React.Fragment />;
return (
<Alert
severity="success"

View File

@@ -1,4 +1,5 @@
import PropTypes from 'prop-types';
import { useLazyQuery, useQuery } from '@apollo/client';
import Autocomplete from '@mui/material/Autocomplete';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
@@ -11,6 +12,8 @@ import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import useAppConfig from 'hooks/useAppConfig.ee';
import { EditorContext } from 'contexts/Editor';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
import useAuthenticateApp from 'hooks/useAuthenticateApp.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import {
@@ -18,10 +21,6 @@ import {
StepPropType,
SubstepPropType,
} from 'propTypes/propTypes';
import useStepConnection from 'hooks/useStepConnection';
import { useQueryClient } from '@tanstack/react-query';
import useAppConnections from 'hooks/useAppConnections';
import useTestConnection from 'hooks/useTestConnection';
const ADD_CONNECTION_VALUE = 'ADD_CONNECTION';
const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION';
@@ -45,42 +44,40 @@ function ChooseConnectionSubstep(props) {
onChange,
application,
} = props;
const { appKey } = step;
const { connection, appKey } = step;
const formatMessage = useFormatMessage();
const editorContext = React.useContext(EditorContext);
const [showAddConnectionDialog, setShowAddConnectionDialog] =
React.useState(false);
const [showAddSharedConnectionDialog, setShowAddSharedConnectionDialog] =
React.useState(false);
const queryClient = useQueryClient();
const { authenticate } = useAuthenticateApp({
appKey: application.key,
useShared: true,
});
const {
data,
isLoading: isAppConnectionsLoading,
refetch,
} = useAppConnections(appKey);
const { data, loading, refetch } = useQuery(GET_APP_CONNECTIONS, {
variables: { key: appKey },
});
const { data: appConfig } = useAppConfig(application.key);
const { data: stepConnectionData } = useStepConnection(step.id);
const stepConnection = stepConnectionData?.data;
// TODO: show detailed error when connection test/verification fails
const { mutate: testConnection, isPending: isTestConnectionPending } =
useTestConnection({
connectionId: stepConnection?.id,
const [
testConnection,
{ loading: testResultLoading, refetch: retestConnection },
] = useLazyQuery(TEST_CONNECTION, {
variables: {
id: connection?.id,
},
});
React.useEffect(() => {
if (stepConnection?.id) {
if (connection?.id) {
testConnection({
variables: {
id: stepConnection.id,
id: connection.id,
},
});
}
@@ -88,10 +85,11 @@ function ChooseConnectionSubstep(props) {
}, []);
const connectionOptions = React.useMemo(() => {
const appWithConnections = data?.data;
const appWithConnections = data?.getApp;
const options =
appWithConnections?.map((connection) => optionGenerator(connection)) ||
[];
appWithConnections?.connections?.map((connection) =>
optionGenerator(connection),
) || [];
if (!appConfig?.data || appConfig?.data?.canCustomConnect) {
options.push({
@@ -156,9 +154,8 @@ function ChooseConnectionSubstep(props) {
},
[onChange, refetch, step],
);
const handleChange = React.useCallback(
async (event, selectedOption) => {
(event, selectedOption) => {
if (typeof selectedOption === 'object') {
// TODO: try to simplify type casting below.
const typedSelectedOption = selectedOption;
@@ -175,7 +172,7 @@ function ChooseConnectionSubstep(props) {
return;
}
if (connectionId !== stepConnection?.id) {
if (connectionId !== step.connection?.id) {
onChange({
step: {
...step,
@@ -184,23 +181,19 @@ function ChooseConnectionSubstep(props) {
},
},
});
await queryClient.invalidateQueries({
queryKey: ['steps', step.id, 'connection'],
});
}
}
},
[step, onChange, queryClient],
[step, onChange],
);
React.useEffect(() => {
if (stepConnection?.id) {
testConnection({
id: stepConnection?.id,
if (step.connection?.id) {
retestConnection({
id: step.connection.id,
});
}
}, [stepConnection?.id, testConnection]);
}, [step.connection?.id, retestConnection]);
const onToggle = expanded ? onCollapse : onExpand;
@@ -210,7 +203,7 @@ function ChooseConnectionSubstep(props) {
expanded={expanded}
onClick={onToggle}
title={name}
valid={isTestConnectionPending ? null : stepConnection?.verified}
valid={testResultLoading ? null : connection?.verified}
/>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<ListItem
@@ -236,9 +229,9 @@ function ChooseConnectionSubstep(props) {
required
/>
)}
value={getOption(connectionOptions, stepConnection?.id)}
value={getOption(connectionOptions, connection?.id)}
onChange={handleChange}
loading={isAppConnectionsLoading}
loading={loading}
data-test="choose-connection-autocomplete"
/>
@@ -248,8 +241,8 @@ function ChooseConnectionSubstep(props) {
onClick={onSubmit}
sx={{ mt: 2 }}
disabled={
isTestConnectionPending ||
!stepConnection?.verified ||
testResultLoading ||
!connection?.verified ||
editorContext.readOnly
}
data-test="flow-substep-continue-button"

View File

@@ -61,38 +61,31 @@ function ControlledCustomAutocomplete(props) {
const [isSingleChoice, setSingleChoice] = React.useState(undefined);
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef(null);
const renderElement = React.useCallback(
(props) => <Element {...props} disabled={disabled} />,
[disabled],
);
const [editor] = React.useState(() => customizeEditor(createEditor()));
const [showVariableSuggestions, setShowVariableSuggestions] =
React.useState(false);
let dependsOnValues = [];
if (dependsOn?.length) {
dependsOnValues = watch(dependsOn);
}
React.useEffect(() => {
const ref = ReactEditor.toDOMNode(editor, editor);
resizeObserver.observe(ref);
return () => resizeObserver.unobserve(ref);
}, []);
const promoteValue = () => {
const serializedValue = serialize(editor.children);
controllerOnChange(serializedValue);
};
const resizeObserver = React.useMemo(function syncCustomOptionsPosition() {
return new ResizeObserver(() => {
forceUpdate();
});
}, []);
React.useEffect(() => {
const hasDependencies = dependsOnValues.length;
if (hasDependencies) {
@@ -100,7 +93,6 @@ function ControlledCustomAutocomplete(props) {
resetEditor(editor);
}
}, dependsOnValues);
React.useEffect(
function updateInitialValue() {
const hasOptions = options.length;
@@ -118,19 +110,16 @@ function ControlledCustomAutocomplete(props) {
},
[isInitialValueSet, options, loading],
);
React.useEffect(() => {
if (!showVariableSuggestions && value !== serialize(editor.children)) {
promoteValue();
}
}, [showVariableSuggestions]);
const hideSuggestionsOnShift = (event) => {
if (event.code === 'Tab') {
setShowVariableSuggestions(false);
}
};
const handleKeyDown = (event) => {
hideSuggestionsOnShift(event);
if (event.code === 'Tab') {
@@ -140,18 +129,15 @@ function ControlledCustomAutocomplete(props) {
event.preventDefault();
}
};
const stepsWithVariables = React.useMemo(() => {
return processStepWithExecutions(priorStepsWithExecutions);
}, [priorStepsWithExecutions]);
const handleVariableSuggestionClick = React.useCallback(
(variable) => {
insertVariable(editor, variable, stepsWithVariables);
},
[stepsWithVariables],
);
const handleOptionClick = React.useCallback(
(event, option) => {
event.stopPropagation();
@@ -161,20 +147,17 @@ function ControlledCustomAutocomplete(props) {
},
[stepsWithVariables],
);
const handleClearButtonClick = (event) => {
event.stopPropagation();
resetEditor(editor);
promoteValue();
setSingleChoice(undefined);
};
const reset = (tabIndex) => {
const isOptions = tabIndex === 0;
setSingleChoice(isOptions);
resetEditor(editor, { focus: true });
};
return (
<Slate
editor={editor}

View File

@@ -22,7 +22,7 @@ function DeleteAccountDialog(props) {
const handleConfirm = React.useCallback(async () => {
await deleteCurrentUser();
authentication.removeToken();
authentication.updateToken('');
await apolloClient.clearStore();
navigate(URLS.LOGIN);
}, [deleteCurrentUser, currentUser]);

View File

@@ -25,7 +25,7 @@ function DeleteRoleButton(props) {
const handleConfirm = React.useCallback(async () => {
try {
await deleteRole();
queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] });
queryClient.invalidateQueries({ queryKey: ['roles'] });
setShowConfirmation(false);
enqueueSnackbar(formatMessage('deleteRoleButton.successfullyDeleted'), {
variant: 'success',

View File

@@ -25,6 +25,7 @@ function DeleteUserButton(props) {
try {
await deleteUser();
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'user', userId] });
setShowConfirmation(false);
enqueueSnackbar(formatMessage('deleteUserButton.successfullyDeleted'), {
variant: 'success',

View File

@@ -3,24 +3,47 @@ import { useMutation } from '@apollo/client';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import { GET_FLOW } from 'graphql/queries/get-flow';
import { CREATE_STEP } from 'graphql/mutations/create-step';
import { UPDATE_STEP } from 'graphql/mutations/update-step';
import FlowStep from 'components/FlowStep';
import { FlowPropType } from 'propTypes/propTypes';
import { useQueryClient } from '@tanstack/react-query';
function updateHandlerFactory(flowId, previousStepId) {
return function createStepUpdateHandler(cache, mutationResult) {
const { data } = mutationResult;
const { createStep: createdStep } = data;
const { getFlow: flow } = cache.readQuery({
query: GET_FLOW,
variables: { id: flowId },
});
const steps = flow.steps.reduce((steps, currentStep) => {
if (currentStep.id === previousStepId) {
return [...steps, currentStep, createdStep];
}
return [...steps, currentStep];
}, []);
cache.writeQuery({
query: GET_FLOW,
variables: { id: flowId },
data: { getFlow: { ...flow, steps } },
});
};
}
function Editor(props) {
const [updateStep] = useMutation(UPDATE_STEP);
const [createStep, { loading: creationInProgress }] =
useMutation(CREATE_STEP);
const [createStep, { loading: creationInProgress }] = useMutation(
CREATE_STEP,
{
refetchQueries: ['GetFlow'],
},
);
const { flow } = props;
const [triggerStep] = flow.steps;
const [currentStepId, setCurrentStepId] = React.useState(triggerStep.id);
const queryClient = useQueryClient();
const onStepChange = React.useCallback(
async (step) => {
(step) => {
const mutationInput = {
id: step.id,
key: step.key,
@@ -32,20 +55,13 @@ function Editor(props) {
id: flow.id,
},
};
if (step.appKey) {
mutationInput.appKey = step.appKey;
}
await updateStep({ variables: { input: mutationInput } });
await queryClient.invalidateQueries({
queryKey: ['steps', step.id, 'connection'],
});
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
updateStep({ variables: { input: mutationInput } });
},
[updateStep, flow.id, queryClient],
[updateStep, flow.id],
);
const addStep = React.useCallback(
async (previousStepId) => {
const mutationInput = {
@@ -56,24 +72,20 @@ function Editor(props) {
id: flow.id,
},
};
const createdStep = await createStep({
variables: { input: mutationInput },
update: updateHandlerFactory(flow.id, previousStepId),
});
const createdStepId = createdStep.data.createStep.id;
setCurrentStepId(createdStepId);
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
},
[createStep, flow.id, queryClient],
[createStep, flow.id],
);
const openNextStep = React.useCallback((nextStep) => {
return () => {
setCurrentStepId(nextStep?.id);
};
}, []);
return (
<Box
display="flex"
@@ -94,7 +106,6 @@ function Editor(props) {
onOpen={() => setCurrentStepId(step.id)}
onClose={() => setCurrentStepId(null)}
onChange={onStepChange}
flowId={flow.id}
onContinue={openNextStep(steps[index + 1])}
/>

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { Link, useParams } from 'react-router-dom';
import { useMutation } from '@apollo/client';
import { useMutation, useQuery } from '@apollo/client';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
@@ -8,7 +8,6 @@ import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import Snackbar from '@mui/material/Snackbar';
import { EditorProvider } from 'contexts/Editor';
import EditableTypography from 'components/EditableTypography';
import Container from 'components/Container';
@@ -16,20 +15,17 @@ import Editor from 'components/Editor';
import useFormatMessage from 'hooks/useFormatMessage';
import { UPDATE_FLOW_STATUS } from 'graphql/mutations/update-flow-status';
import { UPDATE_FLOW } from 'graphql/mutations/update-flow';
import { GET_FLOW } from 'graphql/queries/get-flow';
import * as URLS from 'config/urls';
import { TopBar } from './style';
import useFlow from 'hooks/useFlow';
import { useQueryClient } from '@tanstack/react-query';
export default function EditorLayout() {
const { flowId } = useParams();
const formatMessage = useFormatMessage();
const [updateFlow] = useMutation(UPDATE_FLOW);
const [updateFlowStatus] = useMutation(UPDATE_FLOW_STATUS);
const { data, isLoading: isFlowLoading } = useFlow(flowId);
const flow = data?.data;
const queryClient = useQueryClient();
const { data, loading } = useQuery(GET_FLOW, { variables: { id: flowId } });
const flow = data?.getFlow;
const onFlowNameUpdate = React.useCallback(
async (name) => {
await updateFlow({
@@ -42,17 +38,14 @@ export default function EditorLayout() {
optimisticResponse: {
updateFlow: {
__typename: 'Flow',
id: flowId,
id: flow?.id,
name,
},
},
});
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
},
[flowId, queryClient],
[flow?.id],
);
const onFlowStatusUpdate = React.useCallback(
async (active) => {
await updateFlowStatus({
@@ -65,17 +58,14 @@ export default function EditorLayout() {
optimisticResponse: {
updateFlowStatus: {
__typename: 'Flow',
id: flowId,
id: flow?.id,
active,
},
},
});
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
},
[flowId, queryClient],
[flow?.id],
);
return (
<>
<TopBar
@@ -104,7 +94,7 @@ export default function EditorLayout() {
</IconButton>
</Tooltip>
{!isFlowLoading && (
{!loading && (
<EditableTypography
variant="body1"
onConfirm={onFlowNameUpdate}
@@ -134,7 +124,7 @@ export default function EditorLayout() {
<Stack direction="column" height="100%">
<Container maxWidth="md">
<EditorProvider value={{ readOnly: !!flow?.active }}>
{!flow && !isFlowLoading && 'not found'}
{!flow && !loading && 'not found'}
{flow && <Editor flow={flow} />}
</EditorProvider>

View File

@@ -2,12 +2,9 @@ import PropTypes from 'prop-types';
import { useMutation } from '@apollo/client';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useQueryClient } from '@tanstack/react-query';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react';
import { Link } from 'react-router-dom';
import Can from 'components/Can';
import * as URLS from 'config/urls';
import { DELETE_FLOW } from 'graphql/mutations/delete-flow';
@@ -15,33 +12,25 @@ import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow';
import useFormatMessage from 'hooks/useFormatMessage';
function ContextMenu(props) {
const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } =
props;
const { flowId, onClose, anchorEl } = props;
const enqueueSnackbar = useEnqueueSnackbar();
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const [duplicateFlow] = useMutation(DUPLICATE_FLOW);
const [deleteFlow] = useMutation(DELETE_FLOW);
const [duplicateFlow] = useMutation(DUPLICATE_FLOW, {
refetchQueries: ['GetFlows'],
});
const formatMessage = useFormatMessage();
const onFlowDuplicate = React.useCallback(async () => {
await duplicateFlow({
variables: { input: { id: flowId } },
});
await queryClient.invalidateQueries({
queryKey: ['apps', appKey, 'flows'],
});
enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-duplicate-flow-success',
},
});
onDuplicateFlow?.();
onClose();
}, [flowId, onClose, duplicateFlow, queryClient, onDuplicateFlow]);
}, [flowId, onClose, duplicateFlow]);
const onFlowDelete = React.useCallback(async () => {
await deleteFlow({
variables: { input: { id: flowId } },
@@ -55,18 +44,11 @@ function ContextMenu(props) {
});
},
});
await queryClient.invalidateQueries({
queryKey: ['apps', appKey, 'flows'],
});
enqueueSnackbar(formatMessage('flow.successfullyDeleted'), {
variant: 'success',
});
onDeleteFlow?.();
onClose();
}, [flowId, onClose, deleteFlow, queryClient, onDeleteFlow]);
}, [flowId, onClose, deleteFlow]);
return (
<Menu
open={true}
@@ -108,9 +90,6 @@ ContextMenu.propTypes = {
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
onDeleteFlow: PropTypes.func,
onDuplicateFlow: PropTypes.func,
appKey: PropTypes.string.isRequired,
};
export default ContextMenu;

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import Card from '@mui/material/Card';
import IconButton from '@mui/material/IconButton';
@@ -7,7 +6,6 @@ import CardActionArea from '@mui/material/CardActionArea';
import Chip from '@mui/material/Chip';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import { DateTime } from 'luxon';
import FlowAppIcons from 'components/FlowAppIcons';
import FlowContextMenu from 'components/FlowContextMenu';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -37,7 +35,7 @@ function FlowRow(props) {
const formatMessage = useFormatMessage();
const contextButtonRef = React.useRef(null);
const [anchorEl, setAnchorEl] = React.useState(null);
const { flow, onDuplicateFlow, onDeleteFlow, appKey } = props;
const { flow } = props;
const handleClose = () => {
setAnchorEl(null);
};
@@ -114,9 +112,6 @@ function FlowRow(props) {
flowId={flow.id}
onClose={handleClose}
anchorEl={anchorEl}
onDeleteFlow={onDeleteFlow}
onDuplicateFlow={onDuplicateFlow}
appKey={appKey}
/>
)}
</>
@@ -125,9 +120,6 @@ function FlowRow(props) {
FlowRow.propTypes = {
flow: FlowPropType.isRequired,
onDeleteFlow: PropTypes.func,
onDuplicateFlow: PropTypes.func,
appKey: PropTypes.string.isRequired,
};
export default FlowRow;

View File

@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import * as React from 'react';
import { useLazyQuery } from '@apollo/client';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
@@ -23,7 +24,7 @@ import ChooseConnectionSubstep from 'components/ChooseConnectionSubstep';
import Form from 'components/Form';
import FlowStepContextMenu from 'components/FlowStepContextMenu';
import AppIcon from 'components/AppIcon';
import { GET_STEP_WITH_TEST_EXECUTIONS } from 'graphql/queries/get-step-with-test-executions';
import useFormatMessage from 'hooks/useFormatMessage';
import useApps from 'hooks/useApps';
import {
@@ -39,7 +40,6 @@ import useTriggers from 'hooks/useTriggers';
import useActions from 'hooks/useActions';
import useTriggerSubsteps from 'hooks/useTriggerSubsteps';
import useActionSubsteps from 'hooks/useActionSubsteps';
import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions';
const validIcon = <CheckCircleIcon color="success" />;
const errorIcon = <ErrorIcon color="error" />;
@@ -105,7 +105,7 @@ function generateValidationSchema(substeps) {
}
function FlowStep(props) {
const { collapsed, onChange, onContinue, flowId } = props;
const { collapsed, onChange, onContinue } = props;
const editorContext = React.useContext(EditorContext);
const contextButtonRef = React.useRef(null);
const step = props.step;
@@ -126,16 +126,28 @@ function FlowStep(props) {
const { data: apps } = useApps(useAppsOptions);
const { data: stepWithTestExecutions, refetch } = useStepWithTestExecutions(
step.id,
);
const stepWithTestExecutionsData = stepWithTestExecutions?.data;
const [
getStepWithTestExecutions,
{ data: stepWithTestExecutionsData, called: stepWithTestExecutionsCalled },
] = useLazyQuery(GET_STEP_WITH_TEST_EXECUTIONS, {
fetchPolicy: 'network-only',
});
React.useEffect(() => {
if (!collapsed && !isTrigger) {
refetch(step.id);
if (!stepWithTestExecutionsCalled && !collapsed && !isTrigger) {
getStepWithTestExecutions({
variables: {
stepId: step.id,
},
});
}
}, [collapsed, refetch, step.id, isTrigger]);
}, [
collapsed,
stepWithTestExecutionsCalled,
getStepWithTestExecutions,
step.id,
isTrigger,
]);
const app = apps?.data?.find((currentApp) => currentApp.key === step.appKey);
@@ -262,7 +274,9 @@ function FlowStep(props) {
<Collapse in={!collapsed} unmountOnExit>
<Content>
<List>
<StepExecutionsProvider value={stepWithTestExecutionsData}>
<StepExecutionsProvider
value={stepWithTestExecutionsData?.getStepWithTestExecutions}
>
<Form
defaultValues={step}
onSubmit={handleSubmit}
@@ -314,7 +328,6 @@ function FlowStep(props) {
: false
}
step={step}
flowId={flowId}
/>
)}
@@ -350,7 +363,6 @@ function FlowStep(props) {
deletable={!isTrigger}
onClose={onContextMenuClose}
anchorEl={anchorEl}
flowId={flowId}
/>
)}
</Wrapper>

View File

@@ -1,29 +1,24 @@
import PropTypes from 'prop-types';
import * as React from 'react';
import { useMutation } from '@apollo/client';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { DELETE_STEP } from 'graphql/mutations/delete-step';
import useFormatMessage from 'hooks/useFormatMessage';
import { useQueryClient } from '@tanstack/react-query';
function FlowStepContextMenu(props) {
const { stepId, onClose, anchorEl, deletable, flowId } = props;
const { stepId, onClose, anchorEl, deletable } = props;
const [deleteStep] = useMutation(DELETE_STEP, {
refetchQueries: ['GetFlow', 'GetStepWithTestExecutions'],
});
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const [deleteStep] = useMutation(DELETE_STEP);
const deleteActionHandler = React.useCallback(
async (event) => {
event.stopPropagation();
await deleteStep({ variables: { input: { id: stepId } } });
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
},
[stepId, queryClient],
[stepId],
);
return (
<Menu
open={true}

View File

@@ -178,7 +178,6 @@ export default function InputCreator(props) {
helperText={description}
clickToCopy={schema.clickToCopy}
shouldUnregister={shouldUnregister}
disabled={disabled}
/>
{isDynamicFieldsLoading && !additionalFields?.length && (

View File

@@ -12,20 +12,17 @@ import { LOGIN } from 'graphql/mutations/login';
import Form from 'components/Form';
import TextField from 'components/TextField';
import useFormatMessage from 'hooks/useFormatMessage';
function LoginForm() {
const isCloud = useCloud();
const navigate = useNavigate();
const formatMessage = useFormatMessage();
const authentication = useAuthentication();
const [login, { loading }] = useMutation(LOGIN);
React.useEffect(() => {
if (authentication.isAuthenticated) {
navigate(URLS.DASHBOARD);
}
}, [authentication.isAuthenticated]);
const handleSubmit = async (values) => {
const { data } = await login({
variables: {
@@ -35,7 +32,6 @@ function LoginForm() {
const { token } = data.login;
authentication.updateToken(token);
};
return (
<Paper sx={{ px: 2, py: 4 }}>
<Typography
@@ -111,5 +107,4 @@ function LoginForm() {
</Paper>
);
}
export default LoginForm;

View File

@@ -1,20 +1,17 @@
import MuiTabs from '@mui/material/Tabs';
import { styled } from '@mui/material/styles';
export const ChildrenWrapper = styled('div')`
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
hyphens: auto;
`;
export const InputLabelWrapper = styled('div')`
position: absolute;
left: ${({ theme }) => theme.spacing(1.75)};
inset: 0;
left: -6px;
`;
export const FakeInput = styled('div', {
shouldForwardProp: (prop) => prop !== 'disabled',
})`
@@ -34,17 +31,15 @@ export const FakeInput = styled('div', {
border-color: ${theme.palette.action.disabled};
`}
${({ disabled, theme }) =>
!disabled &&
`
&:hover {
border-color: ${theme.palette.text.primary};
border-color: ${({ theme }) => theme.palette.text.primary};
}
&:focus-within,
&:focus {
&:before {
border-color: ${theme.palette.primary.main};
border-radius: ${theme.spacing(0.5)};
border-color: ${({ theme }) => theme.palette.primary.main};
border-radius: ${({ theme }) => theme.spacing(0.5)};
border-style: solid;
border-width: 2px;
bottom: -2px;
@@ -56,9 +51,7 @@ export const FakeInput = styled('div', {
top: -2px;
}
}
`}
`;
export const Tabs = styled(MuiTabs)`
border-bottom: 1px solid ${({ theme }) => theme.palette.divider};
`;

View File

@@ -4,12 +4,12 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import api from 'helpers/api.js';
import useAuthentication from 'hooks/useAuthentication.js';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000,
retryOnMount: false,
refetchOnWindowFocus: false,
// provides a convenient default while it should be overridden for other HTTP methods
queryFn: async ({ queryKey, signal }) => {
@@ -25,9 +25,27 @@ const queryClient = new QueryClient({
});
export default function AutomatischQueryClientProvider({ children }) {
const { token, initialize } = useAuthentication();
React.useEffect(
function updateTokenInHttpClient() {
if (!initialize) return;
if (token) {
api.defaults.headers.Authorization = token;
} else {
delete api.defaults.headers.Authorization;
}
initialize();
},
[initialize, token],
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
@@ -10,26 +9,21 @@ import Paper from '@mui/material/Paper';
import Popper from '@mui/material/Popper';
import * as React from 'react';
import { Link } from 'react-router-dom';
export default function SplitButton(props) {
const { options, disabled, defaultActionIndex = 0 } = props;
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef(null);
const multiOptions = options.length > 1;
const selectedOption = options[defaultActionIndex];
const handleToggle = () => {
setOpen((prevOpen) => !prevOpen);
};
const handleClose = (event) => {
if (anchorRef.current && anchorRef.current.contains(event.target)) {
return;
}
setOpen(false);
};
return (
<React.Fragment>
<ButtonGroup
@@ -48,7 +42,6 @@ export default function SplitButton(props) {
borderRadius: 0,
borderRight: '1px solid #bdbdbd',
}}
disabled={selectedOption.disabled}
>
{selectedOption.label}
</Button>
@@ -87,7 +80,6 @@ export default function SplitButton(props) {
selected={index === defaultActionIndex}
component={Link}
to={option.to}
disabled={option.disabled}
>
{option.label}
</MenuItem>
@@ -102,17 +94,3 @@ export default function SplitButton(props) {
</React.Fragment>
);
}
SplitButton.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
'data-test': PropTypes.string.isRequired,
to: PropTypes.string.isRequired,
disabled: PropTypes.bool.isRequired,
}).isRequired,
).isRequired,
disabled: PropTypes.bool,
defaultActionIndex: PropTypes.number,
};

View File

@@ -3,21 +3,11 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import useSubscription from 'hooks/useSubscription.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import { DateTime } from 'luxon';
import useUserTrial from 'hooks/useUserTrial.ee';
export default function SubscriptionCancelledAlert() {
const formatMessage = useFormatMessage();
const subscription = useSubscription();
const trial = useUserTrial();
if (subscription?.data?.status === 'active' || trial.hasTrial)
return <React.Fragment />;
const cancellationEffectiveDateObject = DateTime.fromISO(
subscription?.data?.cancellationEffectiveDate,
);
if (!subscription) return <React.Fragment />;
return (
<Alert
@@ -28,9 +18,7 @@ export default function SubscriptionCancelledAlert() {
}}
>
<Typography variant="subtitle2" sx={{ lineHeight: 1.5 }}>
{formatMessage('subscriptionCancelledAlert.text', {
date: cancellationEffectiveDateObject.toFormat('DDD'),
})}
{subscription.message}
</Typography>
</Alert>
);

View File

@@ -6,15 +6,12 @@ import ListItem from '@mui/material/ListItem';
import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle';
import LoadingButton from '@mui/lab/LoadingButton';
import { EditorContext } from 'contexts/Editor';
import useFormatMessage from 'hooks/useFormatMessage';
import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow';
import JSONViewer from 'components/JSONViewer';
import WebhookUrlInfo from 'components/WebhookUrlInfo';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import { useQueryClient } from '@tanstack/react-query';
function serializeErrors(graphQLErrors) {
return graphQLErrors?.map((error) => {
try {
@@ -31,7 +28,6 @@ function serializeErrors(graphQLErrors) {
}
});
}
function TestSubstep(props) {
const {
substep,
@@ -42,13 +38,13 @@ function TestSubstep(props) {
onContinue,
step,
showWebhookUrl = false,
flowId,
} = props;
const formatMessage = useFormatMessage();
const editorContext = React.useContext(EditorContext);
const [executeFlow, { data, error, loading, called, reset }] = useMutation(
EXECUTE_FLOW,
{
refetchQueries: ['GetStepWithTestExecutions'],
context: { autoSnackbar: false },
},
);
@@ -56,8 +52,6 @@ function TestSubstep(props) {
const isCompleted = !error && called && !loading;
const hasNoOutput = !response && isCompleted;
const { name } = substep;
const queryClient = useQueryClient();
React.useEffect(
function resetTestDataOnSubstepToggle() {
if (!expanded) {
@@ -66,28 +60,20 @@ function TestSubstep(props) {
},
[expanded, reset],
);
const handleSubmit = React.useCallback(async () => {
const handleSubmit = React.useCallback(() => {
if (isCompleted) {
onContinue?.();
return;
}
await executeFlow({
executeFlow({
variables: {
input: {
stepId: step.id,
},
},
});
await queryClient.invalidateQueries({
queryKey: ['flows', flowId],
});
}, [onSubmit, onContinue, isCompleted, queryClient, flowId]);
}, [onSubmit, onContinue, isCompleted, step.id]);
const onToggle = expanded ? onCollapse : onExpand;
return (
<React.Fragment>
<FlowSubstepTitle expanded={expanded} onClick={onToggle} title={name} />

View File

@@ -8,7 +8,7 @@ import useUserTrial from 'hooks/useUserTrial.ee';
export default function TrialStatusBadge() {
const data = useUserTrial();
if (!data.hasTrial) return <React.Fragment />;
if (!data) return <React.Fragment />;
const { message, status } = data;

View File

@@ -10,23 +10,15 @@ import CardContent from '@mui/material/CardContent';
import Divider from '@mui/material/Divider';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import TrialOverAlert from 'components/TrialOverAlert/index.ee';
import SubscriptionCancelledAlert from 'components/SubscriptionCancelledAlert/index.ee';
import CheckoutCompletedAlert from 'components/CheckoutCompletedAlert/index.ee';
import * as URLS from 'config/urls';
import useBillingAndUsageData from 'hooks/useBillingAndUsageData.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import usePlanAndUsage from 'hooks/usePlanAndUsage';
import useSubscription from 'hooks/useSubscription.ee';
import useUserTrial from 'hooks/useUserTrial.ee';
import { useQueryClient } from '@tanstack/react-query';
import useCurrentUser from 'hooks/useCurrentUser';
const capitalize = (str) => str[0].toUpperCase() + str.slice(1, str.length);
function BillingCard(props) {
const { name, title = '', action, text } = props;
const { name, title = '', action } = props;
return (
<Card
sx={{
@@ -48,94 +40,42 @@ function BillingCard(props) {
</CardContent>
<CardActions>
<Action action={action} text={text} />
<Action action={action} />
</CardActions>
</Card>
);
}
function Action(props) {
const { action, text } = props;
const { action } = props;
if (!action) return <React.Fragment />;
if (action.startsWith('http')) {
const { text, type } = action;
if (type === 'link') {
if (action.src.startsWith('http')) {
return (
<Button size="small" href={action} target="_blank">
<Button size="small" href={action.src} target="_blank">
{text}
</Button>
);
} else if (action.startsWith('/')) {
} else {
return (
<Button size="small" component={Link} to={action}>
<Button size="small" component={Link} to={action.src}>
{text}
</Button>
);
}
}
if (type === 'text') {
return (
<Typography variant="subtitle2" pb={1}>
{text}
</Typography>
);
}
return <React.Fragment />;
}
export default function UsageDataInformation() {
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const { data: currentUser } = useCurrentUser();
const currentUserId = currentUser?.data?.id;
const { data } = usePlanAndUsage(currentUserId);
const planAndUsage = data?.data;
const trial = useUserTrial();
const subscriptionData = useSubscription();
const subscription = subscriptionData?.data;
let billingInfo;
React.useEffect(() => {
queryClient.invalidateQueries({
queryKey: ['users', currentUserId, 'planAndUsage'],
});
}, [subscription, queryClient, currentUserId]);
if (trial.hasTrial) {
billingInfo = {
monthlyQuota: {
title: formatMessage('usageDataInformation.freeTrial'),
action: URLS.SETTINGS_PLAN_UPGRADE,
text: 'Upgrade plan',
},
nextBillAmount: {
title: '---',
action: null,
text: null,
},
nextBillDate: {
title: '---',
action: null,
text: null,
},
};
} else {
billingInfo = {
monthlyQuota: {
title: planAndUsage?.plan?.limit,
action: subscription?.cancelUrl,
text: formatMessage('usageDataInformation.cancelPlan'),
},
nextBillAmount: {
title: `${subscription?.nextBillAmount}`,
action: subscription?.updateUrl,
text: formatMessage('usageDataInformation.updatePaymentMethod'),
},
nextBillDate: {
title: subscription?.nextBillDate,
action: formatMessage('usageDataInformation.monthlyPayment'),
text: formatMessage('usageDataInformation.monthlyPayment'),
},
};
}
const billingAndUsageData = useBillingAndUsageData();
return (
<React.Fragment>
<Stack sx={{ width: '100%', mb: 2 }} spacing={2}>
@@ -152,8 +92,11 @@ export default function UsageDataInformation() {
{formatMessage('usageDataInformation.subscriptionPlan')}
</Typography>
{subscription?.status && (
<Chip label={capitalize(subscription?.status)} color="success" />
{billingAndUsageData?.subscription?.status && (
<Chip
label={capitalize(billingAndUsageData?.subscription?.status)}
color="success"
/>
)}
</Box>
@@ -170,27 +113,26 @@ export default function UsageDataInformation() {
<Grid item xs={12} md={4}>
<BillingCard
name={formatMessage('usageDataInformation.monthlyQuota')}
title={billingInfo.monthlyQuota.title}
action={billingInfo.monthlyQuota.action}
text={billingInfo.monthlyQuota.text}
title={billingAndUsageData?.subscription?.monthlyQuota.title}
action={billingAndUsageData?.subscription?.monthlyQuota.action}
/>
</Grid>
<Grid item xs={12} md={4}>
<BillingCard
name={formatMessage('usageDataInformation.nextBillAmount')}
title={billingInfo.nextBillAmount.title}
action={billingInfo.nextBillAmount.action}
text={billingInfo.nextBillAmount.text}
title={billingAndUsageData?.subscription?.nextBillAmount.title}
action={
billingAndUsageData?.subscription?.nextBillAmount.action
}
/>
</Grid>
<Grid item xs={12} md={4}>
<BillingCard
name={formatMessage('usageDataInformation.nextBillDate')}
title={billingInfo.nextBillDate.title}
action={billingInfo.nextBillDate.action}
text={billingInfo.nextBillDate.text}
title={billingAndUsageData?.subscription?.nextBillDate.title}
action={billingAndUsageData?.subscription?.nextBillDate.action}
/>
</Grid>
</Grid>
@@ -229,7 +171,7 @@ export default function UsageDataInformation() {
variant="subtitle2"
sx={{ color: 'text.secondary', mt: 2, fontWeight: 500 }}
>
{planAndUsage?.usage.task}
{billingAndUsageData?.usage.task}
</Typography>
</Box>
@@ -237,7 +179,7 @@ export default function UsageDataInformation() {
</Box>
{/* free plan has `null` status so that we can show the upgrade button */}
{subscription?.status === undefined && (
{billingAndUsageData?.subscription?.status === null && (
<Button
component={Link}
to={URLS.SETTINGS_PLAN_UPGRADE}

View File

@@ -17,13 +17,13 @@ export const APP_ADD_CONNECTION = (appKey, shared = false) =>
`/app/${appKey}/connections/add?shared=${shared}`;
export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (
appKey,
appAuthClientId,
appAuthClientId
) => `/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`;
export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add';
export const APP_RECONNECT_CONNECTION = (
appKey,
connectionId,
appAuthClientId,
appAuthClientId
) => {
const path = `/app/${appKey}/connections/${connectionId}/reconnect`;
if (appAuthClientId) {

View File

@@ -1,34 +1,31 @@
import * as React from 'react';
import { getItem, removeItem, setItem } from 'helpers/storage';
import api from 'helpers/api.js';
import { getItem, setItem } from 'helpers/storage';
export const AuthenticationContext = React.createContext({
token: null,
updateToken: () => {},
removeToken: () => {},
updateToken: () => void 0,
isAuthenticated: false,
initialize: () => void 0,
});
export const AuthenticationProvider = (props) => {
const { children } = props;
const [isInitialized, setInitialized] = React.useState(false);
const [token, setToken] = React.useState(() => getItem('token'));
const value = React.useMemo(() => {
return {
token,
updateToken: (newToken) => {
api.defaults.headers.Authorization = newToken;
setToken(newToken);
setItem('token', newToken);
},
removeToken: () => {
delete api.defaults.headers.Authorization;
setToken(null);
removeItem('token');
isAuthenticated: Boolean(token) && isInitialized,
initialize: () => {
setInitialized(true);
},
isAuthenticated: Boolean(token),
};
}, [token]);
}, [token, isInitialized]);
return (
<AuthenticationContext.Provider value={value}>

View File

@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
import * as URLS from 'config/urls';
import useCloud from 'hooks/useCloud';
import usePaddleInfo from 'hooks/usePaddleInfo.ee';
import { useQueryClient } from '@tanstack/react-query';
import apolloClient from 'graphql/client';
export const PaddleContext = React.createContext({
loaded: false,
@@ -17,7 +17,6 @@ export const PaddleProvider = (props) => {
const { data } = usePaddleInfo();
const sandbox = data?.data?.sandbox;
const vendorId = data?.data?.vendorId;
const queryClient = useQueryClient();
const [loaded, setLoaded] = React.useState(false);
@@ -30,12 +29,8 @@ export const PaddleProvider = (props) => {
if (completed) {
// Paddle has side effects in the background,
// so we need to refetch the relevant queries
await queryClient.refetchQueries({
queryKey: ['users', 'me', 'trial'],
});
await queryClient.refetchQueries({
queryKey: ['users', 'me', 'subscription'],
await apolloClient.refetchQueries({
include: ['GetTrialStatus', 'GetBillingAndUsage'],
});
navigate(URLS.SETTINGS_BILLING_AND_USAGE, {
@@ -44,7 +39,7 @@ export const PaddleProvider = (props) => {
}
}
},
[navigate, queryClient],
[navigate],
);
const value = React.useMemo(() => {

View File

@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_APP_AUTH_CLIENT = gql`
query GetAppAuthClient($id: String!) {
getAppAuthClient(id: $id) {
id
appConfigId
name
active
}
}
`;

View File

@@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GET_APP_AUTH_CLIENTS = gql`
query GetAppAuthClients($appKey: String!, $active: Boolean) {
getAppAuthClients(appKey: $appKey, active: $active) {
id
appConfigId
name
active
}
}
`;

View File

@@ -0,0 +1,20 @@
import { gql } from '@apollo/client';
export const GET_APP_CONNECTIONS = gql`
query GetAppConnections($key: String!) {
getApp(key: $key) {
key
connections {
id
key
reconnectable
appAuthClientId
verified
flowCount
formattedData {
screenName
}
createdAt
}
}
}
`;

View File

@@ -0,0 +1,37 @@
import { gql } from '@apollo/client';
export const GET_BILLING_AND_USAGE = gql`
query GetBillingAndUsage {
getBillingAndUsage {
subscription {
status
monthlyQuota {
title
action {
type
text
src
}
}
nextBillDate {
title
action {
type
text
src
}
}
nextBillAmount {
title
action {
type
text
src
}
}
}
usage {
task
}
}
}
`;

View File

@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const GET_CONNECTED_APPS = gql`
query GetConnectedApps($name: String) {
getConnectedApps(name: $name) {
key
name
iconUrl
docUrl
primaryColor
connectionCount
flowCount
supportsConnections
}
}
`;

Some files were not shown because too many files have changed in this diff Show More