Compare commits
63 Commits
custom-dep
...
AUT-1025
Author | SHA1 | Date | |
---|---|---|---|
![]() |
a77ac9a3f9 | ||
![]() |
6062cfafaf | ||
![]() |
5263e774d2 | ||
![]() |
22b4a04567 | ||
![]() |
e0d610071d | ||
![]() |
ab0966c005 | ||
![]() |
751eb41e72 | ||
![]() |
f08dc25711 | ||
![]() |
737eb31776 | ||
![]() |
d6abf283bc | ||
![]() |
bac4ab5aa4 | ||
![]() |
b5839390fd | ||
![]() |
d19271dae1 | ||
![]() |
ef5a09314e | ||
![]() |
ba52e298eb | ||
![]() |
b3c3998189 | ||
![]() |
782f9b5c04 | ||
![]() |
3079d8c605 | ||
![]() |
c5202d7b3e | ||
![]() |
fbae83f4de | ||
![]() |
5b7b8c934f | ||
![]() |
b70223e824 | ||
![]() |
9900bbbc8d | ||
![]() |
fc7f1ddd69 | ||
![]() |
991b2f4bf7 | ||
![]() |
bb251b16a9 | ||
![]() |
1b34a48a61 | ||
![]() |
8c83b715fe | ||
![]() |
c122708b0b | ||
![]() |
258d920ff2 | ||
![]() |
6e5c0cc0c7 | ||
![]() |
9dc82290b5 | ||
![]() |
bb73f90374 | ||
![]() |
8a0720b0e3 | ||
![]() |
88468c4f89 | ||
![]() |
d19a45592f | ||
![]() |
21a921d25d | ||
![]() |
3b2946aac5 | ||
![]() |
196d555e8c | ||
![]() |
28f39b5c7e | ||
![]() |
ec8ac17f4a | ||
![]() |
c45573349a | ||
![]() |
d36c9d43f6 | ||
![]() |
b06c744392 | ||
![]() |
9548c93b4c | ||
![]() |
4144944ab2 | ||
![]() |
46b85519c1 | ||
![]() |
5a83fc33ec | ||
![]() |
c80791267f | ||
![]() |
b30f97db3e | ||
![]() |
717c81fa2b | ||
![]() |
ae188bc563 | ||
![]() |
fc4561221d | ||
![]() |
5aeb4f8809 | ||
![]() |
c6c900bc39 | ||
![]() |
c18ab67a25 | ||
![]() |
55ae1470d0 | ||
![]() |
a1136fdfb2 | ||
![]() |
3da5e13ecd | ||
![]() |
40d0fe0db6 | ||
![]() |
029fd2d0b0 | ||
![]() |
1dc9646894 | ||
![]() |
c413ab030b |
@@ -36,7 +36,6 @@ services:
|
|||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:21.1
|
image: quay.io/keycloak/keycloak:21.1
|
||||||
restart: always
|
restart: always
|
||||||
container_name: keycloak
|
|
||||||
environment:
|
environment:
|
||||||
- KEYCLOAK_ADMIN=admin
|
- KEYCLOAK_ADMIN=admin
|
||||||
- KEYCLOAK_ADMIN_PASSWORD=admin
|
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||||
|
@@ -2,6 +2,7 @@ import appConfig from '../../src/config/app.js';
|
|||||||
import logger from '../../src/helpers/logger.js';
|
import logger from '../../src/helpers/logger.js';
|
||||||
import client from './client.js';
|
import client from './client.js';
|
||||||
import User from '../../src/models/user.js';
|
import User from '../../src/models/user.js';
|
||||||
|
import Config from '../../src/models/config.js';
|
||||||
import Role from '../../src/models/role.js';
|
import Role from '../../src/models/role.js';
|
||||||
import '../../src/config/orm.js';
|
import '../../src/config/orm.js';
|
||||||
import process from 'process';
|
import process from 'process';
|
||||||
@@ -21,6 +22,14 @@ export async function createUser(
|
|||||||
email = 'user@automatisch.io',
|
email = 'user@automatisch.io',
|
||||||
password = 'sample'
|
password = 'sample'
|
||||||
) {
|
) {
|
||||||
|
if (appConfig.disableSeedUser) {
|
||||||
|
logger.info('Seed user is disabled.');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const UNIQUE_VIOLATION_CODE = '23505';
|
const UNIQUE_VIOLATION_CODE = '23505';
|
||||||
|
|
||||||
const role = await fetchAdminRole();
|
const role = await fetchAdminRole();
|
||||||
@@ -37,6 +46,8 @@ export async function createUser(
|
|||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
const user = await User.query().insertAndFetch(userParams);
|
const user = await User.query().insertAndFetch(userParams);
|
||||||
logger.info(`User has been saved: ${user.email}`);
|
logger.info(`User has been saved: ${user.email}`);
|
||||||
|
|
||||||
|
await Config.markInstallationCompleted();
|
||||||
} else {
|
} else {
|
||||||
logger.info('No need to seed a user.');
|
logger.info('No need to seed a user.');
|
||||||
}
|
}
|
||||||
|
@@ -67,6 +67,7 @@
|
|||||||
"pluralize": "^8.0.0",
|
"pluralize": "^8.0.0",
|
||||||
"raw-body": "^2.5.2",
|
"raw-body": "^2.5.2",
|
||||||
"showdown": "^2.1.0",
|
"showdown": "^2.1.0",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.7.1",
|
"winston": "^3.7.1",
|
||||||
"xmlrpc": "^1.3.2"
|
"xmlrpc": "^1.3.2"
|
||||||
},
|
},
|
||||||
|
@@ -0,0 +1,92 @@
|
|||||||
|
import defineAction from '../../../../helpers/define-action.js';
|
||||||
|
|
||||||
|
export default defineAction({
|
||||||
|
name: 'Create record',
|
||||||
|
key: 'createRecord',
|
||||||
|
description: 'Creates a new record with fields that automatically populate.',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
label: 'Base',
|
||||||
|
key: 'baseId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: 'Base in which to create the record.',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listBases',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Table',
|
||||||
|
key: 'tableId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
dependsOn: ['parameters.baseId'],
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listTables',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.baseId',
|
||||||
|
value: '{parameters.baseId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
additionalFields: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicFields',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listFields',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.baseId',
|
||||||
|
value: '{parameters.baseId}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.tableId',
|
||||||
|
value: '{parameters.tableId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const { baseId, tableId, ...rest } = $.step.parameters;
|
||||||
|
|
||||||
|
const fields = Object.entries(rest).reduce((result, [key, value]) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
result[key] = value.map((item) => item.value);
|
||||||
|
} else if (value !== '') {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
typecast: true,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.post(`/v0/${baseId}/${tableId}`, body);
|
||||||
|
|
||||||
|
$.setActionItem({
|
||||||
|
raw: data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
174
packages/backend/src/apps/airtable/actions/find-record/index.js
Normal file
174
packages/backend/src/apps/airtable/actions/find-record/index.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import defineAction from '../../../../helpers/define-action.js';
|
||||||
|
import { URLSearchParams } from 'url';
|
||||||
|
|
||||||
|
export default defineAction({
|
||||||
|
name: 'Find record',
|
||||||
|
key: 'findRecord',
|
||||||
|
description:
|
||||||
|
"Finds a record using simple field search or use Airtable's formula syntax to find a matching record.",
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
label: 'Base',
|
||||||
|
key: 'baseId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: 'Base in which to create the record.',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listBases',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Table',
|
||||||
|
key: 'tableId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
dependsOn: ['parameters.baseId'],
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listTables',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.baseId',
|
||||||
|
value: '{parameters.baseId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search by field',
|
||||||
|
key: 'tableField',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
dependsOn: ['parameters.baseId', 'parameters.tableId'],
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listTableFields',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.baseId',
|
||||||
|
value: '{parameters.baseId}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.tableId',
|
||||||
|
value: '{parameters.tableId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search Value',
|
||||||
|
key: 'searchValue',
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
description:
|
||||||
|
'The value of unique identifier for the record. For date values, please use the ISO format (e.g., "YYYY-MM-DD").',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search for exact match?',
|
||||||
|
key: 'exactMatch',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
options: [
|
||||||
|
{ label: 'Yes', value: 'true' },
|
||||||
|
{ label: 'No', value: 'false' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search Formula',
|
||||||
|
key: 'searchFormula',
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
description:
|
||||||
|
'Instead, you have the option to use an Airtable search formula for locating records according to sophisticated criteria and across various fields.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Limit to View',
|
||||||
|
key: 'limitToView',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
dependsOn: ['parameters.baseId', 'parameters.tableId'],
|
||||||
|
description:
|
||||||
|
'You have the choice to restrict the search to a particular view ID if desired.',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listTableViews',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.baseId',
|
||||||
|
value: '{parameters.baseId}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.tableId',
|
||||||
|
value: '{parameters.tableId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const {
|
||||||
|
baseId,
|
||||||
|
tableId,
|
||||||
|
tableField,
|
||||||
|
searchValue,
|
||||||
|
exactMatch,
|
||||||
|
searchFormula,
|
||||||
|
limitToView,
|
||||||
|
} = $.step.parameters;
|
||||||
|
|
||||||
|
let filterByFormula;
|
||||||
|
|
||||||
|
if (tableField && searchValue) {
|
||||||
|
filterByFormula =
|
||||||
|
exactMatch === 'true'
|
||||||
|
? `{${tableField}} = '${searchValue}'`
|
||||||
|
: `LOWER({${tableField}}) = LOWER('${searchValue}')`;
|
||||||
|
} else {
|
||||||
|
filterByFormula = searchFormula;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
filterByFormula,
|
||||||
|
view: limitToView,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await $.http.post(
|
||||||
|
`/v0/${baseId}/${tableId}/listRecords`,
|
||||||
|
body
|
||||||
|
);
|
||||||
|
|
||||||
|
$.setActionItem({
|
||||||
|
raw: data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
4
packages/backend/src/apps/airtable/actions/index.js
Normal file
4
packages/backend/src/apps/airtable/actions/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import createRecord from './create-record/index.js';
|
||||||
|
import findRecord from './find-record/index.js';
|
||||||
|
|
||||||
|
export default [createRecord, findRecord];
|
9
packages/backend/src/apps/airtable/assets/favicon.svg
Normal file
9
packages/backend/src/apps/airtable/assets/favicon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="256px" height="215px" viewBox="0 0 256 215" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||||
|
<g>
|
||||||
|
<path d="M114.25873,2.70101695 L18.8604023,42.1756384 C13.5552723,44.3711638 13.6102328,51.9065311 18.9486282,54.0225085 L114.746142,92.0117514 C123.163769,95.3498757 132.537419,95.3498757 140.9536,92.0117514 L236.75256,54.0225085 C242.08951,51.9065311 242.145916,44.3711638 236.83934,42.1756384 L141.442459,2.70101695 C132.738459,-0.900338983 122.961284,-0.900338983 114.25873,2.70101695" fill="#FFBF00"></path>
|
||||||
|
<path d="M136.349071,112.756863 L136.349071,207.659101 C136.349071,212.173089 140.900664,215.263892 145.096461,213.600615 L251.844122,172.166219 C254.281184,171.200072 255.879376,168.845451 255.879376,166.224705 L255.879376,71.3224678 C255.879376,66.8084791 251.327783,63.7176768 247.131986,65.3809537 L140.384325,106.815349 C137.94871,107.781496 136.349071,110.136118 136.349071,112.756863" fill="#26B5F8"></path>
|
||||||
|
<path d="M111.422771,117.65355 L79.742409,132.949912 L76.5257763,134.504714 L9.65047684,166.548104 C5.4112904,168.593211 0.000578531073,165.503855 0.000578531073,160.794612 L0.000578531073,71.7210757 C0.000578531073,70.0173017 0.874160452,68.5463864 2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill="#ED3049"></path>
|
||||||
|
<path d="M111.422771,117.65355 L79.742409,132.949912 L2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill-opacity="0.25" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
38
packages/backend/src/apps/airtable/auth/generate-auth-url.js
Normal file
38
packages/backend/src/apps/airtable/auth/generate-auth-url.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
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 state = crypto.randomBytes(100).toString('base64url');
|
||||||
|
const codeVerifier = crypto.randomBytes(96).toString('base64url');
|
||||||
|
const codeChallenge = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(codeVerifier)
|
||||||
|
.digest('base64')
|
||||||
|
.replace(/=/g, '')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_');
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
client_id: $.auth.data.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: authScope.join(' '),
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `https://airtable.com/oauth2/v1/authorize?${searchParams.toString()}`;
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
url,
|
||||||
|
originalCodeChallenge: codeChallenge,
|
||||||
|
originalState: state,
|
||||||
|
codeVerifier,
|
||||||
|
});
|
||||||
|
}
|
48
packages/backend/src/apps/airtable/auth/index.js
Normal file
48
packages/backend/src/apps/airtable/auth/index.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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/airtable/connections/add',
|
||||||
|
placeholder: null,
|
||||||
|
description:
|
||||||
|
'When asked to input a redirect URL in Airtable, 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,
|
||||||
|
};
|
@@ -0,0 +1,8 @@
|
|||||||
|
import getCurrentUser from '../common/get-current-user.js';
|
||||||
|
|
||||||
|
const isStillVerified = async ($) => {
|
||||||
|
const currentUser = await getCurrentUser($);
|
||||||
|
return !!currentUser.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default isStillVerified;
|
40
packages/backend/src/apps/airtable/auth/refresh-token.js
Normal file
40
packages/backend/src/apps/airtable/auth/refresh-token.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { URLSearchParams } from 'node:url';
|
||||||
|
|
||||||
|
import authScope from '../common/auth-scope.js';
|
||||||
|
|
||||||
|
const refreshToken = async ($) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: $.auth.data.clientId,
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
refresh_token: $.auth.data.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
const basicAuthToken = Buffer.from(
|
||||||
|
$.auth.data.clientId + ':' + $.auth.data.clientSecret
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const { data } = await $.http.post(
|
||||||
|
'https://airtable.com/oauth2/v1/token',
|
||||||
|
params.toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: `Basic ${basicAuthToken}`,
|
||||||
|
},
|
||||||
|
additionalProperties: {
|
||||||
|
skipAddingAuthHeader: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
accessToken: data.access_token,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
refreshExpiresIn: data.refresh_expires_in,
|
||||||
|
scope: authScope.join(' '),
|
||||||
|
tokenType: data.token_type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default refreshToken;
|
@@ -0,0 +1,56 @@
|
|||||||
|
import getCurrentUser from '../common/get-current-user.js';
|
||||||
|
|
||||||
|
const verifyCredentials = async ($) => {
|
||||||
|
if ($.auth.data.originalState !== $.auth.data.state) {
|
||||||
|
throw new Error("The 'state' parameter does not match.");
|
||||||
|
}
|
||||||
|
if ($.auth.data.originalCodeChallenge !== $.auth.data.code_challenge) {
|
||||||
|
throw new Error("The 'code challenge' parameter does not match.");
|
||||||
|
}
|
||||||
|
const oauthRedirectUrlField = $.app.auth.fields.find(
|
||||||
|
(field) => field.key == 'oAuthRedirectUrl'
|
||||||
|
);
|
||||||
|
const redirectUri = oauthRedirectUrlField.value;
|
||||||
|
const basicAuthToken = Buffer.from(
|
||||||
|
$.auth.data.clientId + ':' + $.auth.data.clientSecret
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
const { data } = await $.http.post(
|
||||||
|
'https://airtable.com/oauth2/v1/token',
|
||||||
|
{
|
||||||
|
code: $.auth.data.code,
|
||||||
|
client_id: $.auth.data.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
code_verifier: $.auth.data.codeVerifier,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
Authorization: `Basic ${basicAuthToken}`,
|
||||||
|
},
|
||||||
|
additionalProperties: {
|
||||||
|
skipAddingAuthHeader: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
accessToken: data.access_token,
|
||||||
|
tokenType: data.token_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUser = await getCurrentUser($);
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
clientId: $.auth.data.clientId,
|
||||||
|
clientSecret: $.auth.data.clientSecret,
|
||||||
|
scope: $.auth.data.scope,
|
||||||
|
expiresIn: data.expires_in,
|
||||||
|
refreshExpiresIn: data.refresh_expires_in,
|
||||||
|
refreshToken: data.refresh_token,
|
||||||
|
screenName: currentUser.email,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default verifyCredentials;
|
12
packages/backend/src/apps/airtable/common/add-auth-header.js
Normal file
12
packages/backend/src/apps/airtable/common/add-auth-header.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const addAuthHeader = ($, requestConfig) => {
|
||||||
|
if (
|
||||||
|
!requestConfig.additionalProperties?.skipAddingAuthHeader &&
|
||||||
|
$.auth.data?.accessToken
|
||||||
|
) {
|
||||||
|
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addAuthHeader;
|
12
packages/backend/src/apps/airtable/common/auth-scope.js
Normal file
12
packages/backend/src/apps/airtable/common/auth-scope.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const authScope = [
|
||||||
|
'data.records:read',
|
||||||
|
'data.records:write',
|
||||||
|
'data.recordComments:read',
|
||||||
|
'data.recordComments:write',
|
||||||
|
'schema.bases:read',
|
||||||
|
'schema.bases:write',
|
||||||
|
'user.email:read',
|
||||||
|
'webhook:manage',
|
||||||
|
];
|
||||||
|
|
||||||
|
export default authScope;
|
@@ -0,0 +1,6 @@
|
|||||||
|
const getCurrentUser = async ($) => {
|
||||||
|
const { data: currentUser } = await $.http.get('/v0/meta/whoami');
|
||||||
|
return currentUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getCurrentUser;
|
6
packages/backend/src/apps/airtable/dynamic-data/index.js
Normal file
6
packages/backend/src/apps/airtable/dynamic-data/index.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import listBases from './list-bases/index.js';
|
||||||
|
import listTableFields from './list-table-fields/index.js';
|
||||||
|
import listTableViews from './list-table-views/index.js';
|
||||||
|
import listTables from './list-tables/index.js';
|
||||||
|
|
||||||
|
export default [listBases, listTableFields, listTableViews, listTables];
|
@@ -0,0 +1,28 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List bases',
|
||||||
|
key: 'listBases',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const bases = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { data } = await $.http.get('/v0/meta/bases', { params });
|
||||||
|
params.offset = data.offset;
|
||||||
|
|
||||||
|
if (data?.bases) {
|
||||||
|
for (const base of data.bases) {
|
||||||
|
bases.data.push({
|
||||||
|
value: base.id,
|
||||||
|
name: base.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (params.offset);
|
||||||
|
|
||||||
|
return bases;
|
||||||
|
},
|
||||||
|
};
|
@@ -0,0 +1,39 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List table fields',
|
||||||
|
key: 'listTableFields',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const tableFields = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
const { baseId, tableId } = $.step.parameters;
|
||||||
|
|
||||||
|
if (!baseId) {
|
||||||
|
return tableFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
params.offset = data.offset;
|
||||||
|
|
||||||
|
if (data?.tables) {
|
||||||
|
for (const table of data.tables) {
|
||||||
|
if (table.id === tableId) {
|
||||||
|
table.fields.forEach((field) => {
|
||||||
|
tableFields.data.push({
|
||||||
|
value: field.name,
|
||||||
|
name: field.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (params.offset);
|
||||||
|
|
||||||
|
return tableFields;
|
||||||
|
},
|
||||||
|
};
|
@@ -0,0 +1,39 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List table views',
|
||||||
|
key: 'listTableViews',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const tableViews = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
const { baseId, tableId } = $.step.parameters;
|
||||||
|
|
||||||
|
if (!baseId) {
|
||||||
|
return tableViews;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
params.offset = data.offset;
|
||||||
|
|
||||||
|
if (data?.tables) {
|
||||||
|
for (const table of data.tables) {
|
||||||
|
if (table.id === tableId) {
|
||||||
|
table.views.forEach((view) => {
|
||||||
|
tableViews.data.push({
|
||||||
|
value: view.id,
|
||||||
|
name: view.name,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (params.offset);
|
||||||
|
|
||||||
|
return tableViews;
|
||||||
|
},
|
||||||
|
};
|
@@ -0,0 +1,35 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List tables',
|
||||||
|
key: 'listTables',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const tables = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
const baseId = $.step.parameters.baseId;
|
||||||
|
|
||||||
|
if (!baseId) {
|
||||||
|
return tables;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
params.offset = data.offset;
|
||||||
|
|
||||||
|
if (data?.tables) {
|
||||||
|
for (const table of data.tables) {
|
||||||
|
tables.data.push({
|
||||||
|
value: table.id,
|
||||||
|
name: table.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (params.offset);
|
||||||
|
|
||||||
|
return tables;
|
||||||
|
},
|
||||||
|
};
|
@@ -0,0 +1,3 @@
|
|||||||
|
import listFields from './list-fields/index.js';
|
||||||
|
|
||||||
|
export default [listFields];
|
@@ -0,0 +1,86 @@
|
|||||||
|
const hasValue = (value) => value !== null && value !== undefined;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'List fields',
|
||||||
|
key: 'listFields',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const options = [];
|
||||||
|
const { baseId, tableId } = $.step.parameters;
|
||||||
|
|
||||||
|
if (!hasValue(baseId) || !hasValue(tableId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await $.http.get(`/v0/meta/bases/${baseId}/tables`);
|
||||||
|
|
||||||
|
const selectedTable = data.tables.find((table) => table.id === tableId);
|
||||||
|
|
||||||
|
if (!selectedTable) return;
|
||||||
|
|
||||||
|
selectedTable.fields.forEach((field) => {
|
||||||
|
if (field.type === 'singleSelect') {
|
||||||
|
options.push({
|
||||||
|
label: field.name,
|
||||||
|
key: field.name,
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
options: field.options.choices.map((choice) => ({
|
||||||
|
label: choice.name,
|
||||||
|
value: choice.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} else if (field.type === 'multipleSelects') {
|
||||||
|
options.push({
|
||||||
|
label: field.name,
|
||||||
|
key: field.name,
|
||||||
|
type: 'dynamic',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: 'Value',
|
||||||
|
key: 'value',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
options: field.options.choices.map((choice) => ({
|
||||||
|
label: choice.name,
|
||||||
|
value: choice.id,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (field.type === 'checkbox') {
|
||||||
|
options.push({
|
||||||
|
label: field.name,
|
||||||
|
key: field.name,
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: 'Yes',
|
||||||
|
value: 'true',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'No',
|
||||||
|
value: 'false',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
options.push({
|
||||||
|
label: field.name,
|
||||||
|
key: field.name,
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
variables: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
};
|
22
packages/backend/src/apps/airtable/index.js
Normal file
22
packages/backend/src/apps/airtable/index.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import defineApp from '../../helpers/define-app.js';
|
||||||
|
import addAuthHeader from './common/add-auth-header.js';
|
||||||
|
import auth from './auth/index.js';
|
||||||
|
import actions from './actions/index.js';
|
||||||
|
import dynamicData from './dynamic-data/index.js';
|
||||||
|
import dynamicFields from './dynamic-fields/index.js';
|
||||||
|
|
||||||
|
export default defineApp({
|
||||||
|
name: 'Airtable',
|
||||||
|
key: 'airtable',
|
||||||
|
baseUrl: 'https://airtable.com',
|
||||||
|
apiBaseUrl: 'https://api.airtable.com',
|
||||||
|
iconUrl: '{BASE_URL}/apps/airtable/assets/favicon.svg',
|
||||||
|
authDocUrl: '{DOCS_URL}/apps/airtable/connection',
|
||||||
|
primaryColor: 'FFBF00',
|
||||||
|
supportsConnections: true,
|
||||||
|
beforeRequest: [addAuthHeader],
|
||||||
|
auth,
|
||||||
|
actions,
|
||||||
|
dynamicData,
|
||||||
|
dynamicFields,
|
||||||
|
});
|
1
packages/backend/src/apps/appwrite/assets/favicon.svg
Normal file
1
packages/backend/src/apps/appwrite/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="132" height="24" fill="none" viewBox="0 0 132 24"><path fill="#19191C" d="M38.557 19.495c2.16 0 3.25-1.113 3.725-1.87h.214c.094.805.664 1.562 1.779 1.562h2.111V16.82h-.545c-.38 0-.57-.213-.57-.545V6.776h-2.8v1.516h-.213c-.545-.758-1.684-1.824-3.772-1.824-3.321 0-5.789 2.748-5.789 6.514s2.515 6.513 5.86 6.513m.498-2.7c-1.969 0-3.51-1.445-3.51-3.79 0-2.297 1.494-3.86 3.487-3.86 1.898 0 3.487 1.397 3.487 3.86 0 2.108-1.352 3.79-3.463 3.79M48.04 24h2.799v-6.376h.213c.522.758 1.637 1.871 3.844 1.871 3.321 0 5.741-2.795 5.741-6.513 0-3.743-2.586-6.514-5.931-6.514-2.135 0-3.18 1.16-3.678 1.8h-.213V6.776h-2.776V24m6.263-7.134c-1.922 0-3.512-1.42-3.512-3.884 0-2.108 1.353-3.885 3.464-3.885 1.97 0 3.511 1.54 3.511 3.885 0 2.297-1.494 3.884-3.463 3.884M62.082 24h2.8v-6.376h.213c.522.758 1.637 1.871 3.843 1.871 3.321 0 5.51-2.795 5.51-6.513 0-3.743-2.355-6.514-5.7-6.514-2.135 0-3.179 1.16-3.677 1.8h-.214V6.776h-2.775zm6.263-7.134c-1.922 0-3.511-1.42-3.511-3.884 0-2.108 1.352-3.885 3.463-3.885 1.97 0 3.512 1.54 3.512 3.885 0 2.297-1.495 3.884-3.464 3.884m9.805 2.61h3.961l2.254-9.735h.143l2.253 9.735H90.7l3.153-12.412h-2.821l-2.254 9.759h-.214l-2.253-9.759h-3.725l-2.278 9.759h-.213l-2.23-9.759h-2.99l3.274 12.412m17.123 0h2.8V13.34c0-2.345 1.09-3.79 3.131-3.79h1.233V6.756h-.925c-1.59 0-2.8 1.09-3.274 2.132h-.19V7.064h-2.775zm21.057 0h2.183v-2.487h-2.159c-.854 0-1.21-.38-1.21-1.256V9.528h3.511V7.064h-3.511V3.582h-2.657v3.482h-2.325v2.464h2.159v6.229c0 2.63 1.589 3.719 4.009 3.719m9.693.019c2.586 0 4.864-1.279 5.67-3.86l-2.562-.616c-.451 1.373-1.755 2.084-3.131 2.084-2.041 0-3.393-1.326-3.417-3.41h9.419v-.782c0-3.695-2.301-6.443-6.097-6.443-3.346 0-6.216 2.63-6.216 6.537 0 3.79 2.538 6.49 6.334 6.49m-3.416-7.84c.166-1.492 1.518-2.747 3.298-2.747 1.708 0 3.108 1.066 3.25 2.747h-6.548"/><path fill="#19191C" fill-rule="evenodd" d="M108.916 19.476h-2.8V9.528h-2.182V7.064h4.982z" clip-rule="evenodd"/><path fill="#19191C" d="M107.309 5.342c1.02 0 1.779-.758 1.779-1.753 0-.971-.759-1.73-1.779-1.73-1.021 0-1.78.759-1.78 1.73 0 .995.759 1.753 1.78 1.753"/><path fill="#FD366E" d="M24.443 16.432v5.478H10.752c-3.989 0-7.472-2.203-9.335-5.478A11.041 11.041 0 0 1 0 11.695v-1.48a10.97 10.97 0 0 1 .381-2.247C1.661 3.368 5.82 0 10.751 0c4.934 0 9.092 3.37 10.371 7.967h-5.854c-.96-1.499-2.624-2.49-4.516-2.49s-3.555.991-4.516 2.49a5.47 5.47 0 0 0-.67 1.494 5.562 5.562 0 0 0-.202 1.494 5.5 5.5 0 0 0 1.69 3.983 5.32 5.32 0 0 0 3.698 1.494h13.69"/><path fill="#FD366E" d="M24.443 9.46v5.478h-9.994a5.5 5.5 0 0 0 1.691-3.983 5.56 5.56 0 0 0-.203-1.494h8.506"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
65
packages/backend/src/apps/appwrite/auth/index.js
Normal file
65
packages/backend/src/apps/appwrite/auth/index.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import verifyCredentials from './verify-credentials.js';
|
||||||
|
import isStillVerified from './is-still-verified.js';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'screenName',
|
||||||
|
label: 'Screen Name',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
readOnly: false,
|
||||||
|
value: null,
|
||||||
|
placeholder: null,
|
||||||
|
description:
|
||||||
|
'Screen name of your connection to be used on Automatisch UI.',
|
||||||
|
clickToCopy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'projectId',
|
||||||
|
label: 'Project ID',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
readOnly: false,
|
||||||
|
value: null,
|
||||||
|
placeholder: null,
|
||||||
|
description: 'Project ID of your Appwrite project.',
|
||||||
|
clickToCopy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'apiKey',
|
||||||
|
label: 'API Key',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
readOnly: false,
|
||||||
|
value: null,
|
||||||
|
placeholder: null,
|
||||||
|
description: 'API key of your Appwrite project.',
|
||||||
|
clickToCopy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'instanceUrl',
|
||||||
|
label: 'Appwrite instance URL',
|
||||||
|
type: 'string',
|
||||||
|
required: false,
|
||||||
|
readOnly: false,
|
||||||
|
placeholder: '',
|
||||||
|
description: '',
|
||||||
|
clickToCopy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'host',
|
||||||
|
label: 'Host Name',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
readOnly: false,
|
||||||
|
value: null,
|
||||||
|
placeholder: null,
|
||||||
|
description: 'Host name of your Appwrite project.',
|
||||||
|
clickToCopy: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
verifyCredentials,
|
||||||
|
isStillVerified,
|
||||||
|
};
|
@@ -0,0 +1,8 @@
|
|||||||
|
import verifyCredentials from './verify-credentials.js';
|
||||||
|
|
||||||
|
const isStillVerified = async ($) => {
|
||||||
|
await verifyCredentials($);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default isStillVerified;
|
@@ -0,0 +1,5 @@
|
|||||||
|
const verifyCredentials = async ($) => {
|
||||||
|
await $.http.get('/v1/users');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default verifyCredentials;
|
16
packages/backend/src/apps/appwrite/common/add-auth-header.js
Normal file
16
packages/backend/src/apps/appwrite/common/add-auth-header.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const addAuthHeader = ($, requestConfig) => {
|
||||||
|
requestConfig.headers['Content-Type'] = 'application/json';
|
||||||
|
|
||||||
|
if ($.auth.data?.apiKey && $.auth.data?.projectId) {
|
||||||
|
requestConfig.headers['X-Appwrite-Project'] = $.auth.data.projectId;
|
||||||
|
requestConfig.headers['X-Appwrite-Key'] = $.auth.data.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($.auth.data?.host) {
|
||||||
|
requestConfig.headers['Host'] = $.auth.data.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addAuthHeader;
|
13
packages/backend/src/apps/appwrite/common/set-base-url.js
Normal file
13
packages/backend/src/apps/appwrite/common/set-base-url.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
const setBaseUrl = ($, requestConfig) => {
|
||||||
|
const instanceUrl = $.auth.data.instanceUrl;
|
||||||
|
|
||||||
|
if (instanceUrl) {
|
||||||
|
requestConfig.baseURL = instanceUrl;
|
||||||
|
} else if ($.app.apiBaseUrl) {
|
||||||
|
requestConfig.baseURL = $.app.apiBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default setBaseUrl;
|
4
packages/backend/src/apps/appwrite/dynamic-data/index.js
Normal file
4
packages/backend/src/apps/appwrite/dynamic-data/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import listCollections from './list-collections/index.js';
|
||||||
|
import listDatabases from './list-databases/index.js';
|
||||||
|
|
||||||
|
export default [listCollections, listDatabases];
|
@@ -0,0 +1,44 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List collections',
|
||||||
|
key: 'listCollections',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const collections = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
const databaseId = $.step.parameters.databaseId;
|
||||||
|
|
||||||
|
if (!databaseId) {
|
||||||
|
return collections;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
queries: [
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'orderAsc',
|
||||||
|
attribute: 'name',
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'limit',
|
||||||
|
values: [100],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.get(
|
||||||
|
`/v1/databases/${databaseId}/collections`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data?.collections) {
|
||||||
|
for (const collection of data.collections) {
|
||||||
|
collections.data.push({
|
||||||
|
value: collection.$id,
|
||||||
|
name: collection.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collections;
|
||||||
|
},
|
||||||
|
};
|
@@ -0,0 +1,36 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List databases',
|
||||||
|
key: 'listDatabases',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const databases = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
queries: [
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'orderAsc',
|
||||||
|
attribute: 'name',
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'limit',
|
||||||
|
values: [100],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.get('/v1/databases', { params });
|
||||||
|
|
||||||
|
if (data?.databases) {
|
||||||
|
for (const database of data.databases) {
|
||||||
|
databases.data.push({
|
||||||
|
value: database.$id,
|
||||||
|
name: database.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return databases;
|
||||||
|
},
|
||||||
|
};
|
21
packages/backend/src/apps/appwrite/index.js
Normal file
21
packages/backend/src/apps/appwrite/index.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import defineApp from '../../helpers/define-app.js';
|
||||||
|
import addAuthHeader from './common/add-auth-header.js';
|
||||||
|
import setBaseUrl from './common/set-base-url.js';
|
||||||
|
import auth from './auth/index.js';
|
||||||
|
import triggers from './triggers/index.js';
|
||||||
|
import dynamicData from './dynamic-data/index.js';
|
||||||
|
|
||||||
|
export default defineApp({
|
||||||
|
name: 'Appwrite',
|
||||||
|
key: 'appwrite',
|
||||||
|
baseUrl: 'https://appwrite.io',
|
||||||
|
apiBaseUrl: 'https://cloud.appwrite.io',
|
||||||
|
iconUrl: '{BASE_URL}/apps/appwrite/assets/favicon.svg',
|
||||||
|
authDocUrl: '{DOCS_URL}/apps/appwrite/connection',
|
||||||
|
primaryColor: 'FD366E',
|
||||||
|
supportsConnections: true,
|
||||||
|
beforeRequest: [setBaseUrl, addAuthHeader],
|
||||||
|
auth,
|
||||||
|
triggers,
|
||||||
|
dynamicData,
|
||||||
|
});
|
3
packages/backend/src/apps/appwrite/triggers/index.js
Normal file
3
packages/backend/src/apps/appwrite/triggers/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import newDocuments from './new-documents/index.js';
|
||||||
|
|
||||||
|
export default [newDocuments];
|
@@ -0,0 +1,104 @@
|
|||||||
|
import defineTrigger from '../../../../helpers/define-trigger.js';
|
||||||
|
|
||||||
|
export default defineTrigger({
|
||||||
|
name: 'New documents',
|
||||||
|
key: 'newDocuments',
|
||||||
|
pollInterval: 15,
|
||||||
|
description: 'Triggers when a new document is created.',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
label: 'Database',
|
||||||
|
key: 'databaseId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listDatabases',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Collection',
|
||||||
|
key: 'collectionId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
dependsOn: ['parameters.databaseId'],
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listCollections',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.databaseId',
|
||||||
|
value: '{parameters.databaseId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const { databaseId, collectionId } = $.step.parameters;
|
||||||
|
|
||||||
|
const limit = 1;
|
||||||
|
let lastDocumentId = undefined;
|
||||||
|
let offset = 0;
|
||||||
|
let documentCount = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const params = {
|
||||||
|
queries: [
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'orderDesc',
|
||||||
|
attribute: '$createdAt',
|
||||||
|
}),
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'limit',
|
||||||
|
values: [limit],
|
||||||
|
}),
|
||||||
|
// An invalid cursor shouldn't be sent.
|
||||||
|
lastDocumentId &&
|
||||||
|
JSON.stringify({
|
||||||
|
method: 'cursorAfter',
|
||||||
|
values: [lastDocumentId],
|
||||||
|
}),
|
||||||
|
].filter(Boolean),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.get(
|
||||||
|
`/v1/databases/${databaseId}/collections/${collectionId}/documents`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
|
||||||
|
const documents = data?.documents;
|
||||||
|
documentCount = documents?.length;
|
||||||
|
offset = offset + limit;
|
||||||
|
lastDocumentId = documents[documentCount - 1]?.$id;
|
||||||
|
|
||||||
|
if (!documentCount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const document of documents) {
|
||||||
|
$.pushTriggerItem({
|
||||||
|
raw: document,
|
||||||
|
meta: {
|
||||||
|
internalId: document.$id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} while (documentCount === limit);
|
||||||
|
},
|
||||||
|
});
|
7
packages/backend/src/apps/eventbrite/assets/favicon.svg
Normal file
7
packages/backend/src/apps/eventbrite/assets/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||||
|
<g>
|
||||||
|
<circle fill="#F05537" cx="128" cy="128" r="128"></circle>
|
||||||
|
<path d="M117.475323,82.7290398 C136.772428,78.4407943 156.069532,86.3025777 166.790146,101.311437 L81.5017079,120.608542 C84.3605382,102.26438 98.1782181,87.0172853 117.475323,82.7290398 Z M167.266618,153.48509 C160.596014,163.252761 150.351872,170.161601 138.678314,172.782195 C119.38121,177.070441 99.8458692,169.208657 89.1252554,153.961562 L174.651929,134.664457 L188.469609,131.567391 L215.152026,125.611495 C214.91379,119.893834 214.199082,114.176173 213.007903,108.696749 C202.287289,62.7172275 155.354825,33.8906884 108.42236,44.6113021 C61.4898956,55.3319159 32.1868848,101.073201 43.1457344,147.290958 C54.1045839,193.508715 100.798813,222.097018 147.731277,211.376404 C175.366637,205.182272 196.807864,186.599875 207.766714,163.014525 L167.266618,153.48509 L167.266618,153.48509 Z" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
import { URLSearchParams } from 'url';
|
||||||
|
|
||||||
|
export default async function generateAuthUrl($) {
|
||||||
|
const oauthRedirectUrlField = $.app.auth.fields.find(
|
||||||
|
(field) => field.key == 'oAuthRedirectUrl'
|
||||||
|
);
|
||||||
|
const redirectUri = oauthRedirectUrlField.value;
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
response_type: 'code',
|
||||||
|
client_id: $.auth.data.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `https://www.eventbrite.com/oauth/authorize?${searchParams.toString()}`;
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
46
packages/backend/src/apps/eventbrite/auth/index.js
Normal file
46
packages/backend/src/apps/eventbrite/auth/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import generateAuthUrl from './generate-auth-url.js';
|
||||||
|
import verifyCredentials from './verify-credentials.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/eventbrite/connections/add',
|
||||||
|
placeholder: null,
|
||||||
|
description:
|
||||||
|
'When asked to input a redirect URL in Eventbrite, enter the URL above.',
|
||||||
|
clickToCopy: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'clientId',
|
||||||
|
label: 'API Key',
|
||||||
|
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,
|
||||||
|
};
|
@@ -0,0 +1,8 @@
|
|||||||
|
import getCurrentUser from '../common/get-current-user.js';
|
||||||
|
|
||||||
|
const isStillVerified = async ($) => {
|
||||||
|
const currentUser = await getCurrentUser($);
|
||||||
|
return !!currentUser.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default isStillVerified;
|
@@ -0,0 +1,42 @@
|
|||||||
|
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://www.eventbrite.com/oauth/token',
|
||||||
|
{
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: $.auth.data.clientId,
|
||||||
|
client_secret: $.auth.data.clientSecret,
|
||||||
|
code: $.auth.data.code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
accessToken: data.access_token,
|
||||||
|
tokenType: data.token_type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUser = await getCurrentUser($);
|
||||||
|
|
||||||
|
const screenName = [currentUser.name, currentUser.emails[0].email]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' @ ');
|
||||||
|
|
||||||
|
await $.auth.set({
|
||||||
|
clientId: $.auth.data.clientId,
|
||||||
|
clientSecret: $.auth.data.clientSecret,
|
||||||
|
screenName,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default verifyCredentials;
|
@@ -0,0 +1,9 @@
|
|||||||
|
const addAuthHeader = ($, requestConfig) => {
|
||||||
|
if ($.auth.data?.accessToken) {
|
||||||
|
requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default addAuthHeader;
|
@@ -0,0 +1,6 @@
|
|||||||
|
const getCurrentUser = async ($) => {
|
||||||
|
const { data: currentUser } = await $.http.get('/v3/users/me');
|
||||||
|
return currentUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default getCurrentUser;
|
@@ -0,0 +1,4 @@
|
|||||||
|
import listEvents from './list-events/index.js';
|
||||||
|
import listOrganizations from './list-organizations/index.js';
|
||||||
|
|
||||||
|
export default [listEvents, listOrganizations];
|
@@ -0,0 +1,44 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List events',
|
||||||
|
key: 'listEvents',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const events = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
continuation: undefined,
|
||||||
|
order_by: 'created_desc',
|
||||||
|
};
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { data } = await $.http.get(
|
||||||
|
`/v3/organizations/${organizationId}/events/`,
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.pagination.has_more_items) {
|
||||||
|
params.continuation = data.pagination.continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.events) {
|
||||||
|
for (const event of data.events) {
|
||||||
|
events.data.push({
|
||||||
|
value: event.id,
|
||||||
|
name: `${event.name.text} (${event.status})`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (params.continuation);
|
||||||
|
|
||||||
|
return events;
|
||||||
|
},
|
||||||
|
};
|
@@ -0,0 +1,35 @@
|
|||||||
|
export default {
|
||||||
|
name: 'List organizations',
|
||||||
|
key: 'listOrganizations',
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const organizations = {
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
continuation: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
do {
|
||||||
|
const { data } = await $.http.get('/v3/users/me/organizations', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.pagination.has_more_items) {
|
||||||
|
params.continuation = data.pagination.continuation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.organizations) {
|
||||||
|
for (const organization of data.organizations) {
|
||||||
|
organizations.data.push({
|
||||||
|
value: organization.id,
|
||||||
|
name: organization.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (params.continuation);
|
||||||
|
|
||||||
|
return organizations;
|
||||||
|
},
|
||||||
|
};
|
20
packages/backend/src/apps/eventbrite/index.js
Normal file
20
packages/backend/src/apps/eventbrite/index.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import defineApp from '../../helpers/define-app.js';
|
||||||
|
import addAuthHeader from './common/add-auth-header.js';
|
||||||
|
import auth from './auth/index.js';
|
||||||
|
import dynamicData from './dynamic-data/index.js';
|
||||||
|
import triggers from './triggers/index.js';
|
||||||
|
|
||||||
|
export default defineApp({
|
||||||
|
name: 'Eventbrite',
|
||||||
|
key: 'eventbrite',
|
||||||
|
baseUrl: 'https://www.eventbrite.com',
|
||||||
|
apiBaseUrl: 'https://www.eventbriteapi.com',
|
||||||
|
iconUrl: '{BASE_URL}/apps/eventbrite/assets/favicon.svg',
|
||||||
|
authDocUrl: '{DOCS_URL}/apps/eventbrite/connection',
|
||||||
|
primaryColor: 'F05537',
|
||||||
|
supportsConnections: true,
|
||||||
|
beforeRequest: [addAuthHeader],
|
||||||
|
auth,
|
||||||
|
dynamicData,
|
||||||
|
triggers,
|
||||||
|
});
|
5
packages/backend/src/apps/eventbrite/triggers/index.js
Normal file
5
packages/backend/src/apps/eventbrite/triggers/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import newAttendeeCheckIn from './new-attendee-check-in/index.js';
|
||||||
|
import newAttendeeRegistered from './new-attendee-registered/index.js';
|
||||||
|
import newEvents from './new-events/index.js';
|
||||||
|
|
||||||
|
export default [newAttendeeCheckIn, newAttendeeRegistered, newEvents];
|
@@ -0,0 +1,120 @@
|
|||||||
|
import Crypto from 'crypto';
|
||||||
|
import defineTrigger from '../../../../helpers/define-trigger.js';
|
||||||
|
|
||||||
|
export default defineTrigger({
|
||||||
|
name: 'New attendee check in',
|
||||||
|
key: 'newAttendeeCheckIn',
|
||||||
|
type: 'webhook',
|
||||||
|
description: "Triggers when an attendee's barcode is scanned in.",
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
label: 'Organization',
|
||||||
|
key: 'organizationId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listOrganizations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Event',
|
||||||
|
key: 'eventId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listEvents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.organizationId',
|
||||||
|
value: '{parameters.organizationId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const dataItem = {
|
||||||
|
raw: $.request.body,
|
||||||
|
meta: {
|
||||||
|
internalId: Crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
$.pushTriggerItem(dataItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async testRun($) {
|
||||||
|
const eventId = $.step.parameters.eventId;
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
event_id: eventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { orders },
|
||||||
|
} = await $.http.get(`/v3/events/${eventId}/orders/`, params);
|
||||||
|
|
||||||
|
if (orders.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedWebhookEvent = {
|
||||||
|
config: {
|
||||||
|
action: 'barcode.checked_in',
|
||||||
|
user_id: organizationId,
|
||||||
|
webhook_id: '11111111',
|
||||||
|
endpoint_url: $.webhookUrl,
|
||||||
|
},
|
||||||
|
api_url: orders[0].resource_uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataItem = {
|
||||||
|
raw: computedWebhookEvent,
|
||||||
|
meta: {
|
||||||
|
internalId: computedWebhookEvent.user_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
$.pushTriggerItem(dataItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerHook($) {
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
const eventId = $.step.parameters.eventId;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
endpoint_url: $.webhookUrl,
|
||||||
|
actions: 'attendee.checked_in',
|
||||||
|
event_id: eventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.post(
|
||||||
|
`/v3/organizations/${organizationId}/webhooks/`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
await $.flow.setRemoteWebhookId(data.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async unregisterHook($) {
|
||||||
|
await $.http.delete(`/v3/webhooks/${$.flow.remoteWebhookId}/`);
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,120 @@
|
|||||||
|
import Crypto from 'crypto';
|
||||||
|
import defineTrigger from '../../../../helpers/define-trigger.js';
|
||||||
|
|
||||||
|
export default defineTrigger({
|
||||||
|
name: 'New attendee registered',
|
||||||
|
key: 'newAttendeeRegistered',
|
||||||
|
type: 'webhook',
|
||||||
|
description: 'Triggers when an attendee orders a ticket for an event.',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
label: 'Organization',
|
||||||
|
key: 'organizationId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listOrganizations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Event',
|
||||||
|
key: 'eventId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: false,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listEvents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'parameters.organizationId',
|
||||||
|
value: '{parameters.organizationId}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const dataItem = {
|
||||||
|
raw: $.request.body,
|
||||||
|
meta: {
|
||||||
|
internalId: Crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
$.pushTriggerItem(dataItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async testRun($) {
|
||||||
|
const eventId = $.step.parameters.eventId;
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
event_id: eventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { orders },
|
||||||
|
} = await $.http.get(`/v3/events/${eventId}/orders/`, params);
|
||||||
|
|
||||||
|
if (orders.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedWebhookEvent = {
|
||||||
|
config: {
|
||||||
|
action: 'order.placed',
|
||||||
|
user_id: organizationId,
|
||||||
|
webhook_id: '11111111',
|
||||||
|
endpoint_url: $.webhookUrl,
|
||||||
|
},
|
||||||
|
api_url: orders[0].resource_uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataItem = {
|
||||||
|
raw: computedWebhookEvent,
|
||||||
|
meta: {
|
||||||
|
internalId: computedWebhookEvent.user_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
$.pushTriggerItem(dataItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerHook($) {
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
const eventId = $.step.parameters.eventId;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
endpoint_url: $.webhookUrl,
|
||||||
|
actions: 'order.placed',
|
||||||
|
event_id: eventId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.post(
|
||||||
|
`/v3/organizations/${organizationId}/webhooks/`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
await $.flow.setRemoteWebhookId(data.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async unregisterHook($) {
|
||||||
|
await $.http.delete(`/v3/webhooks/${$.flow.remoteWebhookId}/`);
|
||||||
|
},
|
||||||
|
});
|
@@ -0,0 +1,98 @@
|
|||||||
|
import Crypto from 'crypto';
|
||||||
|
import defineTrigger from '../../../../helpers/define-trigger.js';
|
||||||
|
|
||||||
|
export default defineTrigger({
|
||||||
|
name: 'New events',
|
||||||
|
key: 'newEvents',
|
||||||
|
type: 'webhook',
|
||||||
|
description:
|
||||||
|
'Triggers when a new event is published and live within an organization.',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
label: 'Organization',
|
||||||
|
key: 'organizationId',
|
||||||
|
type: 'dropdown',
|
||||||
|
required: true,
|
||||||
|
description: '',
|
||||||
|
variables: true,
|
||||||
|
source: {
|
||||||
|
type: 'query',
|
||||||
|
name: 'getDynamicData',
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
name: 'key',
|
||||||
|
value: 'listOrganizations',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
async run($) {
|
||||||
|
const dataItem = {
|
||||||
|
raw: $.request.body,
|
||||||
|
meta: {
|
||||||
|
internalId: Crypto.randomUUID(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
$.pushTriggerItem(dataItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async testRun($) {
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
orderBy: 'created_desc',
|
||||||
|
status: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { events },
|
||||||
|
} = await $.http.get(`/v3/organizations/${organizationId}/events/`, params);
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedWebhookEvent = {
|
||||||
|
config: {
|
||||||
|
action: 'event.published',
|
||||||
|
user_id: events[0].organization_id,
|
||||||
|
webhook_id: '11111111',
|
||||||
|
endpoint_url: $.webhookUrl,
|
||||||
|
},
|
||||||
|
api_url: events[0].resource_uri,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataItem = {
|
||||||
|
raw: computedWebhookEvent,
|
||||||
|
meta: {
|
||||||
|
internalId: computedWebhookEvent.user_id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
$.pushTriggerItem(dataItem);
|
||||||
|
},
|
||||||
|
|
||||||
|
async registerHook($) {
|
||||||
|
const organizationId = $.step.parameters.organizationId;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
endpoint_url: $.webhookUrl,
|
||||||
|
actions: 'event.published',
|
||||||
|
event_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await $.http.post(
|
||||||
|
`/v3/organizations/${organizationId}/webhooks/`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
|
||||||
|
await $.flow.setRemoteWebhookId(data.id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async unregisterHook($) {
|
||||||
|
await $.http.delete(`/v3/webhooks/${$.flow.remoteWebhookId}/`);
|
||||||
|
},
|
||||||
|
});
|
@@ -5,11 +5,24 @@ const formatDateTime = ($) => {
|
|||||||
|
|
||||||
const fromFormat = $.step.parameters.fromFormat;
|
const fromFormat = $.step.parameters.fromFormat;
|
||||||
const fromTimezone = $.step.parameters.fromTimezone;
|
const fromTimezone = $.step.parameters.fromTimezone;
|
||||||
|
let inputDateTime;
|
||||||
|
|
||||||
const inputDateTime = DateTime.fromFormat(input, fromFormat, {
|
if (fromFormat === 'X') {
|
||||||
|
inputDateTime = DateTime.fromSeconds(Number(input), fromFormat, {
|
||||||
zone: fromTimezone,
|
zone: fromTimezone,
|
||||||
setZone: true,
|
setZone: true,
|
||||||
});
|
});
|
||||||
|
} else if (fromFormat === 'x') {
|
||||||
|
inputDateTime = DateTime.fromMillis(Number(input), fromFormat, {
|
||||||
|
zone: fromTimezone,
|
||||||
|
setZone: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
inputDateTime = DateTime.fromFormat(input, fromFormat, {
|
||||||
|
zone: fromTimezone,
|
||||||
|
setZone: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const toFormat = $.step.parameters.toFormat;
|
const toFormat = $.step.parameters.toFormat;
|
||||||
const toTimezone = $.step.parameters.toTimezone;
|
const toTimezone = $.step.parameters.toTimezone;
|
||||||
|
@@ -52,7 +52,7 @@ const appConfig = {
|
|||||||
isDev: appEnv === 'development',
|
isDev: appEnv === 'development',
|
||||||
isTest: appEnv === 'test',
|
isTest: appEnv === 'test',
|
||||||
isProd: appEnv === 'production',
|
isProd: appEnv === 'production',
|
||||||
version: '0.11.0',
|
version: '0.12.0',
|
||||||
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
|
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
|
||||||
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
|
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
|
||||||
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
|
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||||
@@ -98,6 +98,7 @@ const appConfig = {
|
|||||||
disableFavicon: process.env.DISABLE_FAVICON === 'true',
|
disableFavicon: process.env.DISABLE_FAVICON === 'true',
|
||||||
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
|
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
|
||||||
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
|
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
|
||||||
|
disableSeedUser: process.env.DISABLE_SEED_USER === 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!appConfig.encryptionKey) {
|
if (!appConfig.encryptionKey) {
|
||||||
|
@@ -1,11 +1,10 @@
|
|||||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import app from '../../../../../app';
|
import app from '../../../../../app';
|
||||||
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
|
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
|
||||||
import { createRole } from '../../../../../../test/factories/role';
|
import { createRole } from '../../../../../../test/factories/role';
|
||||||
import { createUser } from '../../../../../../test/factories/user';
|
import { createUser } from '../../../../../../test/factories/user';
|
||||||
import getUsersMock from '../../../../../../test/mocks/rest/api/v1/admin/users/get-users.js';
|
import getUsersMock from '../../../../../../test/mocks/rest/api/v1/admin/users/get-users.js';
|
||||||
import * as license from '../../../../../helpers/license.ee.js';
|
|
||||||
|
|
||||||
describe('GET /api/v1/admin/users', () => {
|
describe('GET /api/v1/admin/users', () => {
|
||||||
let currentUser, currentUserRole, anotherUser, anotherUserRole, token;
|
let currentUser, currentUserRole, anotherUser, anotherUserRole, token;
|
||||||
@@ -32,8 +31,6 @@ describe('GET /api/v1/admin/users', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return users data', async () => {
|
it('should return users data', async () => {
|
||||||
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
|
|
||||||
|
|
||||||
const response = await request(app)
|
const response = await request(app)
|
||||||
.get('/api/v1/admin/users')
|
.get('/api/v1/admin/users')
|
||||||
.set('Authorization', token)
|
.set('Authorization', token)
|
||||||
|
@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
|
|||||||
|
|
||||||
const expectedPayload = {
|
const expectedPayload = {
|
||||||
data: {
|
data: {
|
||||||
version: '0.11.0',
|
version: '0.12.0',
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
count: 1,
|
count: 1,
|
||||||
|
@@ -0,0 +1,9 @@
|
|||||||
|
import User from '../../../../../models/user.js';
|
||||||
|
|
||||||
|
export default async (request, response) => {
|
||||||
|
const { email, password, fullName } = request.body;
|
||||||
|
|
||||||
|
await User.createAdmin({ email, password, fullName });
|
||||||
|
|
||||||
|
response.status(204).end();
|
||||||
|
};
|
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import request from 'supertest';
|
||||||
|
import app from '../../../../../app.js';
|
||||||
|
import Config from '../../../../../models/config.js';
|
||||||
|
import User from '../../../../../models/user.js';
|
||||||
|
import { createRole } from '../../../../../../test/factories/role';
|
||||||
|
import { createUser } from '../../../../../../test/factories/user';
|
||||||
|
import { createInstallationCompletedConfig } from '../../../../../../test/factories/config';
|
||||||
|
|
||||||
|
describe('POST /api/v1/installation/users', () => {
|
||||||
|
let adminRole;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
adminRole = await createRole({
|
||||||
|
name: 'Admin',
|
||||||
|
key: 'admin',
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for incomplete installations', () => {
|
||||||
|
it('should respond with HTTP 204 with correct payload when no user', async () => {
|
||||||
|
expect(await Config.isInstallationCompleted()).toBe(false);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/installation/users')
|
||||||
|
.send({
|
||||||
|
email: 'user@automatisch.io',
|
||||||
|
password: 'password',
|
||||||
|
fullName: 'Initial admin'
|
||||||
|
})
|
||||||
|
.expect(204);
|
||||||
|
|
||||||
|
const user = await User.query().findOne({ email: 'user@automatisch.io' });
|
||||||
|
|
||||||
|
expect(user.roleId).toBe(adminRole.id);
|
||||||
|
expect(await Config.isInstallationCompleted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with HTTP 403 with correct payload when one user exists at least', async () => {
|
||||||
|
expect(await Config.isInstallationCompleted()).toBe(false);
|
||||||
|
|
||||||
|
await createUser();
|
||||||
|
|
||||||
|
const usersCountBefore = await User.query().resultSize();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/installation/users')
|
||||||
|
.send({
|
||||||
|
email: 'user@automatisch.io',
|
||||||
|
password: 'password',
|
||||||
|
fullName: 'Initial admin'
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
const usersCountAfter = await User.query().resultSize();
|
||||||
|
|
||||||
|
expect(usersCountBefore).toEqual(usersCountAfter);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('for completed installations', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await createInstallationCompletedConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respond with HTTP 403 when installation completed', async () => {
|
||||||
|
expect(await Config.isInstallationCompleted()).toBe(true);
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/installation/users')
|
||||||
|
.send({
|
||||||
|
email: 'user@automatisch.io',
|
||||||
|
password: 'password',
|
||||||
|
fullName: 'Initial admin'
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
const user = await User.query().findOne({ email: 'user@automatisch.io' });
|
||||||
|
|
||||||
|
expect(user).toBeUndefined();
|
||||||
|
expect(await Config.isInstallationCompleted()).toBe(true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
@@ -0,0 +1,11 @@
|
|||||||
|
export async function up(knex) {
|
||||||
|
return knex.schema.table('access_tokens', (table) => {
|
||||||
|
table.string('saml_session_id').nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex) {
|
||||||
|
return knex.schema.table('access_tokens', (table) => {
|
||||||
|
table.dropColumn('saml_session_id');
|
||||||
|
});
|
||||||
|
}
|
@@ -0,0 +1,17 @@
|
|||||||
|
export async function up(knex) {
|
||||||
|
const users = await knex('users').limit(1);
|
||||||
|
|
||||||
|
// no user implies installation is not completed yet.
|
||||||
|
if (users.length === 0) return;
|
||||||
|
|
||||||
|
await knex('config').insert({
|
||||||
|
key: 'installation.completed',
|
||||||
|
value: {
|
||||||
|
data: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function down(knex) {
|
||||||
|
await knex('config').where({ key: 'installation.completed' }).delete();
|
||||||
|
};
|
@@ -0,0 +1,11 @@
|
|||||||
|
export async function up(knex) {
|
||||||
|
return knex.schema.alterTable('datastore', (table) => {
|
||||||
|
table.text('value').alter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex) {
|
||||||
|
return knex.schema.alterTable('datastore', (table) => {
|
||||||
|
table.string('value').alter();
|
||||||
|
});
|
||||||
|
}
|
16
packages/backend/src/helpers/allow-installation.js
Normal file
16
packages/backend/src/helpers/allow-installation.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Config from '../models/config.js';
|
||||||
|
import User from '../models/user.js';
|
||||||
|
|
||||||
|
export async function allowInstallation(request, response, next) {
|
||||||
|
if (await Config.isInstallationCompleted()) {
|
||||||
|
return response.status(403).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasAnyUsers = await User.query().resultSize() > 0;
|
||||||
|
|
||||||
|
if (hasAnyUsers) {
|
||||||
|
return response.status(403).end();
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
@@ -4,12 +4,13 @@ import AccessToken from '../models/access-token.js';
|
|||||||
|
|
||||||
const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds
|
const TOKEN_EXPIRES_IN = 14 * 24 * 60 * 60; // 14 days in seconds
|
||||||
|
|
||||||
const createAuthTokenByUserId = async (userId) => {
|
const createAuthTokenByUserId = async (userId, samlSessionId) => {
|
||||||
const user = await User.query().findById(userId).throwIfNotFound();
|
const user = await User.query().findById(userId).throwIfNotFound();
|
||||||
const token = await crypto.randomBytes(48).toString('hex');
|
const token = await crypto.randomBytes(48).toString('hex');
|
||||||
|
|
||||||
await AccessToken.query().insert({
|
await AccessToken.query().insert({
|
||||||
token,
|
token,
|
||||||
|
samlSessionId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
expiresIn: TOKEN_EXPIRES_IN,
|
expiresIn: TOKEN_EXPIRES_IN,
|
||||||
});
|
});
|
||||||
|
@@ -5,8 +5,11 @@ import passport from 'passport';
|
|||||||
import appConfig from '../config/app.js';
|
import appConfig from '../config/app.js';
|
||||||
import createAuthTokenByUserId from './create-auth-token-by-user-id.js';
|
import createAuthTokenByUserId from './create-auth-token-by-user-id.js';
|
||||||
import SamlAuthProvider from '../models/saml-auth-provider.ee.js';
|
import SamlAuthProvider from '../models/saml-auth-provider.ee.js';
|
||||||
|
import AccessToken from '../models/access-token.js';
|
||||||
import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js';
|
import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee.js';
|
||||||
|
|
||||||
|
const asyncNoop = async () => { };
|
||||||
|
|
||||||
export default function configurePassport(app) {
|
export default function configurePassport(app) {
|
||||||
app.use(
|
app.use(
|
||||||
passport.initialize({
|
passport.initialize({
|
||||||
@@ -19,6 +22,10 @@ export default function configurePassport(app) {
|
|||||||
{
|
{
|
||||||
passReqToCallback: true,
|
passReqToCallback: true,
|
||||||
getSamlOptions: async function (request, done) {
|
getSamlOptions: async function (request, done) {
|
||||||
|
// This is a workaround to avoid session logout which passport-saml enforces
|
||||||
|
request.logout = asyncNoop;
|
||||||
|
request.logOut = asyncNoop;
|
||||||
|
|
||||||
const { issuer } = request.params;
|
const { issuer } = request.params;
|
||||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||||
|
|
||||||
@@ -35,7 +42,7 @@ export default function configurePassport(app) {
|
|||||||
return done(null, authProvider.config);
|
return done(null, authProvider.config);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async function (request, user, done) {
|
async function signonVerify(request, user, done) {
|
||||||
const { issuer } = request.params;
|
const { issuer } = request.params;
|
||||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||||
|
|
||||||
@@ -53,10 +60,38 @@ export default function configurePassport(app) {
|
|||||||
user,
|
user,
|
||||||
authProvider
|
authProvider
|
||||||
);
|
);
|
||||||
|
|
||||||
|
request.samlSessionId = user.sessionIndex;
|
||||||
|
|
||||||
return done(null, foundUserWithIdentity);
|
return done(null, foundUserWithIdentity);
|
||||||
},
|
},
|
||||||
function (request, user, done) {
|
async function logoutVerify(request, user, done) {
|
||||||
return done(null, null);
|
const { issuer } = request.params;
|
||||||
|
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||||
|
|
||||||
|
if (!issuer) return done(notFoundIssuer);
|
||||||
|
|
||||||
|
const authProvider = await SamlAuthProvider.query().findOne({
|
||||||
|
issuer: request.params.issuer,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!authProvider) {
|
||||||
|
return done(notFoundIssuer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const foundUserWithIdentity = await findOrCreateUserBySamlIdentity(
|
||||||
|
user,
|
||||||
|
authProvider
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = await AccessToken.query().findOne({
|
||||||
|
revoked_at: null,
|
||||||
|
saml_session_id: user.sessionIndex,
|
||||||
|
}).throwIfNotFound();
|
||||||
|
|
||||||
|
await accessToken.revoke();
|
||||||
|
|
||||||
|
return done(null, foundUserWithIdentity);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -73,17 +108,22 @@ export default function configurePassport(app) {
|
|||||||
'/login/saml/:issuer/callback',
|
'/login/saml/:issuer/callback',
|
||||||
passport.authenticate('saml', {
|
passport.authenticate('saml', {
|
||||||
session: false,
|
session: false,
|
||||||
failureRedirect: '/',
|
|
||||||
failureFlash: true,
|
|
||||||
}),
|
}),
|
||||||
async (req, res) => {
|
async (request, response) => {
|
||||||
const token = await createAuthTokenByUserId(req.currentUser.id);
|
const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId);
|
||||||
|
|
||||||
const redirectUrl = new URL(
|
const redirectUrl = new URL(
|
||||||
`/login/callback?token=${token}`,
|
`/login/callback?token=${token}`,
|
||||||
appConfig.webAppUrl
|
appConfig.webAppUrl
|
||||||
).toString();
|
).toString();
|
||||||
res.redirect(redirectUrl);
|
response.redirect(redirectUrl);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/logout/saml/:issuer',
|
||||||
|
passport.authenticate('saml', {
|
||||||
|
session: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,7 @@ class AccessToken extends Base {
|
|||||||
id: { type: 'string', format: 'uuid' },
|
id: { type: 'string', format: 'uuid' },
|
||||||
userId: { type: 'string', format: 'uuid' },
|
userId: { type: 'string', format: 'uuid' },
|
||||||
token: { type: 'string', minLength: 32 },
|
token: { type: 'string', minLength: 32 },
|
||||||
|
samlSessionId: { type: ['string', 'null'] },
|
||||||
expiresIn: { type: 'integer' },
|
expiresIn: { type: 'integer' },
|
||||||
revokedAt: { type: ['string', 'null'], format: 'date-time' },
|
revokedAt: { type: ['string', 'null'], format: 'date-time' },
|
||||||
},
|
},
|
||||||
@@ -28,8 +29,37 @@ class AccessToken extends Base {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async terminateRemoteSamlSession() {
|
||||||
|
if (!this.samlSessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this
|
||||||
|
.$relatedQuery('user');
|
||||||
|
|
||||||
|
const firstIdentity = await user
|
||||||
|
.$relatedQuery('identities')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const samlAuthProvider = await firstIdentity
|
||||||
|
.$relatedQuery('samlAuthProvider')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
const response = await samlAuthProvider.terminateRemoteSession(this.samlSessionId);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
async revoke() {
|
async revoke() {
|
||||||
return await this.$query().patch({ revokedAt: new Date().toISOString() });
|
const response = await this.$query().patch({ revokedAt: new Date().toISOString() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.terminateRemoteSamlSession();
|
||||||
|
} catch (error) {
|
||||||
|
// TODO: should it silently fail or not?
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -13,6 +13,28 @@ class Config extends Base {
|
|||||||
value: { type: 'object' },
|
value: { type: 'object' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static async isInstallationCompleted() {
|
||||||
|
const installationCompletedEntry = await this
|
||||||
|
.query()
|
||||||
|
.where({
|
||||||
|
key: 'installation.completed'
|
||||||
|
})
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const installationCompleted = installationCompletedEntry?.value?.data === true;
|
||||||
|
|
||||||
|
return installationCompleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async markInstallationCompleted() {
|
||||||
|
return await this.query().insert({
|
||||||
|
key: 'installation.completed',
|
||||||
|
value: {
|
||||||
|
data: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Config;
|
export default Config;
|
||||||
|
@@ -45,6 +45,10 @@ class Role extends Base {
|
|||||||
get isAdmin() {
|
get isAdmin() {
|
||||||
return this.key === 'admin';
|
return this.key === 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async findAdmin() {
|
||||||
|
return await this.query().findOne({ key: 'admin' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Role;
|
export default Role;
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import appConfig from '../config/app.js';
|
import appConfig from '../config/app.js';
|
||||||
|
import axios from '../helpers/axios-with-proxy.js';
|
||||||
import Base from './base.js';
|
import Base from './base.js';
|
||||||
import Identity from './identity.ee.js';
|
import Identity from './identity.ee.js';
|
||||||
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
||||||
@@ -61,27 +63,71 @@ class SamlAuthProvider extends Base {
|
|||||||
});
|
});
|
||||||
|
|
||||||
static get virtualAttributes() {
|
static get virtualAttributes() {
|
||||||
return ['loginUrl'];
|
return ['loginUrl', 'remoteLogoutUrl'];
|
||||||
}
|
}
|
||||||
|
|
||||||
get loginUrl() {
|
get loginUrl() {
|
||||||
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
get config() {
|
get loginCallBackUrl() {
|
||||||
const callbackUrl = new URL(
|
return new URL(
|
||||||
`/login/saml/${this.issuer}/callback`,
|
`/login/saml/${this.issuer}/callback`,
|
||||||
appConfig.baseUrl
|
appConfig.baseUrl
|
||||||
).toString();
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get remoteLogoutUrl() {
|
||||||
|
return this.entryPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
get config() {
|
||||||
return {
|
return {
|
||||||
callbackUrl,
|
callbackUrl: this.loginCallBackUrl,
|
||||||
cert: this.certificate,
|
cert: this.certificate,
|
||||||
entryPoint: this.entryPoint,
|
entryPoint: this.entryPoint,
|
||||||
issuer: this.issuer,
|
issuer: this.issuer,
|
||||||
signatureAlgorithm: this.signatureAlgorithm,
|
signatureAlgorithm: this.signatureAlgorithm,
|
||||||
|
logoutUrl: this.remoteLogoutUrl
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateLogoutRequestBody(sessionId) {
|
||||||
|
const logoutRequest = `
|
||||||
|
<samlp:LogoutRequest
|
||||||
|
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||||
|
ID="${uuidv4()}"
|
||||||
|
Version="2.0"
|
||||||
|
IssueInstant="${new Date().toISOString()}"
|
||||||
|
Destination="${this.remoteLogoutUrl}">
|
||||||
|
|
||||||
|
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${this.issuer}</saml:Issuer>
|
||||||
|
<samlp:SessionIndex>${sessionId}</samlp:SessionIndex>
|
||||||
|
</samlp:LogoutRequest>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const encodedLogoutRequest = Buffer.from(logoutRequest).toString('base64')
|
||||||
|
|
||||||
|
return encodedLogoutRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminateRemoteSession(sessionId) {
|
||||||
|
const logoutRequest = this.generateLogoutRequestBody(sessionId);
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
this.remoteLogoutUrl,
|
||||||
|
new URLSearchParams({
|
||||||
|
SAMLRequest: logoutRequest,
|
||||||
|
}).toString(),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SamlAuthProvider;
|
export default SamlAuthProvider;
|
||||||
|
@@ -10,6 +10,7 @@ import Base from './base.js';
|
|||||||
import App from './app.js';
|
import App from './app.js';
|
||||||
import AccessToken from './access-token.js';
|
import AccessToken from './access-token.js';
|
||||||
import Connection from './connection.js';
|
import Connection from './connection.js';
|
||||||
|
import Config from './config.js';
|
||||||
import Execution from './execution.js';
|
import Execution from './execution.js';
|
||||||
import Flow from './flow.js';
|
import Flow from './flow.js';
|
||||||
import Identity from './identity.ee.js';
|
import Identity from './identity.ee.js';
|
||||||
@@ -373,6 +374,21 @@ class User extends Base {
|
|||||||
return apps;
|
return apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async createAdmin({ email, password, fullName }) {
|
||||||
|
const adminRole = await Role.findAdmin();
|
||||||
|
|
||||||
|
const adminUser = await this.query().insert({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
fullName,
|
||||||
|
roleId: adminRole.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await Config.markInstallationCompleted();
|
||||||
|
|
||||||
|
return adminUser;
|
||||||
|
}
|
||||||
|
|
||||||
async $beforeInsert(queryContext) {
|
async $beforeInsert(queryContext) {
|
||||||
await super.$beforeInsert(queryContext);
|
await super.$beforeInsert(queryContext);
|
||||||
|
|
||||||
|
@@ -2,25 +2,17 @@ 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 { authorizeAdmin } from '../../../../helpers/authorization.js';
|
import { authorizeAdmin } from '../../../../helpers/authorization.js';
|
||||||
import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js';
|
|
||||||
import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js';
|
import getUsersAction from '../../../../controllers/api/v1/admin/users/get-users.ee.js';
|
||||||
import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js';
|
import getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get(
|
router.get('/', authenticateUser, authorizeAdmin, asyncHandler(getUsersAction));
|
||||||
'/',
|
|
||||||
authenticateUser,
|
|
||||||
authorizeAdmin,
|
|
||||||
checkIsEnterprise,
|
|
||||||
asyncHandler(getUsersAction)
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:userId',
|
'/:userId',
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
authorizeAdmin,
|
authorizeAdmin,
|
||||||
checkIsEnterprise,
|
|
||||||
asyncHandler(getUserAction)
|
asyncHandler(getUserAction)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
14
packages/backend/src/routes/api/v1/installation/users.js
Normal file
14
packages/backend/src/routes/api/v1/installation/users.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import asyncHandler from 'express-async-handler';
|
||||||
|
import { allowInstallation } from '../../../../helpers/allow-installation.js';
|
||||||
|
import createUserAction from '../../../../controllers/api/v1/installation/users/create-user.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
allowInstallation,
|
||||||
|
asyncHandler(createUserAction)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
@@ -18,6 +18,7 @@ import adminSamlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.
|
|||||||
import rolesRouter from './api/v1/admin/roles.ee.js';
|
import rolesRouter from './api/v1/admin/roles.ee.js';
|
||||||
import permissionsRouter from './api/v1/admin/permissions.ee.js';
|
import permissionsRouter from './api/v1/admin/permissions.ee.js';
|
||||||
import adminUsersRouter from './api/v1/admin/users.ee.js';
|
import adminUsersRouter from './api/v1/admin/users.ee.js';
|
||||||
|
import installationUsersRouter from './api/v1/installation/users.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -40,5 +41,7 @@ router.use('/api/v1/admin/users', adminUsersRouter);
|
|||||||
router.use('/api/v1/admin/roles', rolesRouter);
|
router.use('/api/v1/admin/roles', rolesRouter);
|
||||||
router.use('/api/v1/admin/permissions', permissionsRouter);
|
router.use('/api/v1/admin/permissions', permissionsRouter);
|
||||||
router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter);
|
router.use('/api/v1/admin/saml-auth-providers', adminSamlAuthProvidersRouter);
|
||||||
|
router.use('/api/v1/installation/users', installationUsersRouter);
|
||||||
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
@@ -11,3 +11,7 @@ export const createConfig = async (params = {}) => {
|
|||||||
|
|
||||||
return config;
|
return config;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createInstallationCompletedConfig = async () => {
|
||||||
|
return await createConfig({ key: 'installation.completed', value: { data: true } });
|
||||||
|
}
|
||||||
|
@@ -8,7 +8,7 @@ global.beforeAll(async () => {
|
|||||||
logger.silent = true;
|
logger.silent = true;
|
||||||
|
|
||||||
// Remove default roles and permissions before running the test suite
|
// Remove default roles and permissions before running the test suite
|
||||||
await knex.raw('TRUNCATE TABLE roles, permissions CASCADE');
|
await knex.raw('TRUNCATE TABLE config, roles, permissions CASCADE');
|
||||||
});
|
});
|
||||||
|
|
||||||
global.beforeEach(async () => {
|
global.beforeEach(async () => {
|
||||||
|
@@ -26,12 +26,30 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Apps',
|
text: 'Apps',
|
||||||
link: '/apps/carbone/connection',
|
link: '/apps/airtable/connection',
|
||||||
activeMatch: '/apps/',
|
activeMatch: '/apps/',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sidebar: {
|
sidebar: {
|
||||||
'/apps/': [
|
'/apps/': [
|
||||||
|
{
|
||||||
|
text: 'Airtable',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
items: [
|
||||||
|
{ text: 'Actions', link: '/apps/airtable/actions' },
|
||||||
|
{ text: 'Connection', link: '/apps/airtable/connection' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Appwrite',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
items: [
|
||||||
|
{ text: 'Triggers', link: '/apps/appwrite/triggers' },
|
||||||
|
{ text: 'Connection', link: '/apps/appwrite/connection' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Carbone',
|
text: 'Carbone',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
@@ -95,6 +113,15 @@ export default defineConfig({
|
|||||||
{ text: 'Connection', link: '/apps/dropbox/connection' },
|
{ text: 'Connection', link: '/apps/dropbox/connection' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'Eventbrite',
|
||||||
|
collapsible: true,
|
||||||
|
collapsed: true,
|
||||||
|
items: [
|
||||||
|
{ text: 'Triggers', link: '/apps/eventbrite/triggers' },
|
||||||
|
{ text: 'Connection', link: '/apps/eventbrite/connection' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: 'Filter',
|
text: 'Filter',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
|
14
packages/docs/pages/apps/airtable/actions.md
Normal file
14
packages/docs/pages/apps/airtable/actions.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
favicon: /favicons/airtable.svg
|
||||||
|
items:
|
||||||
|
- name: Create record
|
||||||
|
desc: Creates a new record with fields that automatically populate.
|
||||||
|
- name: Find record
|
||||||
|
desc: Finds a record using simple field search or use Airtable's formula syntax to find a matching record.
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CustomListing from '../../components/CustomListing.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomListing />
|
19
packages/docs/pages/apps/airtable/connection.md
Normal file
19
packages/docs/pages/apps/airtable/connection.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Airtable
|
||||||
|
|
||||||
|
:::info
|
||||||
|
This page explains the steps you need to follow to set up the Airtable
|
||||||
|
connection in Automatisch. If any of the steps are outdated, please let us know!
|
||||||
|
:::
|
||||||
|
|
||||||
|
1. Login to your [Airtable account](https://www.airtable.com/).
|
||||||
|
2. Go to this [link](https://airtable.com/create/oauth) and click on the **Register new OAuth integration**.
|
||||||
|
3. Fill the name field.
|
||||||
|
4. Copy **OAuth Redirect URL** from Automatisch to **OAuth redirect URL** field.
|
||||||
|
5. Click on the **Register integration** button.
|
||||||
|
6. In **Developer Details** section, click on the **Generate client secret**.
|
||||||
|
7. Check the checkboxes of **Scopes** section.
|
||||||
|
8. Click on the **Save changes** button.
|
||||||
|
9. Copy **Client ID** to **Client ID** field on Automatisch.
|
||||||
|
10. Copy **Client secret** to **Client secret** field on Automatisch.
|
||||||
|
11. Click **Submit** button on Automatisch.
|
||||||
|
12. Congrats! Start using your new Airtable connection within the flows.
|
20
packages/docs/pages/apps/appwrite/connection.md
Normal file
20
packages/docs/pages/apps/appwrite/connection.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Appwrite
|
||||||
|
|
||||||
|
:::info
|
||||||
|
This page explains the steps you need to follow to set up the Appwrite
|
||||||
|
connection in Automatisch. If any of the steps are outdated, please let us know!
|
||||||
|
:::
|
||||||
|
|
||||||
|
1. Login to your Appwrite account: [https://appwrite.io/](https://appwrite.io/).
|
||||||
|
2. Go to your project's **Settings**.
|
||||||
|
3. In the Settings, click on the **View API Keys** button in **API credentials** section.
|
||||||
|
4. Click on the **Create API Key** button.
|
||||||
|
5. Fill the name field and select **Never** for the expiration date.
|
||||||
|
6. Click on the **Next** button.
|
||||||
|
7. Click on the **Select all** and then click on the **Create** button.
|
||||||
|
8. Now, copy your **API key secret** and paste the key into the **API Key** field in Automatisch.
|
||||||
|
9. Write any screen name to be displayed in Automatisch.
|
||||||
|
10. You can find your project ID next to your project name. Paste the id into **Project ID** field in Automatsich.
|
||||||
|
11. If you are using self-hosted Appwrite project, you can paste the instace url into **Appwrite instance URL** field in Automatisch.
|
||||||
|
12. Fill the host name field with the hostname of your instance URL. It's either `cloud.appwrite.io` or hostname of your instance URL.
|
||||||
|
13. Start using Appwrite integration with Automatisch!
|
12
packages/docs/pages/apps/appwrite/triggers.md
Normal file
12
packages/docs/pages/apps/appwrite/triggers.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
favicon: /favicons/appwrite.svg
|
||||||
|
items:
|
||||||
|
- name: New documets
|
||||||
|
desc: Triggers when a new document is created.
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CustomListing from '../../components/CustomListing.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomListing />
|
18
packages/docs/pages/apps/eventbrite/connection.md
Normal file
18
packages/docs/pages/apps/eventbrite/connection.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Eventbrite
|
||||||
|
|
||||||
|
:::info
|
||||||
|
This page explains the steps you need to follow to set up the Eventbrite
|
||||||
|
connection in Automatisch. If any of the steps are outdated, please let us know!
|
||||||
|
:::
|
||||||
|
|
||||||
|
1. Go to your Eventbrite account settings.
|
||||||
|
2. Click on the **Developer Links**, and click on the **API Keys** button.
|
||||||
|
3. Click on the **Create API Key** button.
|
||||||
|
4. Fill the form.
|
||||||
|
5. Copy **OAuth Redirect URL** from Automatisch to **OAuth Redirect URI** field in the form.
|
||||||
|
6. After filling the form, click on the **Create Key** button.
|
||||||
|
7. Click on the **Show API key, client secret and tokens** in the middle of the page.
|
||||||
|
8. Copy the **API Key** value to the `API Key` field on Automatisch.
|
||||||
|
9. Copy the **Client secret** value to the `Client Secret` field on Automatisch.
|
||||||
|
10. Click **Submit** button on Automatisch.
|
||||||
|
11. Congrats! Start using your new Eventbrite connection within the flows.
|
16
packages/docs/pages/apps/eventbrite/triggers.md
Normal file
16
packages/docs/pages/apps/eventbrite/triggers.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
favicon: /favicons/eventbrite.svg
|
||||||
|
items:
|
||||||
|
- name: New attendee check in
|
||||||
|
desc: Triggers when an attendee's barcode is scanned in.
|
||||||
|
- name: New attendee registered
|
||||||
|
desc: Triggers when an attendee orders a ticket for an event.
|
||||||
|
- name: New events
|
||||||
|
desc: Triggers when a new event is published and live within an organization.
|
||||||
|
---
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import CustomListing from '../../components/CustomListing.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CustomListing />
|
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
The following integrations are currently supported by Automatisch.
|
The following integrations are currently supported by Automatisch.
|
||||||
|
|
||||||
|
- [Airtable](/apps/airtable/actions)
|
||||||
|
- [Appwrite](/apps/appwrite/triggers)
|
||||||
- [Carbone](/apps/carbone/actions)
|
- [Carbone](/apps/carbone/actions)
|
||||||
- [Datastore](/apps/datastore/actions)
|
- [Datastore](/apps/datastore/actions)
|
||||||
- [DeepL](/apps/deepl/actions)
|
- [DeepL](/apps/deepl/actions)
|
||||||
@@ -9,6 +11,7 @@ The following integrations are currently supported by Automatisch.
|
|||||||
- [Discord](/apps/discord/actions)
|
- [Discord](/apps/discord/actions)
|
||||||
- [Disqus](/apps/disqus/triggers)
|
- [Disqus](/apps/disqus/triggers)
|
||||||
- [Dropbox](/apps/dropbox/actions)
|
- [Dropbox](/apps/dropbox/actions)
|
||||||
|
- [Eventbrite](/apps/eventbrite/triggers)
|
||||||
- [Filter](/apps/filter/actions)
|
- [Filter](/apps/filter/actions)
|
||||||
- [Flickr](/apps/flickr/triggers)
|
- [Flickr](/apps/flickr/triggers)
|
||||||
- [Formatter](/apps/formatter/actions)
|
- [Formatter](/apps/formatter/actions)
|
||||||
|
9
packages/docs/pages/public/favicons/airtable.svg
Normal file
9
packages/docs/pages/public/favicons/airtable.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="256px" height="215px" viewBox="0 0 256 215" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||||
|
<g>
|
||||||
|
<path d="M114.25873,2.70101695 L18.8604023,42.1756384 C13.5552723,44.3711638 13.6102328,51.9065311 18.9486282,54.0225085 L114.746142,92.0117514 C123.163769,95.3498757 132.537419,95.3498757 140.9536,92.0117514 L236.75256,54.0225085 C242.08951,51.9065311 242.145916,44.3711638 236.83934,42.1756384 L141.442459,2.70101695 C132.738459,-0.900338983 122.961284,-0.900338983 114.25873,2.70101695" fill="#FFBF00"></path>
|
||||||
|
<path d="M136.349071,112.756863 L136.349071,207.659101 C136.349071,212.173089 140.900664,215.263892 145.096461,213.600615 L251.844122,172.166219 C254.281184,171.200072 255.879376,168.845451 255.879376,166.224705 L255.879376,71.3224678 C255.879376,66.8084791 251.327783,63.7176768 247.131986,65.3809537 L140.384325,106.815349 C137.94871,107.781496 136.349071,110.136118 136.349071,112.756863" fill="#26B5F8"></path>
|
||||||
|
<path d="M111.422771,117.65355 L79.742409,132.949912 L76.5257763,134.504714 L9.65047684,166.548104 C5.4112904,168.593211 0.000578531073,165.503855 0.000578531073,160.794612 L0.000578531073,71.7210757 C0.000578531073,70.0173017 0.874160452,68.5463864 2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill="#ED3049"></path>
|
||||||
|
<path d="M111.422771,117.65355 L79.742409,132.949912 L2.04568588,67.4384994 C2.53454463,66.9481944 3.08848814,66.5446689 3.66412655,66.2250305 C5.26231864,65.2661153 7.54173107,65.0101153 9.47981017,65.7766689 L110.890522,105.957098 C116.045234,108.002206 116.450206,115.225166 111.422771,117.65355" fill-opacity="0.25" fill="#000000"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
1
packages/docs/pages/public/favicons/appwrite.svg
Normal file
1
packages/docs/pages/public/favicons/appwrite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="132" height="24" fill="none" viewBox="0 0 132 24"><path fill="#19191C" d="M38.557 19.495c2.16 0 3.25-1.113 3.725-1.87h.214c.094.805.664 1.562 1.779 1.562h2.111V16.82h-.545c-.38 0-.57-.213-.57-.545V6.776h-2.8v1.516h-.213c-.545-.758-1.684-1.824-3.772-1.824-3.321 0-5.789 2.748-5.789 6.514s2.515 6.513 5.86 6.513m.498-2.7c-1.969 0-3.51-1.445-3.51-3.79 0-2.297 1.494-3.86 3.487-3.86 1.898 0 3.487 1.397 3.487 3.86 0 2.108-1.352 3.79-3.463 3.79M48.04 24h2.799v-6.376h.213c.522.758 1.637 1.871 3.844 1.871 3.321 0 5.741-2.795 5.741-6.513 0-3.743-2.586-6.514-5.931-6.514-2.135 0-3.18 1.16-3.678 1.8h-.213V6.776h-2.776V24m6.263-7.134c-1.922 0-3.512-1.42-3.512-3.884 0-2.108 1.353-3.885 3.464-3.885 1.97 0 3.511 1.54 3.511 3.885 0 2.297-1.494 3.884-3.463 3.884M62.082 24h2.8v-6.376h.213c.522.758 1.637 1.871 3.843 1.871 3.321 0 5.51-2.795 5.51-6.513 0-3.743-2.355-6.514-5.7-6.514-2.135 0-3.179 1.16-3.677 1.8h-.214V6.776h-2.775zm6.263-7.134c-1.922 0-3.511-1.42-3.511-3.884 0-2.108 1.352-3.885 3.463-3.885 1.97 0 3.512 1.54 3.512 3.885 0 2.297-1.495 3.884-3.464 3.884m9.805 2.61h3.961l2.254-9.735h.143l2.253 9.735H90.7l3.153-12.412h-2.821l-2.254 9.759h-.214l-2.253-9.759h-3.725l-2.278 9.759h-.213l-2.23-9.759h-2.99l3.274 12.412m17.123 0h2.8V13.34c0-2.345 1.09-3.79 3.131-3.79h1.233V6.756h-.925c-1.59 0-2.8 1.09-3.274 2.132h-.19V7.064h-2.775zm21.057 0h2.183v-2.487h-2.159c-.854 0-1.21-.38-1.21-1.256V9.528h3.511V7.064h-3.511V3.582h-2.657v3.482h-2.325v2.464h2.159v6.229c0 2.63 1.589 3.719 4.009 3.719m9.693.019c2.586 0 4.864-1.279 5.67-3.86l-2.562-.616c-.451 1.373-1.755 2.084-3.131 2.084-2.041 0-3.393-1.326-3.417-3.41h9.419v-.782c0-3.695-2.301-6.443-6.097-6.443-3.346 0-6.216 2.63-6.216 6.537 0 3.79 2.538 6.49 6.334 6.49m-3.416-7.84c.166-1.492 1.518-2.747 3.298-2.747 1.708 0 3.108 1.066 3.25 2.747h-6.548"/><path fill="#19191C" fill-rule="evenodd" d="M108.916 19.476h-2.8V9.528h-2.182V7.064h4.982z" clip-rule="evenodd"/><path fill="#19191C" d="M107.309 5.342c1.02 0 1.779-.758 1.779-1.753 0-.971-.759-1.73-1.779-1.73-1.021 0-1.78.759-1.78 1.73 0 .995.759 1.753 1.78 1.753"/><path fill="#FD366E" d="M24.443 16.432v5.478H10.752c-3.989 0-7.472-2.203-9.335-5.478A11.041 11.041 0 0 1 0 11.695v-1.48a10.97 10.97 0 0 1 .381-2.247C1.661 3.368 5.82 0 10.751 0c4.934 0 9.092 3.37 10.371 7.967h-5.854c-.96-1.499-2.624-2.49-4.516-2.49s-3.555.991-4.516 2.49a5.47 5.47 0 0 0-.67 1.494 5.562 5.562 0 0 0-.202 1.494 5.5 5.5 0 0 0 1.69 3.983 5.32 5.32 0 0 0 3.698 1.494h13.69"/><path fill="#FD366E" d="M24.443 9.46v5.478h-9.994a5.5 5.5 0 0 0 1.691-3.983 5.56 5.56 0 0 0-.203-1.494h8.506"/></svg>
|
After Width: | Height: | Size: 2.6 KiB |
7
packages/docs/pages/public/favicons/eventbrite.svg
Normal file
7
packages/docs/pages/public/favicons/eventbrite.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||||
|
<g>
|
||||||
|
<circle fill="#F05537" cx="128" cy="128" r="128"></circle>
|
||||||
|
<path d="M117.475323,82.7290398 C136.772428,78.4407943 156.069532,86.3025777 166.790146,101.311437 L81.5017079,120.608542 C84.3605382,102.26438 98.1782181,87.0172853 117.475323,82.7290398 Z M167.266618,153.48509 C160.596014,163.252761 150.351872,170.161601 138.678314,172.782195 C119.38121,177.070441 99.8458692,169.208657 89.1252554,153.961562 L174.651929,134.664457 L188.469609,131.567391 L215.152026,125.611495 C214.91379,119.893834 214.199082,114.176173 213.007903,108.696749 C202.287289,62.7172275 155.354825,33.8906884 108.42236,44.6113021 C61.4898956,55.3319159 32.1868848,101.073201 43.1457344,147.290958 C54.1045839,193.508715 100.798813,222.097018 147.731277,211.376404 C175.366637,205.182272 196.807864,186.599875 207.766714,163.014525 L167.266618,153.48509 L167.266618,153.48509 Z" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
@@ -61,7 +61,7 @@ test.describe('Apps page', () => {
|
|||||||
|
|
||||||
await applicationsPage.page.getByTestId('app-list-item').first().click();
|
await applicationsPage.page.getByTestId('app-list-item').first().click();
|
||||||
await expect(applicationsPage.page).toHaveURL(
|
await expect(applicationsPage.page).toHaveURL(
|
||||||
'/app/azure-openai/connections/add?shared=false'
|
'/app/airtable/connections/add?shared=false'
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
applicationsPage.page.getByTestId('add-app-connection-dialog')
|
applicationsPage.page.getByTestId('add-app-connection-dialog')
|
||||||
@@ -75,14 +75,14 @@ test.describe('Apps page', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await applicationsPage.page.getByTestId('app-list-item').first().click();
|
await applicationsPage.page.getByTestId('app-list-item').first().click();
|
||||||
await expect(applicationsPage.page).toHaveURL(
|
await expect(applicationsPage.page).toHaveURL(
|
||||||
'/app/azure-openai/connections/add?shared=false'
|
'/app/airtable/connections/add?shared=false'
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
applicationsPage.page.getByTestId('add-app-connection-dialog')
|
applicationsPage.page.getByTestId('add-app-connection-dialog')
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await applicationsPage.clickAway();
|
await applicationsPage.clickAway();
|
||||||
await expect(applicationsPage.page).toHaveURL(
|
await expect(applicationsPage.page).toHaveURL(
|
||||||
'/app/azure-openai/connections'
|
'/app/airtable/connections'
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
applicationsPage.page.getByTestId('add-app-connection-dialog')
|
applicationsPage.page.getByTestId('add-app-connection-dialog')
|
||||||
|
@@ -7,6 +7,7 @@
|
|||||||
"@apollo/client": "^3.6.9",
|
"@apollo/client": "^3.6.9",
|
||||||
"@casl/ability": "^6.5.0",
|
"@casl/ability": "^6.5.0",
|
||||||
"@casl/react": "^3.1.0",
|
"@casl/react": "^3.1.0",
|
||||||
|
"@dagrejs/dagre": "^1.1.2",
|
||||||
"@emotion/react": "^11.4.1",
|
"@emotion/react": "^11.4.1",
|
||||||
"@emotion/styled": "^11.3.0",
|
"@emotion/styled": "^11.3.0",
|
||||||
"@hookform/resolvers": "^2.8.8",
|
"@hookform/resolvers": "^2.8.8",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"react-router-dom": "^6.0.2",
|
"react-router-dom": "^6.0.2",
|
||||||
"react-scripts": "5.0.0",
|
"react-scripts": "5.0.0",
|
||||||
"react-window": "^1.8.9",
|
"react-window": "^1.8.9",
|
||||||
|
"reactflow": "^11.11.2",
|
||||||
"slate": "^0.94.1",
|
"slate": "^0.94.1",
|
||||||
"slate-history": "^0.93.0",
|
"slate-history": "^0.93.0",
|
||||||
"slate-react": "^0.94.2",
|
"slate-react": "^0.94.2",
|
||||||
|
@@ -36,7 +36,7 @@ function AdminApplicationSettings(props) {
|
|||||||
|
|
||||||
const handleSubmit = async (values) => {
|
const handleSubmit = async (values) => {
|
||||||
try {
|
try {
|
||||||
if (!appConfig.data) {
|
if (!appConfig?.data) {
|
||||||
await createAppConfig({
|
await createAppConfig({
|
||||||
variables: {
|
variables: {
|
||||||
input: { key: props.appKey, ...values },
|
input: { key: props.appKey, ...values },
|
||||||
@@ -69,6 +69,7 @@ function AdminApplicationSettings(props) {
|
|||||||
}),
|
}),
|
||||||
[appConfig?.data],
|
[appConfig?.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
defaultValues={defaultValues}
|
defaultValues={defaultValues}
|
||||||
|
@@ -165,6 +165,7 @@ function ChooseAppAndEventSubstep(props) {
|
|||||||
value={getOption(appOptions, step.appKey) || null}
|
value={getOption(appOptions, step.appKey) || null}
|
||||||
onChange={onAppChange}
|
onChange={onAppChange}
|
||||||
data-test="choose-app-autocomplete"
|
data-test="choose-app-autocomplete"
|
||||||
|
componentsProps={{ popper: { className: 'nowheel' } }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{step.appKey && (
|
{step.appKey && (
|
||||||
@@ -227,6 +228,7 @@ function ChooseAppAndEventSubstep(props) {
|
|||||||
value={getOption(actionOrTriggerOptions, step.key) || null}
|
value={getOption(actionOrTriggerOptions, step.key) || null}
|
||||||
onChange={onEventChange}
|
onChange={onEventChange}
|
||||||
data-test="choose-event-autocomplete"
|
data-test="choose-event-autocomplete"
|
||||||
|
componentsProps={{ popper: { className: 'nowheel' } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
@@ -240,6 +240,7 @@ function ChooseConnectionSubstep(props) {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
loading={isAppConnectionsLoading}
|
loading={isAppConnectionsLoading}
|
||||||
data-test="choose-connection-autocomplete"
|
data-test="choose-connection-autocomplete"
|
||||||
|
componentsProps={{ popper: { className: 'nowheel' } }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
@@ -32,9 +32,11 @@ function ControlledAutocomplete(props) {
|
|||||||
...autocompleteProps
|
...autocompleteProps
|
||||||
} = props;
|
} = props;
|
||||||
let dependsOnValues = [];
|
let dependsOnValues = [];
|
||||||
|
|
||||||
if (dependsOn?.length) {
|
if (dependsOn?.length) {
|
||||||
dependsOnValues = watch(dependsOn);
|
dependsOnValues = watch(dependsOn);
|
||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const hasDependencies = dependsOnValues.length;
|
const hasDependencies = dependsOnValues.length;
|
||||||
const allDepsSatisfied = dependsOnValues.every(Boolean);
|
const allDepsSatisfied = dependsOnValues.every(Boolean);
|
||||||
@@ -44,6 +46,7 @@ function ControlledAutocomplete(props) {
|
|||||||
resetField(name);
|
resetField(name);
|
||||||
}
|
}
|
||||||
}, dependsOnValues);
|
}, dependsOnValues);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<Controller
|
||||||
rules={{ required }}
|
rules={{ required }}
|
||||||
|
@@ -47,6 +47,7 @@ const CustomOptions = (props) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
className="nowheel"
|
||||||
>
|
>
|
||||||
<Paper elevation={5} sx={{ width: '100%' }}>
|
<Paper elevation={5} sx={{ width: '100%' }}>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
@@ -8,6 +8,7 @@ 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 { ReactFlowProvider } from 'reactflow';
|
||||||
|
|
||||||
import { EditorProvider } from 'contexts/Editor';
|
import { EditorProvider } from 'contexts/Editor';
|
||||||
import EditableTypography from 'components/EditableTypography';
|
import EditableTypography from 'components/EditableTypography';
|
||||||
@@ -20,6 +21,9 @@ import * as URLS from 'config/urls';
|
|||||||
import { TopBar } from './style';
|
import { TopBar } from './style';
|
||||||
import useFlow from 'hooks/useFlow';
|
import useFlow from 'hooks/useFlow';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import EditorNew from 'components/EditorNew/EditorNew';
|
||||||
|
|
||||||
|
const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true';
|
||||||
|
|
||||||
export default function EditorLayout() {
|
export default function EditorLayout() {
|
||||||
const { flowId } = useParams();
|
const { flowId } = useParams();
|
||||||
@@ -131,15 +135,28 @@ export default function EditorLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</TopBar>
|
</TopBar>
|
||||||
|
|
||||||
|
{useNewFlowEditor ? (
|
||||||
|
<Stack direction="column" height="100%" flexGrow={1}>
|
||||||
|
<Stack direction="column" flexGrow={1}>
|
||||||
|
<EditorProvider value={{ readOnly: !!flow?.active }}>
|
||||||
|
<ReactFlowProvider>
|
||||||
|
{!flow && !isFlowLoading && 'not found'}
|
||||||
|
{flow && <EditorNew flow={flow} />}
|
||||||
|
</ReactFlowProvider>
|
||||||
|
</EditorProvider>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
<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 && !isFlowLoading && 'not found'}
|
||||||
|
|
||||||
{flow && <Editor flow={flow} />}
|
{flow && <Editor flow={flow} />}
|
||||||
</EditorProvider>
|
</EditorProvider>
|
||||||
</Container>
|
</Container>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<Snackbar
|
<Snackbar
|
||||||
data-test="flow-cannot-edit-info-snackbar"
|
data-test="flow-cannot-edit-info-snackbar"
|
||||||
|
79
packages/web/src/components/EditorNew/Edge/Edge.jsx
Normal file
79
packages/web/src/components/EditorNew/Edge/Edge.jsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { EdgeLabelRenderer, getStraightPath } from 'reactflow';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { CREATE_STEP } from 'graphql/mutations/create-step';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export default function Edge({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
source,
|
||||||
|
data: { flowId, setCurrentStepId, flowActive, layouted },
|
||||||
|
}) {
|
||||||
|
const [createStep, { loading: creationInProgress }] =
|
||||||
|
useMutation(CREATE_STEP);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [edgePath, labelX, labelY] = getStraightPath({
|
||||||
|
sourceX,
|
||||||
|
sourceY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addStep = async (previousStepId) => {
|
||||||
|
const mutationInput = {
|
||||||
|
previousStep: {
|
||||||
|
id: previousStepId,
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
id: flowId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdStep = await createStep({
|
||||||
|
variables: { input: mutationInput },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdStepId = createdStep.data.createStep.id;
|
||||||
|
setCurrentStepId(createdStepId);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EdgeLabelRenderer>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => addStep(source)}
|
||||||
|
color="primary"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
|
pointerEvents: 'all',
|
||||||
|
visibility: layouted ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
disabled={creationInProgress || flowActive}
|
||||||
|
>
|
||||||
|
<AddIcon />
|
||||||
|
</IconButton>
|
||||||
|
</EdgeLabelRenderer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Edge.propTypes = {
|
||||||
|
sourceX: PropTypes.number.isRequired,
|
||||||
|
sourceY: PropTypes.number.isRequired,
|
||||||
|
targetX: PropTypes.number.isRequired,
|
||||||
|
targetY: PropTypes.number.isRequired,
|
||||||
|
source: PropTypes.string.isRequired,
|
||||||
|
data: PropTypes.shape({
|
||||||
|
flowId: PropTypes.string.isRequired,
|
||||||
|
setCurrentStepId: PropTypes.func.isRequired,
|
||||||
|
flowActive: PropTypes.bool.isRequired,
|
||||||
|
layouted: PropTypes.bool,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
258
packages/web/src/components/EditorNew/EditorNew.jsx
Normal file
258
packages/web/src/components/EditorNew/EditorNew.jsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useMutation } from '@apollo/client';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { FlowPropType } from 'propTypes/propTypes';
|
||||||
|
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow';
|
||||||
|
import 'reactflow/dist/style.css';
|
||||||
|
import { UPDATE_STEP } from 'graphql/mutations/update-step';
|
||||||
|
|
||||||
|
import { useAutoLayout } from './useAutoLayout';
|
||||||
|
import { useScrollBoundries } from './useScrollBoundries';
|
||||||
|
import FlowStepNode from './FlowStepNode/FlowStepNode';
|
||||||
|
import Edge from './Edge/Edge';
|
||||||
|
import InvisibleNode from './InvisibleNode/InvisibleNode';
|
||||||
|
import { EditorWrapper } from './style';
|
||||||
|
|
||||||
|
const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode };
|
||||||
|
|
||||||
|
const edgeTypes = {
|
||||||
|
addNodeEdge: Edge,
|
||||||
|
};
|
||||||
|
|
||||||
|
const INVISIBLE_NODE_ID = 'invisible-node';
|
||||||
|
|
||||||
|
const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`;
|
||||||
|
|
||||||
|
const EditorNew = ({ flow }) => {
|
||||||
|
const [triggerStep] = flow.steps;
|
||||||
|
const [currentStepId, setCurrentStepId] = useState(triggerStep.id);
|
||||||
|
|
||||||
|
const [updateStep] = useMutation(UPDATE_STEP);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||||
|
useAutoLayout();
|
||||||
|
useScrollBoundries();
|
||||||
|
|
||||||
|
const onConnect = useCallback(
|
||||||
|
(params) => setEdges((eds) => addEdge(params, eds)),
|
||||||
|
[setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openNextStep = useCallback(
|
||||||
|
(nextStep) => () => {
|
||||||
|
setCurrentStepId(nextStep?.id);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onStepChange = useCallback(
|
||||||
|
async (step) => {
|
||||||
|
const mutationInput = {
|
||||||
|
id: step.id,
|
||||||
|
key: step.key,
|
||||||
|
parameters: step.parameters,
|
||||||
|
connection: {
|
||||||
|
id: step.connection?.id,
|
||||||
|
},
|
||||||
|
flow: {
|
||||||
|
id: flow.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step.appKey) {
|
||||||
|
mutationInput.appKey = step.appKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateStep({
|
||||||
|
variables: { input: mutationInput },
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ['steps', step.id, 'connection'],
|
||||||
|
});
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
|
||||||
|
},
|
||||||
|
[flow.id, updateStep, queryClient],
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateEdges = useCallback((flow, prevEdges) => {
|
||||||
|
const newEdges =
|
||||||
|
flow.steps
|
||||||
|
.map((step, i) => {
|
||||||
|
const sourceId = step.id;
|
||||||
|
const targetId = flow.steps[i + 1]?.id;
|
||||||
|
const edge = prevEdges?.find(
|
||||||
|
(edge) => edge.id === generateEdgeId(sourceId, targetId),
|
||||||
|
);
|
||||||
|
if (targetId) {
|
||||||
|
return {
|
||||||
|
id: generateEdgeId(sourceId, targetId),
|
||||||
|
source: sourceId,
|
||||||
|
target: targetId,
|
||||||
|
type: 'addNodeEdge',
|
||||||
|
data: {
|
||||||
|
flowId: flow.id,
|
||||||
|
flowActive: flow.active,
|
||||||
|
setCurrentStepId,
|
||||||
|
layouted: !!edge,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((edge) => !!edge) || [];
|
||||||
|
|
||||||
|
const lastStep = flow.steps[flow.steps.length - 1];
|
||||||
|
|
||||||
|
return lastStep
|
||||||
|
? [
|
||||||
|
...newEdges,
|
||||||
|
{
|
||||||
|
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
|
||||||
|
source: lastStep.id,
|
||||||
|
target: INVISIBLE_NODE_ID,
|
||||||
|
type: 'addNodeEdge',
|
||||||
|
data: {
|
||||||
|
flowId: flow.id,
|
||||||
|
flowActive: flow.active,
|
||||||
|
setCurrentStepId,
|
||||||
|
layouted: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: newEdges;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateNodes = useCallback(
|
||||||
|
(flow, prevNodes) => {
|
||||||
|
const newNodes = flow.steps.map((step, index) => {
|
||||||
|
const node = prevNodes?.find(({ id }) => id === step.id);
|
||||||
|
const collapsed = currentStepId !== step.id;
|
||||||
|
return {
|
||||||
|
id: step.id,
|
||||||
|
type: 'flowStep',
|
||||||
|
position: {
|
||||||
|
x: node ? node.position.x : 0,
|
||||||
|
y: node ? node.position.y : 0,
|
||||||
|
},
|
||||||
|
zIndex: collapsed ? 0 : 1,
|
||||||
|
data: {
|
||||||
|
step,
|
||||||
|
index: index,
|
||||||
|
flowId: flow.id,
|
||||||
|
collapsed,
|
||||||
|
openNextStep: openNextStep(flow.steps[index + 1]),
|
||||||
|
onOpen: () => setCurrentStepId(step.id),
|
||||||
|
onClose: () => setCurrentStepId(null),
|
||||||
|
onChange: onStepChange,
|
||||||
|
layouted: !!node,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const prevInvisibleNode = nodes.find((node) => node.type === 'invisible');
|
||||||
|
|
||||||
|
return [
|
||||||
|
...newNodes,
|
||||||
|
{
|
||||||
|
id: INVISIBLE_NODE_ID,
|
||||||
|
type: 'invisible',
|
||||||
|
position: {
|
||||||
|
x: prevInvisibleNode ? prevInvisibleNode.position.x : 0,
|
||||||
|
y: prevInvisibleNode ? prevInvisibleNode.position.y : 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[currentStepId, nodes, onStepChange, openNextStep],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateNodesData = useCallback(
|
||||||
|
(steps) => {
|
||||||
|
setNodes((nodes) =>
|
||||||
|
nodes.map((node) => {
|
||||||
|
const step = steps.find((step) => step.id === node.id);
|
||||||
|
if (step) {
|
||||||
|
return { ...node, data: { ...node.data, step: { ...step } } };
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateEdgesData = useCallback(
|
||||||
|
(flow) => {
|
||||||
|
setEdges((edges) =>
|
||||||
|
edges.map((edge) => {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
data: { ...edge.data, flowId: flow.id, flowActive: flow.active },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[setEdges],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNodes(
|
||||||
|
nodes.map((node) => {
|
||||||
|
if (node.type === 'flowStep') {
|
||||||
|
const collapsed = currentStepId !== node.data.step.id;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
zIndex: collapsed ? 0 : 1,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
collapsed,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [currentStepId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flow.steps.length + 1 !== nodes.length) {
|
||||||
|
const newNodes = generateNodes(flow, nodes);
|
||||||
|
const newEdges = generateEdges(flow, edges);
|
||||||
|
|
||||||
|
setNodes(newNodes);
|
||||||
|
setEdges(newEdges);
|
||||||
|
} else {
|
||||||
|
updateNodesData(flow.steps);
|
||||||
|
updateEdgesData(flow);
|
||||||
|
}
|
||||||
|
}, [flow]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorWrapper direction="column">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
onConnect={onConnect}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
panOnScroll
|
||||||
|
panOnScrollMode="vertical"
|
||||||
|
panOnDrag={false}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
zoomOnPinch={false}
|
||||||
|
zoomOnDoubleClick={false}
|
||||||
|
panActivationKeyCode={null}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
/>
|
||||||
|
</EditorWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorNew.propTypes = {
|
||||||
|
flow: FlowPropType.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorNew;
|
@@ -0,0 +1,72 @@
|
|||||||
|
import { Handle, Position } from 'reactflow';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import FlowStep from 'components/FlowStep';
|
||||||
|
import { StepPropType } from 'propTypes/propTypes';
|
||||||
|
|
||||||
|
import { NodeWrapper, NodeInnerWrapper } from './style.js';
|
||||||
|
|
||||||
|
function FlowStepNode({
|
||||||
|
data: {
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
flowId,
|
||||||
|
collapsed,
|
||||||
|
openNextStep,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onChange,
|
||||||
|
layouted,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NodeWrapper
|
||||||
|
className="nodrag"
|
||||||
|
sx={{
|
||||||
|
visibility: layouted ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NodeInnerWrapper>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={Position.Top}
|
||||||
|
isConnectable={false}
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
<FlowStep
|
||||||
|
step={step}
|
||||||
|
index={index + 1}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onOpen={onOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
onChange={onChange}
|
||||||
|
flowId={flowId}
|
||||||
|
onContinue={openNextStep}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={Position.Bottom}
|
||||||
|
isConnectable={false}
|
||||||
|
style={{ visibility: 'hidden' }}
|
||||||
|
/>
|
||||||
|
</NodeInnerWrapper>
|
||||||
|
</NodeWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlowStepNode.propTypes = {
|
||||||
|
data: PropTypes.shape({
|
||||||
|
step: StepPropType.isRequired,
|
||||||
|
index: PropTypes.number.isRequired,
|
||||||
|
flowId: PropTypes.string.isRequired,
|
||||||
|
collapsed: PropTypes.bool.isRequired,
|
||||||
|
openNextStep: PropTypes.func.isRequired,
|
||||||
|
onOpen: PropTypes.func.isRequired,
|
||||||
|
onClose: PropTypes.func.isRequired,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
layouted: PropTypes.bool.isRequired,
|
||||||
|
}).isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FlowStepNode;
|
14
packages/web/src/components/EditorNew/FlowStepNode/style.js
Normal file
14
packages/web/src/components/EditorNew/FlowStepNode/style.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
export const NodeWrapper = styled(Box)(({ theme }) => ({
|
||||||
|
width: '100vw',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: theme.spacing(0, 2.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const NodeInnerWrapper = styled(Box)(({ theme }) => ({
|
||||||
|
maxWidth: 900,
|
||||||
|
flex: 1,
|
||||||
|
}));
|
@@ -0,0 +1,19 @@
|
|||||||
|
import { Handle, Position } from 'reactflow';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
// This node is used for adding an edge with add node button after the last flow step node
|
||||||
|
function InvisibleNode() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
maxWidth={900}
|
||||||
|
width="100vw"
|
||||||
|
className="nodrag"
|
||||||
|
sx={{ visibility: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Handle type="target" position={Position.Top} isConnectable={false} />
|
||||||
|
Invisible node
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InvisibleNode;
|
13
packages/web/src/components/EditorNew/style.js
Normal file
13
packages/web/src/components/EditorNew/style.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Stack } from '@mui/material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
|
||||||
|
export const EditorWrapper = styled(Stack)(({ theme }) => ({
|
||||||
|
flexGrow: 1,
|
||||||
|
'& > div': {
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
'& .react-flow__pane, & .react-flow__node': {
|
||||||
|
cursor: 'auto !important',
|
||||||
|
},
|
||||||
|
}));
|
69
packages/web/src/components/EditorNew/useAutoLayout.js
Normal file
69
packages/web/src/components/EditorNew/useAutoLayout.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import Dagre from '@dagrejs/dagre';
|
||||||
|
import { usePrevious } from 'hooks/usePrevious';
|
||||||
|
import { isEqual } from 'lodash';
|
||||||
|
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
|
||||||
|
|
||||||
|
const getLayoutedElements = (nodes, edges) => {
|
||||||
|
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||||
|
graph.setGraph({
|
||||||
|
rankdir: 'TB',
|
||||||
|
marginy: 60,
|
||||||
|
ranksep: 64,
|
||||||
|
});
|
||||||
|
edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
|
||||||
|
nodes.forEach((node) => graph.setNode(node.id, node));
|
||||||
|
|
||||||
|
Dagre.layout(graph);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((node) => {
|
||||||
|
const { x, y, width, height } = graph.node(node.id);
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: { x: x - width / 2, y: y - height / 2 },
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
edges,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAutoLayout = () => {
|
||||||
|
const nodes = useNodes();
|
||||||
|
const prevNodes = usePrevious(nodes);
|
||||||
|
const nodesInitialized = useNodesInitialized();
|
||||||
|
const { getEdges, setNodes, setEdges } = useReactFlow();
|
||||||
|
|
||||||
|
const onLayout = useCallback(
|
||||||
|
(nodes, edges) => {
|
||||||
|
const layoutedElements = getLayoutedElements(nodes, edges);
|
||||||
|
|
||||||
|
setNodes([
|
||||||
|
...layoutedElements.nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
data: { ...node.data, layouted: true },
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
setEdges([
|
||||||
|
...layoutedElements.edges.map((edge) => ({
|
||||||
|
...edge,
|
||||||
|
data: { ...edge.data, layouted: true },
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
[setEdges, setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldAutoLayout =
|
||||||
|
nodesInitialized &&
|
||||||
|
!isEqual(
|
||||||
|
nodes.map(({ width, height }) => ({ width, height })),
|
||||||
|
prevNodes.map(({ width, height }) => ({ width, height })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldAutoLayout) {
|
||||||
|
onLayout(nodes, getEdges());
|
||||||
|
}
|
||||||
|
}, [nodes]);
|
||||||
|
};
|
13
packages/web/src/components/EditorNew/useScrollBoundries.js
Normal file
13
packages/web/src/components/EditorNew/useScrollBoundries.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useViewport, useReactFlow } from 'reactflow';
|
||||||
|
|
||||||
|
export const useScrollBoundries = () => {
|
||||||
|
const { setViewport } = useReactFlow();
|
||||||
|
const { x, y, zoom } = useViewport();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (y > 0) {
|
||||||
|
setViewport({ x, y: 0, zoom });
|
||||||
|
}
|
||||||
|
}, [y]);
|
||||||
|
};
|
@@ -28,9 +28,12 @@ function ContextMenu(props) {
|
|||||||
variables: { input: { id: flowId } },
|
variables: { input: { id: flowId } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (appKey) {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['apps', appKey, 'flows'],
|
queryKey: ['apps', appKey, 'flows'],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), {
|
enqueueSnackbar(formatMessage('flow.successfullyDuplicated'), {
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
SnackbarProps: {
|
SnackbarProps: {
|
||||||
@@ -56,9 +59,12 @@ function ContextMenu(props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (appKey) {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['apps', appKey, 'flows'],
|
queryKey: ['apps', appKey, 'flows'],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
enqueueSnackbar(formatMessage('flow.successfullyDeleted'), {
|
enqueueSnackbar(formatMessage('flow.successfullyDeleted'), {
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
});
|
});
|
||||||
@@ -110,7 +116,7 @@ ContextMenu.propTypes = {
|
|||||||
]).isRequired,
|
]).isRequired,
|
||||||
onDeleteFlow: PropTypes.func,
|
onDeleteFlow: PropTypes.func,
|
||||||
onDuplicateFlow: PropTypes.func,
|
onDuplicateFlow: PropTypes.func,
|
||||||
appKey: PropTypes.string.isRequired,
|
appKey: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ContextMenu;
|
export default ContextMenu;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user