Compare commits

..

18 Commits

Author SHA1 Message Date
Faruk AYDIN
62b656add1 feat: Move canCustomConnect logic to static db field for AppConfig 2024-03-28 00:58:20 +01:00
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
176 changed files with 1728 additions and 2823 deletions

View File

@@ -1,3 +0,0 @@
import sendEmail from './send-email/index.js';
export default [sendEmail];

View File

@@ -1,234 +0,0 @@
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Send email',
key: 'sendEmail',
description: 'Send a new email message.',
arguments: [
{
label: 'TOs',
key: 'tos',
type: 'dynamic',
required: false,
description: '',
fields: [
{
label: 'To',
key: 'to',
type: 'string',
required: false,
variables: true,
},
],
},
{
label: 'CCs',
key: 'ccs',
type: 'dynamic',
required: false,
description: '',
fields: [
{
label: 'CC',
key: 'cc',
type: 'string',
required: false,
variables: true,
},
],
},
{
label: 'BCCs',
key: 'bccs',
type: 'dynamic',
required: false,
description: '',
fields: [
{
label: 'BCC',
key: 'bcc',
type: 'string',
required: false,
variables: true,
},
],
},
{
label: 'From',
key: 'from',
type: 'dropdown',
required: false,
description:
'Select an email address or alias from your Gmail Account. Defaults to the primary email address.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listEmails',
},
],
},
},
{
label: 'From Name',
key: 'fromName',
type: 'string',
required: false,
description: '',
variables: true,
},
{
label: 'Reply To',
key: 'replyTo',
type: 'string',
required: false,
description: 'Specify a single reply address other than your own.',
variables: true,
},
{
label: 'Subject',
key: 'subject',
type: 'string',
required: true,
description: '',
variables: true,
},
{
label: 'Body Type',
key: 'bodyType',
type: 'dropdown',
required: false,
description: '',
variables: true,
options: [
{
label: 'plain',
value: 'plain',
},
{
label: 'html',
value: 'html',
},
],
},
{
label: 'Body',
key: 'emailBody',
type: 'string',
required: true,
description: '',
variables: true,
},
{
label: 'Signature',
key: 'signature',
type: 'dropdown',
required: false,
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listSignatures',
},
],
},
},
{
label: 'Label',
key: 'labelId',
type: 'dropdown',
required: false,
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listLabels',
},
],
},
},
],
async run($) {
const {
tos,
ccs,
bccs,
from,
fromName,
replyTo,
subject,
bodyType,
emailBody,
signature,
labelId,
} = $.step.parameters;
const userId = $.auth.data.userId;
const allTos = tos?.map((entry) => entry.to);
const allCcs = ccs?.map((entry) => entry.cc);
const allBccs = bccs?.map((entry) => entry.bcc);
const contentType =
bodyType === 'html'
? 'text/html; charset="UTF-8"'
: 'text/plain; charset="UTF-8"';
const email =
'From: ' +
fromName +
' <' +
from +
'>' +
'\r\n' +
'Reply-To: ' +
replyTo +
'\r\n' +
'To: ' +
allTos.join(',') +
'\r\n' +
'Cc: ' +
allCcs.join(',') +
'\r\n' +
'Bcc: ' +
allBccs.join(',') +
'\r\n' +
'Subject: ' +
subject +
'\r\n' +
'Content-Type: ' +
contentType +
'\r\n' +
'\r\n' +
emailBody +
'\r\n' +
'\r\n' +
signature;
const base64EncodedEmailBody = Buffer.from(email).toString('base64');
const body = {
labelIds: [labelId],
raw: base64EncodedEmailBody,
};
const { data } = await $.http.post(
`/gmail/v1/users/${userId}/messages/send`,
body
);
$.setActionItem({
raw: data,
});
},
});

View File

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 49.4 512 399.42">
<g fill="none" fill-rule="evenodd">
<g fill-rule="nonzero">
<path fill="#4285f4" d="M34.91 448.818h81.454V251L0 163.727V413.91c0 19.287 15.622 34.91 34.91 34.91z"/>
<path fill="#34a853" d="M395.636 448.818h81.455c19.287 0 34.909-15.622 34.909-34.909V163.727L395.636 251z"/>
<path fill="#fbbc04" d="M395.636 99.727V251L512 163.727v-46.545c0-43.142-49.25-67.782-83.782-41.891z"/>
</g>
<path fill="#ea4335" d="M116.364 251V99.727L256 204.455 395.636 99.727V251L256 355.727z"/>
<path fill="#c5221f" fill-rule="nonzero" d="M0 117.182v46.545L116.364 251V99.727L83.782 75.291C49.25 49.4 0 74.04 0 117.18z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 720 B

View File

@@ -1,23 +0,0 @@
import { URLSearchParams } from 'url';
import authScope from '../common/auth-scope.js';
export default async function generateAuthUrl($) {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const searchParams = new URLSearchParams({
client_id: $.auth.data.clientId,
redirect_uri: redirectUri,
prompt: 'select_account',
scope: authScope.join(' '),
response_type: 'code',
access_type: 'offline',
});
const url = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}`;
await $.auth.set({
url,
});
}

View File

@@ -1,48 +0,0 @@
import generateAuthUrl from './generate-auth-url.js';
import verifyCredentials from './verify-credentials.js';
import refreshToken from './refresh-token.js';
import isStillVerified from './is-still-verified.js';
export default {
fields: [
{
key: 'oAuthRedirectUrl',
label: 'OAuth Redirect URL',
type: 'string',
required: true,
readOnly: true,
value: '{WEB_APP_URL}/app/gmail/connections/add',
placeholder: null,
description:
'When asked to input a redirect URL in Google Cloud, enter the URL above.',
clickToCopy: true,
},
{
key: 'clientId',
label: 'Client ID',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
{
key: 'clientSecret',
label: 'Client Secret',
type: 'string',
required: true,
readOnly: false,
value: null,
placeholder: null,
description: null,
clickToCopy: false,
},
],
generateAuthUrl,
verifyCredentials,
isStillVerified,
refreshToken,
};

View File

@@ -1,8 +0,0 @@
import getCurrentUser from '../common/get-current-user.js';
const isStillVerified = async ($) => {
const currentUser = await getCurrentUser($);
return !!currentUser.resourceName;
};
export default isStillVerified;

View File

@@ -1,26 +0,0 @@
import { URLSearchParams } from 'node:url';
import authScope from '../common/auth-scope.js';
const refreshToken = async ($) => {
const params = new URLSearchParams({
client_id: $.auth.data.clientId,
client_secret: $.auth.data.clientSecret,
grant_type: 'refresh_token',
refresh_token: $.auth.data.refreshToken,
});
const { data } = await $.http.post(
'https://oauth2.googleapis.com/token',
params.toString()
);
await $.auth.set({
accessToken: data.access_token,
expiresIn: data.expires_in,
scope: authScope.join(' '),
tokenType: data.token_type,
});
};
export default refreshToken;

View File

@@ -1,43 +0,0 @@
import getCurrentUser from '../common/get-current-user.js';
const verifyCredentials = async ($) => {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const { data } = await $.http.post(`https://oauth2.googleapis.com/token`, {
client_id: $.auth.data.clientId,
client_secret: $.auth.data.clientSecret,
code: $.auth.data.code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
});
await $.auth.set({
accessToken: data.access_token,
tokenType: data.token_type,
});
const currentUser = await getCurrentUser($);
const { displayName } = currentUser.names.find(
(name) => name.metadata.primary
);
const { value: email } = currentUser.emailAddresses.find(
(emailAddress) => emailAddress.metadata.primary
);
await $.auth.set({
clientId: $.auth.data.clientId,
clientSecret: $.auth.data.clientSecret,
scope: $.auth.data.scope,
idToken: data.id_token,
expiresIn: data.expires_in,
refreshToken: data.refresh_token,
resourceName: currentUser.resourceName,
screenName: `${displayName} - ${email}`,
userId: email,
});
};
export default verifyCredentials;

View File

@@ -1,9 +0,0 @@
const addAuthHeader = ($, requestConfig) => {
if ($.auth.data?.accessToken) {
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -1,8 +0,0 @@
const authScope = [
'https://www.googleapis.com/auth/gmail.compose',
'https://www.googleapis.com/auth/gmail.modify',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
export default authScope;

View File

@@ -1,8 +0,0 @@
const getCurrentUser = async ($) => {
const { data: currentUser } = await $.http.get(
'https://people.googleapis.com/v1/people/me?personFields=names,emailAddresses'
);
return currentUser;
};
export default getCurrentUser;

View File

@@ -1,5 +0,0 @@
import listEmails from './list-emails/index.js';
import listLabels from './list-labels/index.js';
import listSignatures from './list-signatures/index.js';
export default [listEmails, listLabels, listSignatures];

View File

@@ -1,23 +0,0 @@
import getCurrentUser from '../../common/get-current-user.js';
export default {
name: 'List emails',
key: 'listEmails',
async run($) {
const emails = {
data: [],
};
const currentUser = await getCurrentUser($);
for (const emailAddress of currentUser.emailAddresses) {
emails.data.push({
value: emailAddress.value,
name: emailAddress.value,
});
}
return emails;
},
};

View File

@@ -1,22 +0,0 @@
export default {
name: 'List labels',
key: 'listLabels',
async run($) {
const labels = {
data: [],
};
const userId = $.auth.data.userId;
const { data } = await $.http.get(`/gmail/v1/users/${userId}/labels`);
for (const label of data.labels) {
labels.data.push({
value: label.id,
name: label.name,
});
}
return labels;
},
};

View File

@@ -1,24 +0,0 @@
export default {
name: 'List signatures',
key: 'listSignatures',
async run($) {
const signatures = {
data: [],
};
const userId = $.auth.data.userId;
const { data } = await $.http.get(
`/gmail/v1/users/${userId}/settings/sendAs`
);
for (const sendAs of data.sendAs) {
signatures.data.push({
value: sendAs.signature,
name: sendAs.sendAsEmail,
});
}
return signatures;
},
};

View File

@@ -1,22 +0,0 @@
import defineApp from '../../helpers/define-app.js';
import addAuthHeader from './common/add-auth-header.js';
import auth from './auth/index.js';
import triggers from './triggers/index.js';
import dynamicData from './dynamic-data/index.js';
import actions from './actions/index.js';
export default defineApp({
name: 'Gmail',
key: 'gmail',
baseUrl: 'https://mail.google.com',
apiBaseUrl: 'https://gmail.googleapis.com',
iconUrl: '{BASE_URL}/apps/gmail/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/gmail/connection',
primaryColor: 'ea4335',
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
triggers,
dynamicData,
actions,
});

View File

@@ -1,3 +0,0 @@
import newEmails from './new-emails/index.js';
export default [newEmails];

View File

@@ -1,68 +0,0 @@
import defineTrigger from '../../../../helpers/define-trigger.js';
export default defineTrigger({
name: 'New emails',
key: 'newEmails',
pollInterval: 15,
description:
'Triggers when a new email is received in the specified mailbox.',
arguments: [
{
label: 'Label',
key: 'labelId',
type: 'dropdown',
required: false,
description:
"If you don't choose a label, this Zap will trigger for all emails, including Drafts.",
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listLabels',
},
],
},
},
],
async run($) {
const userId = $.auth.data.userId;
const labelId = $.step.parameters.labelId;
const params = {
maxResults: 500,
pageToken: undefined,
};
if (labelId) {
params.labelIds = labelId;
}
do {
const { data } = await $.http.get(`/gmail/v1/users/${userId}/messages`, {
params,
});
params.pageToken = data.nextPageToken;
if (!data?.messages?.length) {
return;
}
for (const message of data.messages) {
const { data: messageData } = await $.http.get(
`/gmail/v1/users/${userId}/messages/${message.id}`
);
$.pushTriggerItem({
raw: messageData,
meta: {
internalId: messageData.id,
},
});
}
} while (params.pageToken);
},
});

View File

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

View File

@@ -64,17 +64,32 @@ export default defineAction({
value: '1', value: '1',
description: 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.', '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, options: [
source: { {
type: 'query', label: 'Qualified (Pipeline)',
name: 'getDynamicData', value: 1,
arguments: [ },
{ {
name: 'key', label: 'Contact Made (Pipeline)',
value: 'listStages', 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', label: 'Owner',

View File

@@ -1,25 +1,23 @@
import listActivityTypes from './list-activity-types/index.js'; import listActivityTypes from './list-activity-types/index.js';
import listCurrencies from './list-currencies/index.js'; import listCurrencies from './list-currencies/index.js';
import listDeals from './list-deals/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 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 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 listPersonLabelField from './list-person-label-field/index.js';
import listPersons from './list-persons/index.js'; import listPersons from './list-persons/index.js';
import listStages from './list-stages/index.js';
import listUsers from './list-users/index.js'; import listUsers from './list-users/index.js';
export default [ export default [
listActivityTypes, listActivityTypes,
listCurrencies, listCurrencies,
listDeals, listDeals,
listLeadLabels,
listLeads, listLeads,
listOrganizationLabelField, listLeadLabels,
listOrganizations, listOrganizations,
listOrganizationLabelField,
listPersonLabelField, listPersonLabelField,
listPersons, listPersons,
listStages,
listUsers, 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

@@ -3,9 +3,6 @@ import AppConfig from '../../../../models/app-config.js';
export default async (request, response) => { export default async (request, response) => {
const appConfig = await AppConfig.query() const appConfig = await AppConfig.query()
.withGraphFetched({
appAuthClients: true,
})
.findOne({ .findOne({
key: request.params.appKey, 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) { export async function down(knex) {
await knex.schema.table('app_auth_clients', (table) => { 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,11 @@
export async function up(knex) {
await knex.schema.table('app_configs', (table) => {
table.boolean('can_custom_connect').defaultTo(false);
});
}
export async function down(knex) {
await knex.schema.table('app_configs', (table) => {
table.dropColumn('can_custom_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 mutationResolvers from './mutation-resolvers.js';
import queryResolvers from './query-resolvers.js';
const resolvers = { const resolvers = {
Query: queryResolvers,
Mutation: mutationResolvers, Mutation: mutationResolvers,
}; };

View File

@@ -1,6 +1,19 @@
type Query { 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 { type Mutation {
createAppConfig(input: CreateAppConfigInput): AppConfig createAppConfig(input: CreateAppConfigInput): AppConfig
createAppAuthClient(input: CreateAppAuthClientInput): AppAuthClient createAppAuthClient(input: CreateAppAuthClientInput): AppAuthClient
@@ -550,6 +563,43 @@ type License {
verified: Boolean 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 { type Permission {
id: String id: String
action: String action: String

View File

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

View File

@@ -42,21 +42,19 @@ describe('authentication rules', () => {
const { queries, mutations } = getQueryAndMutationNames(authenticationRules); const { queries, mutations } = getQueryAndMutationNames(authenticationRules);
if (queries.length) { describe('for queries', () => {
describe('for queries', () => { queries.forEach((query) => {
queries.forEach((query) => { it(`should apply correct rule for query: ${query}`, () => {
it(`should apply correct rule for query: ${query}`, () => { const ruleApplied = authenticationRules.Query[query];
const ruleApplied = authenticationRules.Query[query];
if (query === '*') { if (query === '*') {
expect(ruleApplied.func).toBe(isAuthenticated); expect(ruleApplied.func).toBe(isAuthenticated);
} else { } else {
expect(ruleApplied).toEqual(allow); expect(ruleApplied).toEqual(allow);
} }
});
}); });
}); });
} });
describe('for mutations', () => { describe('for mutations', () => {
mutations.forEach((mutation) => { mutations.forEach((mutation) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,45 +15,56 @@ class AppConfig extends Base {
allowCustomConnection: { type: 'boolean', default: false }, allowCustomConnection: { type: 'boolean', default: false },
shared: { type: 'boolean', default: false }, shared: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false },
canConnect: { type: 'boolean', default: false },
canCustomConnect: { 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() { async getApp() {
if (!this.key) return null; if (!this.key) return null;
return await App.findOneByKey(this.key); 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 assignCanCustomConnect() {
const canCustomConnect = !this.disabled && this.allowCustomConnection;
this.canCustomConnect = canCustomConnect;
}
async $beforeInsert(queryContext) {
await super.$beforeInsert(queryContext);
await this.assignCanConnect();
await this.assignCanCustomConnect();
}
async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext);
await this.assignCanConnect();
await this.assignCanCustomConnect();
}
} }
export default AppConfig; export default AppConfig;

View File

@@ -153,24 +153,6 @@ class Connection extends Base {
return await App.findOneByKey(this.key); 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) { async verifyWebhook(request) {
if (!this.key) return true; if (!this.key) return true;

View File

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

View File

@@ -8,7 +8,6 @@ import ExecutionStep from './execution-step.js';
import Telemetry from '../helpers/telemetry/index.js'; import Telemetry from '../helpers/telemetry/index.js';
import appConfig from '../config/app.js'; import appConfig from '../config/app.js';
import globalVariable from '../helpers/global-variable.js'; import globalVariable from '../helpers/global-variable.js';
import computeParameters from '../helpers/compute-parameters.js';
class Step extends Base { class Step extends Base {
static tableName = 'steps'; static tableName = 'steps';
@@ -218,39 +217,6 @@ class Step extends Base {
return dynamicFields; 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() { async updateWebhookUrl() {
if (this.isAction) return this; 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 userAbility from '../helpers/user-ability.js';
import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js'; import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id.js';
import Base from './base.js'; import Base from './base.js';
import App from './app.js';
import Connection from './connection.js'; import Connection from './connection.js';
import Execution from './execution.js'; import Execution from './execution.js';
import Flow from './flow.js'; import Flow from './flow.js';
@@ -156,13 +155,6 @@ class User extends Base {
return conditions.isCreator ? this.$relatedQuery('steps') : Step.query(); 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() { get authorizedExecutions() {
const conditions = this.can('read', 'Execution'); const conditions = this.can('read', 'Execution');
return conditions.isCreator return conditions.isCreator
@@ -314,56 +306,6 @@ class User extends Base {
return invoices; 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) { async $beforeInsert(queryContext) {
await super.$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 getAppAction from '../../../controllers/api/v1/apps/get-app.js';
import getAppsAction from '../../../controllers/api/v1/apps/get-apps.js'; import getAppsAction from '../../../controllers/api/v1/apps/get-apps.js';
import getAuthAction from '../../../controllers/api/v1/apps/get-auth.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 getConfigAction from '../../../controllers/api/v1/apps/get-config.ee.js';
import getAuthClientsAction from '../../../controllers/api/v1/apps/get-auth-clients.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'; 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', authenticateUser, asyncHandler(getAppAction));
router.get('/:appKey/auth', authenticateUser, asyncHandler(getAuthAction)); router.get('/:appKey/auth', authenticateUser, asyncHandler(getAuthAction));
router.get(
'/:appKey/connections',
authenticateUser,
authorizeUser,
asyncHandler(getConnectionsAction)
);
router.get( router.get(
'/:appKey/config', '/:appKey/config',
authenticateUser, authenticateUser,

View File

@@ -3,7 +3,6 @@ import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.js'; import { authenticateUser } from '../../../helpers/authentication.js';
import { authorizeUser } from '../../../helpers/authorization.js'; import { authorizeUser } from '../../../helpers/authorization.js';
import getFlowsAction from '../../../controllers/api/v1/connections/get-flows.js'; import getFlowsAction from '../../../controllers/api/v1/connections/get-flows.js';
import createTestAction from '../../../controllers/api/v1/connections/create-test.js';
const router = Router(); const router = Router();
@@ -14,11 +13,4 @@ router.get(
asyncHandler(getFlowsAction) asyncHandler(getFlowsAction)
); );
router.post(
'/:connectionId/test',
authenticateUser,
authorizeUser,
asyncHandler(createTestAction)
);
export default router; 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 getConnectionAction from '../../../controllers/api/v1/steps/get-connection.js';
import getPreviousStepsAction from '../../../controllers/api/v1/steps/get-previous-steps.js'; import getPreviousStepsAction from '../../../controllers/api/v1/steps/get-previous-steps.js';
import createDynamicFieldsAction from '../../../controllers/api/v1/steps/create-dynamic-fields.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(); const router = Router();
@@ -30,11 +29,4 @@ router.post(
asyncHandler(createDynamicFieldsAction) asyncHandler(createDynamicFieldsAction)
); );
router.post(
'/:stepId/dynamic-data',
authenticateUser,
authorizeUser,
asyncHandler(createDynamicDataAction)
);
export default router; export default router;

View File

@@ -1,11 +1,9 @@
import { Router } from 'express'; import { Router } from 'express';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.js'; import { authenticateUser } from '../../../helpers/authentication.js';
import { authorizeUser } from '../../../helpers/authorization.js';
import checkIsCloud from '../../../helpers/check-is-cloud.js'; import checkIsCloud from '../../../helpers/check-is-cloud.js';
import getCurrentUserAction from '../../../controllers/api/v1/users/get-current-user.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 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 getInvoicesAction from '../../../controllers/api/v1/users/get-invoices.ee.js';
import getSubscriptionAction from '../../../controllers/api/v1/users/get-subscription.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'; 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(); const router = Router();
router.get('/me', authenticateUser, asyncHandler(getCurrentUserAction)); router.get('/me', authenticateUser, asyncHandler(getCurrentUserAction));
router.get(
'/:userId/apps',
authenticateUser,
authorizeUser,
asyncHandler(getAppsAction)
);
router.get( router.get(
'/invoices', '/invoices',
authenticateUser, authenticateUser,

View File

@@ -1,22 +1,12 @@
const appSerializer = (app) => { const appSerializer = (app) => {
let appData = { return {
key: app.key,
name: app.name, name: app.name,
key: app.key,
iconUrl: app.iconUrl, iconUrl: app.iconUrl,
primaryColor: app.primaryColor,
authDocUrl: app.authDocUrl, authDocUrl: app.authDocUrl,
supportsConnections: app.supportsConnections, 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; export default appSerializer;

View File

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

View File

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

View File

@@ -19,8 +19,6 @@ export const createStep = async (params = {}) => {
params.appKey = params.appKey =
params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook'); params?.appKey || (params.type === 'action' ? 'deepl' : 'webhook');
params.parameters = params?.parameters || {};
const step = await Step.query().insertAndFetch(params); const step = await Step.query().insertAndFetch(params);
return step; 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, name: flow.name,
active: flow.active, active: flow.active,
status: flow.active ? 'published' : 'draft', status: flow.active ? 'published' : 'draft',
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: steps.map((step) => ({ steps: steps.map((step) => ({
id: step.id, id: step.id,
type: step.type, type: step.type,

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ const getFlowsMock = async (flows, steps) => {
id: flow.id, id: flow.id,
name: flow.name, name: flow.name,
status: flow.active ? 'published' : 'draft', status: flow.active ? 'published' : 'draft',
createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(),
steps: flowSteps.map((step) => ({ steps: flowSteps.map((step) => ({
appKey: step.appKey, appKey: step.appKey,
iconUrl: step.iconUrl, 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 { Model } from 'objection';
import { client as knex } from '../../src/config/database.js'; import { client as knex } from '../../src/config/database.js';
import logger from '../../src/helpers/logger.js'; import logger from '../../src/helpers/logger.js';
import { vi } from 'vitest';
global.beforeAll(async () => { global.beforeAll(async () => {
global.knex = null; global.knex = null;
@@ -23,8 +22,8 @@ global.afterEach(async () => {
await global.knex.rollback(); await global.knex.rollback();
Model.knex(knex); Model.knex(knex);
vi.restoreAllMocks(); // jest.restoreAllMocks();
vi.clearAllMocks(); // jest.clearAllMocks();
}); });
global.afterAll(async () => { global.afterAll(async () => {

View File

@@ -141,16 +141,6 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/gitlab/connection' }, { text: 'Connection', link: '/apps/gitlab/connection' },
], ],
}, },
{
text: 'Gmail',
collapsible: true,
collapsed: true,
items: [
{ text: 'Triggers', link: '/apps/gmail/triggers' },
{ text: 'Connection', link: '/apps/gmail/connection' },
{ text: 'Actions', link: '/apps/gmail/actions' },
],
},
{ {
text: 'Google Calendar', text: 'Google Calendar',
collapsible: true, collapsible: true,

View File

@@ -1,12 +0,0 @@
---
favicon: /favicons/gmail.svg
items:
- name: Send email
desc: Send a new email message.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -1,28 +0,0 @@
# Gmail
:::info
This page explains the steps you need to follow to set up the Gmail
connection in Automatisch. If any of the steps are outdated, please let us know!
:::
1. Go to the [Google Cloud Console](https://console.cloud.google.com) to create a project.
2. Click on the project drop-down menu at the top of the page, and click on the **New Project** button.
3. Enter a name for your project and click on the **Create** button.
4. Go to [API Library](https://console.cloud.google.com/apis/library) in Google Cloud console.
5. Search for **People API** in the search bar and click on it.
6. Click on the **Enable** button to enable the API.
7. Repeat steps 5 and 6 for the **Gmail API**
8. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) in Google Cloud console.
9. Select **External** here for starting your app in testing mode at first. Click on the **Create** button.
10. Fill **App Name**, **User Support Email**, and **Developer Contact Information**. Click on the **Save and Continue** button.
11. Skip adding or removing scopes and click on the **Save and Continue** button.
12. Click on the **Add Users** button and add a test email because only test users can access the app while publishing status is set to "Testing".
13. Click on the **Save and Continue** button and now you have configured the consent screen.
14. Go to [Credentials](https://console.cloud.google.com/apis/credentials) in Google Cloud console.
15. Click on the **Create Credentials** button and select the **OAuth client ID** option.
16. Select the application type as **Web application** and fill the **Name** field.
17. Copy **OAuth Redirect URL** from Automatisch to **Authorized redirect URIs** field, and click on the **Create** button.
18. Copy the **Your Client ID** value from the following popup to the `Client ID` field on Automatisch.
19. Copy the **Your Client Secret** value from the following popup to the `Client Secret` field on Automatisch.
20. Click **Submit** button on Automatisch.
21. Congrats! Start using your new Gmail connection within the flows.

View File

@@ -1,12 +0,0 @@
---
favicon: /favicons/gmail.svg
items:
- name: New emails
desc: Triggers when a new email is received in the specified mailbox.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -14,7 +14,6 @@ The following integrations are currently supported by Automatisch.
- [Ghost](/apps/ghost/triggers) - [Ghost](/apps/ghost/triggers)
- [GitHub](/apps/github/triggers) - [GitHub](/apps/github/triggers)
- [GitLab](/apps/gitlab/triggers) - [GitLab](/apps/gitlab/triggers)
- [Gmail](/apps/gmail/triggers)
- [Google Calendar](/apps/google-calendar/triggers) - [Google Calendar](/apps/google-calendar/triggers)
- [Google Drive](/apps/google-drive/triggers) - [Google Drive](/apps/google-drive/triggers)
- [Google Forms](/apps/google-forms/triggers) - [Google Forms](/apps/google-forms/triggers)

View File

@@ -1,11 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 49.4 512 399.42">
<g fill="none" fill-rule="evenodd">
<g fill-rule="nonzero">
<path fill="#4285f4" d="M34.91 448.818h81.454V251L0 163.727V413.91c0 19.287 15.622 34.91 34.91 34.91z"/>
<path fill="#34a853" d="M395.636 448.818h81.455c19.287 0 34.909-15.622 34.909-34.909V163.727L395.636 251z"/>
<path fill="#fbbc04" d="M395.636 99.727V251L512 163.727v-46.545c0-43.142-49.25-67.782-83.782-41.891z"/>
</g>
<path fill="#ea4335" d="M116.364 251V99.727L256 204.455 395.636 99.727V251L256 355.727z"/>
<path fill="#c5221f" fill-rule="nonzero" d="M0 117.182v46.545L116.364 251V99.727L83.782 75.291C49.25 49.4 0 74.04 0 117.18z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 720 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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