Compare commits
161 Commits
custom-dep
...
temp-branc
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6bafdf5257 | ||
![]() |
ade6282013 | ||
![]() |
bdd8da98c4 | ||
![]() |
730fdd32b1 | ||
![]() |
aa7f6694fc | ||
![]() |
8fba6704df | ||
![]() |
37d02eba02 | ||
![]() |
75a87cf070 | ||
![]() |
8acdc5853d | ||
![]() |
1aa1f441b3 | ||
![]() |
0e26032ac3 | ||
![]() |
871f25c6d9 | ||
![]() |
d8199e7ba7 | ||
![]() |
52b938eabe | ||
![]() |
94fddf3d9b | ||
![]() |
02c98c1ece | ||
![]() |
6ba77667e9 | ||
![]() |
e201a5b806 | ||
![]() |
adac68c407 | ||
![]() |
d051275e54 | ||
![]() |
2afc00364a | ||
![]() |
0a1461231b | ||
![]() |
129327f40d | ||
![]() |
58819aad94 | ||
![]() |
949a2543f5 | ||
![]() |
0f9d732667 | ||
![]() |
b7df175950 | ||
![]() |
4f7ce9874f | ||
![]() |
f63cc80383 | ||
![]() |
dd0a1328e8 | ||
![]() |
27610c002c | ||
![]() |
46ec9b5229 | ||
![]() |
778559d537 | ||
![]() |
1e64b8a903 | ||
![]() |
0e000693d6 | ||
![]() |
cf9e09ea7a | ||
![]() |
eac2f729a5 | ||
![]() |
9578cd27dd | ||
![]() |
9304ffffc9 | ||
![]() |
3fd628cb05 | ||
![]() |
fe68b70bd9 | ||
![]() |
ec2863d218 | ||
![]() |
dc56e7f883 | ||
![]() |
2f1f537e00 | ||
![]() |
b7b3a3025b | ||
![]() |
edb9526538 | ||
![]() |
62117ece06 | ||
![]() |
913a2a0ac1 | ||
![]() |
92ec3d07a3 | ||
![]() |
67ee7899fd | ||
![]() |
b0ceb3fe7e | ||
![]() |
ba6b4c6854 | ||
![]() |
61e90aed60 | ||
![]() |
45b7a399f2 | ||
![]() |
673ed25598 | ||
![]() |
14fc460174 | ||
![]() |
211b4537f9 | ||
![]() |
a683e059aa | ||
![]() |
d2d2cea567 | ||
![]() |
e5bda65a35 | ||
![]() |
6aaef9df4b | ||
![]() |
7fba4d72b0 | ||
![]() |
a6dc2ed4ba | ||
![]() |
20a40eb2f6 | ||
![]() |
ec4ac9d075 | ||
![]() |
b2c14f4226 | ||
![]() |
099dfbd0b0 | ||
![]() |
a9f5736c12 | ||
![]() |
2bd4dd3ab0 | ||
![]() |
186850c256 | ||
![]() |
9922033d33 | ||
![]() |
3c3e6e4144 | ||
![]() |
0e4ac3b7f3 | ||
![]() |
c9813e0316 | ||
![]() |
577c0cfab9 | ||
![]() |
0966f9d715 | ||
![]() |
3f5df118a0 | ||
![]() |
e05c1b26f1 | ||
![]() |
725b38c697 | ||
![]() |
402a0fdf3b | ||
![]() |
078364ffa1 | ||
![]() |
f64d5ec4fc | ||
![]() |
0666174501 | ||
![]() |
12194a50e1 | ||
![]() |
82ee592699 | ||
![]() |
1b4fb2ce6e | ||
![]() |
ebea8d12d1 | ||
![]() |
f842dd77df | ||
![]() |
a6ec7a6c99 | ||
![]() |
369c72282c | ||
![]() |
6f30c1a509 | ||
![]() |
abfd1116c7 | ||
![]() |
017854955d | ||
![]() |
1405cddea1 | ||
![]() |
00dd3164c9 | ||
![]() |
d5cbc0f611 | ||
![]() |
5d2e9ccc67 | ||
![]() |
017a881494 | ||
![]() |
52994970e6 | ||
![]() |
ebae629e5c | ||
![]() |
4d79220b0c | ||
![]() |
96fba7fbb8 | ||
![]() |
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:
|
||||
image: quay.io/keycloak/keycloak:21.1
|
||||
restart: always
|
||||
container_name: keycloak
|
||||
environment:
|
||||
- KEYCLOAK_ADMIN=admin
|
||||
- KEYCLOAK_ADMIN_PASSWORD=admin
|
||||
|
3
.github/workflows/playwright.yml
vendored
3
.github/workflows/playwright.yml
vendored
@@ -71,9 +71,6 @@ jobs:
|
||||
- name: Migrate database
|
||||
working-directory: ./packages/backend
|
||||
run: yarn db:migrate
|
||||
- name: Seed user
|
||||
working-directory: ./packages/backend
|
||||
run: yarn db:seed:user &
|
||||
- name: Install certutils
|
||||
run: sudo apt install -y libnss3-tools
|
||||
- name: Install mkcert
|
||||
|
@@ -2,6 +2,7 @@ import appConfig from '../../src/config/app.js';
|
||||
import logger from '../../src/helpers/logger.js';
|
||||
import client from './client.js';
|
||||
import User from '../../src/models/user.js';
|
||||
import Config from '../../src/models/config.js';
|
||||
import Role from '../../src/models/role.js';
|
||||
import '../../src/config/orm.js';
|
||||
import process from 'process';
|
||||
@@ -21,6 +22,14 @@ export async function createUser(
|
||||
email = 'user@automatisch.io',
|
||||
password = 'sample'
|
||||
) {
|
||||
if (appConfig.disableSeedUser) {
|
||||
logger.info('Seed user is disabled.');
|
||||
|
||||
process.exit(0);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION_CODE = '23505';
|
||||
|
||||
const role = await fetchAdminRole();
|
||||
@@ -37,6 +46,8 @@ export async function createUser(
|
||||
if (userCount === 0) {
|
||||
const user = await User.query().insertAndFetch(userParams);
|
||||
logger.info(`User has been saved: ${user.email}`);
|
||||
|
||||
await Config.markInstallationCompleted();
|
||||
} else {
|
||||
logger.info('No need to seed a user.');
|
||||
}
|
||||
|
@@ -67,6 +67,7 @@
|
||||
"pluralize": "^8.0.0",
|
||||
"raw-body": "^2.5.2",
|
||||
"showdown": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.7.1",
|
||||
"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);
|
||||
},
|
||||
});
|
@@ -0,0 +1,64 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
|
||||
export default defineAction({
|
||||
name: 'Create HMAC',
|
||||
key: 'createHmac',
|
||||
description: 'Create a Hash-based Message Authentication Code (HMAC) using the specified algorithm, secret key, and message.',
|
||||
arguments: [
|
||||
{
|
||||
label: 'Algorithm',
|
||||
key: 'algorithm',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'sha256',
|
||||
description: 'Specifies the cryptographic hash function to use for HMAC generation.',
|
||||
options: [
|
||||
{ label: 'SHA-256', value: 'sha256' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
key: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The input message to be hashed. This is the value that will be processed to generate the HMAC.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Secret Key',
|
||||
key: 'secretKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The secret key used to create the HMAC.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Output Encoding',
|
||||
key: 'outputEncoding',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'hex',
|
||||
description: 'Specifies the encoding format for the HMAC digest output.',
|
||||
options: [
|
||||
{ label: 'base64', value: 'base64' },
|
||||
{ label: 'base64url', value: 'base64url' },
|
||||
{ label: 'hex', value: 'hex' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
],
|
||||
|
||||
async run($) {
|
||||
const hash = createHmac($.step.parameters.algorithm, $.step.parameters.secretKey)
|
||||
.update($.step.parameters.message)
|
||||
.digest($.step.parameters.outputEncoding);
|
||||
|
||||
$.setActionItem({
|
||||
raw: {
|
||||
hash
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@@ -0,0 +1,65 @@
|
||||
import crypto from 'node:crypto';
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
|
||||
export default defineAction({
|
||||
name: 'Create Signature',
|
||||
key: 'createSignature',
|
||||
description: 'Create a digital signature using the specified algorithm, secret key, and message.',
|
||||
arguments: [
|
||||
{
|
||||
label: 'Algorithm',
|
||||
key: 'algorithm',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'RSA-SHA256',
|
||||
description: 'Specifies the cryptographic hash function to use for HMAC generation.',
|
||||
options: [
|
||||
{ label: 'RSA-SHA256', value: 'RSA-SHA256' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Message',
|
||||
key: 'message',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The input message to be signed.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Private Key',
|
||||
key: 'privateKey',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The RSA private key in PEM format used for signing.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Output Encoding',
|
||||
key: 'outputEncoding',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
value: 'hex',
|
||||
description: 'Specifies the encoding format for the digital signature output. This determines how the generated signature will be represented as a string.',
|
||||
options: [
|
||||
{ label: 'base64', value: 'base64' },
|
||||
{ label: 'base64url', value: 'base64url' },
|
||||
{ label: 'hex', value: 'hex' },
|
||||
],
|
||||
variables: true,
|
||||
},
|
||||
],
|
||||
|
||||
async run($) {
|
||||
const signer = crypto.createSign($.step.parameters.algorithm);
|
||||
signer.update($.step.parameters.message);
|
||||
signer.end();
|
||||
const signature = signer.sign($.step.parameters.privateKey, $.step.parameters.outputEncoding);
|
||||
|
||||
$.setActionItem({
|
||||
raw: {
|
||||
signature
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
4
packages/backend/src/apps/cryptography/actions/index.js
Normal file
4
packages/backend/src/apps/cryptography/actions/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import createHmac from './create-hmac/index.js';
|
||||
import createRsaSha256Signature from './create-rsa-sha256-signature/index.js';
|
||||
|
||||
export default [createHmac, createRsaSha256Signature];
|
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100pt" height="100pt" version="1.1" viewBox="0 0 100 100">
|
||||
<path d="m66.012 33h-3.0117v-11c0-7.1719-5.8281-13-13-13s-13 5.8281-13 13v11h-3.0117c-2.75 0-4.9883 2.2383-4.9883 4.9883v28.012c0 2.75 2.2383 4.9883 4.9883 4.9883h32.012c2.75 0 4.9883-2.2383 4.9883-4.9883v-28.012c0.011719-2.75-2.2266-4.9883-4.9766-4.9883zm-27.012-11c0-6.0703 4.9297-11 11-11s11 4.9297 11 11v11h-22zm30 44.012c0 1.6484-1.3398 2.9883-2.9883 2.9883h-32.023c-1.6484 0-2.9883-1.3398-2.9883-2.9883v-28.023c0-1.6484 1.3398-2.9883 2.9883-2.9883h32.023c1.6484 0 2.9883 1.3398 2.9883 2.9883zm-18 9.9883v14c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-14c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm20 8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm-32-8v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1zm-14-26c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm0-12c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v7h7c0.55078 0 1 0.44922 1 1zm0 24c0 0.55078-0.44922 1-1 1h-7v7c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-8c0-0.55078 0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm66-12c0 0.55078-0.44922 1-1 1h-14c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h14c0.55078 0 1 0.44922 1 1zm-16-12c0-0.55078 0.44922-1 1-1h7v-7c0-0.55078 0.44922-1 1-1s1 0.44922 1 1v8c0 0.55078-0.44922 1-1 1h-8c-0.55078 0-1-0.44922-1-1zm10 24v8c0 0.55078-0.44922 1-1 1s-1-0.44922-1-1v-7h-7c-0.55078 0-1-0.44922-1-1s0.44922-1 1-1h8c0.55078 0 1 0.44922 1 1zm-35-17c-2.7617 0-5 2.2383-5 5 0 2.4102 1.7188 4.4297 4 4.8984v5.1016c0 0.55078 0.44922 1 1 1s1-0.44922 1-1v-5.1016c2.2812-0.46094 4-2.4805 4-4.8984 0-2.7617-2.2383-5-5-5zm0 8c-1.6484 0-3-1.3516-3-3s1.3516-3 3-3 3 1.3516 3 3-1.3516 3-3 3z"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
14
packages/backend/src/apps/cryptography/index.js
Normal file
14
packages/backend/src/apps/cryptography/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import defineApp from '../../helpers/define-app.js';
|
||||
import actions from './actions/index.js';
|
||||
|
||||
export default defineApp({
|
||||
name: 'Cryptography',
|
||||
key: 'cryptography',
|
||||
iconUrl: '{BASE_URL}/apps/cryptography/assets/favicon.svg',
|
||||
authDocUrl: '{DOCS_URL}/apps/cryptography/connection',
|
||||
supportsConnections: false,
|
||||
baseUrl: '',
|
||||
apiBaseUrl: '',
|
||||
primaryColor: '001F52',
|
||||
actions,
|
||||
});
|
@@ -1,8 +1,10 @@
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
import formatDateTime from './transformers/format-date-time.js';
|
||||
import getCurrentTimestamp from './transformers/get-current-timestamp.js';
|
||||
|
||||
const transformers = {
|
||||
formatDateTime,
|
||||
getCurrentTimestamp,
|
||||
};
|
||||
|
||||
export default defineAction({
|
||||
@@ -16,7 +18,16 @@ export default defineAction({
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
variables: true,
|
||||
options: [{ label: 'Format Date / Time', value: 'formatDateTime' }],
|
||||
options: [
|
||||
{
|
||||
label: 'Get current timestamp',
|
||||
value: 'getCurrentTimestamp',
|
||||
},
|
||||
{
|
||||
label: 'Format Date / Time',
|
||||
value: 'formatDateTime',
|
||||
},
|
||||
],
|
||||
additionalFields: {
|
||||
type: 'query',
|
||||
name: 'getDynamicFields',
|
||||
|
@@ -5,11 +5,24 @@ const formatDateTime = ($) => {
|
||||
|
||||
const fromFormat = $.step.parameters.fromFormat;
|
||||
const fromTimezone = $.step.parameters.fromTimezone;
|
||||
let inputDateTime;
|
||||
|
||||
const inputDateTime = DateTime.fromFormat(input, fromFormat, {
|
||||
if (fromFormat === 'X') {
|
||||
inputDateTime = DateTime.fromSeconds(Number(input), fromFormat, {
|
||||
zone: fromTimezone,
|
||||
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 toTimezone = $.step.parameters.toTimezone;
|
||||
|
@@ -0,0 +1,5 @@
|
||||
const getCurrentTimestamp = () => {
|
||||
return Date.now();
|
||||
};
|
||||
|
||||
export default getCurrentTimestamp;
|
@@ -14,6 +14,8 @@ import stringToBase64 from './transformers/string-to-base64.js';
|
||||
import encodeUri from './transformers/encode-uri.js';
|
||||
import trimWhitespace from './transformers/trim-whitespace.js';
|
||||
import useDefaultValue from './transformers/use-default-value.js';
|
||||
import parseStringifiedJson from './transformers/parse-stringified-json.js';
|
||||
import createUuid from './transformers/create-uuid.js';
|
||||
|
||||
const transformers = {
|
||||
base64ToString,
|
||||
@@ -30,6 +32,8 @@ const transformers = {
|
||||
encodeUri,
|
||||
trimWhitespace,
|
||||
useDefaultValue,
|
||||
parseStringifiedJson,
|
||||
createUuid,
|
||||
};
|
||||
|
||||
export default defineAction({
|
||||
@@ -47,19 +51,21 @@ export default defineAction({
|
||||
options: [
|
||||
{ label: 'Base64 to String', value: 'base64ToString' },
|
||||
{ label: 'Capitalize', value: 'capitalize' },
|
||||
{ label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' },
|
||||
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
|
||||
{ label: 'Create UUID', value: 'createUuid' },
|
||||
{ label: 'Encode URI', value: 'encodeUri' },
|
||||
{
|
||||
label: 'Encode URI Component',
|
||||
value: 'encodeUriComponent',
|
||||
},
|
||||
{ label: 'Convert HTML to Markdown', value: 'htmlToMarkdown' },
|
||||
{ label: 'Convert Markdown to HTML', value: 'markdownToHtml' },
|
||||
{ label: 'Extract Email Address', value: 'extractEmailAddress' },
|
||||
{ label: 'Extract Number', value: 'extractNumber' },
|
||||
{ label: 'Lowercase', value: 'lowercase' },
|
||||
{ label: 'Parse stringified JSON', value: 'parseStringifiedJson' },
|
||||
{ label: 'Pluralize', value: 'pluralize' },
|
||||
{ label: 'Replace', value: 'replace' },
|
||||
{ label: 'String to Base64', value: 'stringToBase64' },
|
||||
{ label: 'Encode URI', value: 'encodeUri' },
|
||||
{ label: 'Trim Whitespace', value: 'trimWhitespace' },
|
||||
{ label: 'Use Default Value', value: 'useDefaultValue' },
|
||||
],
|
||||
|
@@ -0,0 +1,7 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const createUuidV4 = () => {
|
||||
return uuidv4();
|
||||
};
|
||||
|
||||
export default createUuidV4;
|
@@ -0,0 +1,7 @@
|
||||
const parseStringifiedJson = ($) => {
|
||||
const input = $.step.parameters.input;
|
||||
|
||||
return JSON.parse(input);
|
||||
};
|
||||
|
||||
export default parseStringifiedJson;
|
@@ -1,8 +1,26 @@
|
||||
const replace = ($) => {
|
||||
const input = $.step.parameters.input;
|
||||
|
||||
const find = $.step.parameters.find;
|
||||
const replace = $.step.parameters.replace;
|
||||
const useRegex = $.step.parameters.useRegex;
|
||||
|
||||
if (useRegex) {
|
||||
const ignoreCase = $.step.parameters.ignoreCase;
|
||||
|
||||
const flags = [ignoreCase && 'i', 'g'].filter(Boolean).join('');
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
$.execution.exit();
|
||||
}, 100);
|
||||
|
||||
const regex = new RegExp(find, flags);
|
||||
|
||||
const replacedValue = input.replaceAll(regex, replace);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
return replacedValue;
|
||||
}
|
||||
|
||||
return input.replaceAll(find, replace);
|
||||
};
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import listTransformOptions from './list-transform-options/index.js';
|
||||
import listReplaceRegexOptions from './list-replace-regex-options/index.js';
|
||||
|
||||
export default [listTransformOptions];
|
||||
export default [listTransformOptions, listReplaceRegexOptions];
|
||||
|
@@ -0,0 +1,23 @@
|
||||
export default {
|
||||
name: 'List replace regex options',
|
||||
key: 'listReplaceRegexOptions',
|
||||
|
||||
async run($) {
|
||||
if (!$.step.parameters.useRegex) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Ignore case',
|
||||
key: 'ignoreCase',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
description: 'Ignore case sensitivity.',
|
||||
variables: true,
|
||||
options: [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
@@ -12,6 +12,7 @@ import stringToBase64 from './text/string-to-base64.js';
|
||||
import encodeUri from './text/encode-uri.js';
|
||||
import trimWhitespace from './text/trim-whitespace.js';
|
||||
import useDefaultValue from './text/use-default-value.js';
|
||||
import parseStringifiedJson from './text/parse-stringified-json.js';
|
||||
import performMathOperation from './numbers/perform-math-operation.js';
|
||||
import randomNumber from './numbers/random-number.js';
|
||||
import formatNumber from './numbers/format-number.js';
|
||||
@@ -38,6 +39,7 @@ const options = {
|
||||
formatNumber,
|
||||
formatPhoneNumber,
|
||||
formatDateTime,
|
||||
parseStringifiedJson,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
@@ -0,0 +1,12 @@
|
||||
const useDefaultValue = [
|
||||
{
|
||||
label: 'Input',
|
||||
key: 'input',
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Stringified JSON you want to parse.',
|
||||
variables: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default useDefaultValue;
|
@@ -23,6 +23,33 @@ const replace = [
|
||||
description: 'Text that will replace the found text.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Use Regular Expression',
|
||||
key: 'useRegex',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
description: 'Use regex to search values.',
|
||||
variables: true,
|
||||
value: false,
|
||||
options: [
|
||||
{ label: 'Yes', value: true },
|
||||
{ label: 'No', value: false },
|
||||
],
|
||||
additionalFields: {
|
||||
type: 'query',
|
||||
name: 'getDynamicFields',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listReplaceRegexOptions',
|
||||
},
|
||||
{
|
||||
name: 'parameters.useRegex',
|
||||
value: '{parameters.useRegex}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default replace;
|
||||
|
@@ -0,0 +1,101 @@
|
||||
import defineAction from '../../../../helpers/define-action.js';
|
||||
import listObjects from '../../dynamic-data/list-objects/index.js';
|
||||
import listFields from '../../dynamic-data/list-fields/index.js';
|
||||
|
||||
export default defineAction({
|
||||
name: 'Find partially matching record',
|
||||
key: 'findPartiallyMatchingRecord',
|
||||
description: 'Finds a record of a specified object by a field containing a value.',
|
||||
arguments: [
|
||||
{
|
||||
label: 'Object',
|
||||
key: 'object',
|
||||
type: 'dropdown',
|
||||
required: true,
|
||||
variables: true,
|
||||
description: 'Pick which type of object you want to search for.',
|
||||
source: {
|
||||
type: 'query',
|
||||
name: 'getDynamicData',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listObjects',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Field',
|
||||
key: 'field',
|
||||
type: 'dropdown',
|
||||
description: 'Pick which field to search by',
|
||||
required: true,
|
||||
variables: true,
|
||||
dependsOn: ['parameters.object'],
|
||||
source: {
|
||||
type: 'query',
|
||||
name: 'getDynamicData',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listFields',
|
||||
},
|
||||
{
|
||||
name: 'parameters.object',
|
||||
value: '{parameters.object}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Search value to contain',
|
||||
key: 'searchValue',
|
||||
type: 'string',
|
||||
required: true,
|
||||
variables: true,
|
||||
description: 'The value to search for in the field.',
|
||||
},
|
||||
],
|
||||
|
||||
async run($) {
|
||||
const sanitizedSearchValue = $.step.parameters.searchValue.replaceAll(`'`, `\\'`);
|
||||
|
||||
// validate given object
|
||||
const objects = await listObjects.run($);
|
||||
const validObject = objects.data.find((object) => object.value === $.step.parameters.object);
|
||||
|
||||
if (!validObject) {
|
||||
throw new Error(`The "${$.step.parameters.object}" object does not exist.`);
|
||||
}
|
||||
|
||||
// validate given object field
|
||||
const fields = await listFields.run($);
|
||||
const validField = fields.data.find((field) => field.value === $.step.parameters.field);
|
||||
|
||||
if (!validField) {
|
||||
throw new Error(`The "${$.step.parameters.field}" field does not exist on the "${$.step.parameters.object}" object.`);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
FIELDS(ALL)
|
||||
FROM
|
||||
${$.step.parameters.object}
|
||||
WHERE
|
||||
${$.step.parameters.field} LIKE '%${sanitizedSearchValue}%'
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const options = {
|
||||
params: {
|
||||
q: query,
|
||||
},
|
||||
};
|
||||
|
||||
const { data } = await $.http.get('/services/data/v61.0/query', options);
|
||||
const record = data.records[0];
|
||||
|
||||
$.setActionItem({ raw: record });
|
||||
},
|
||||
});
|
@@ -1,5 +1,6 @@
|
||||
import createAttachment from './create-attachment/index.js';
|
||||
import executeQuery from './execute-query/index.js';
|
||||
import findRecord from './find-record/index.js';
|
||||
import findPartiallyMatchingRecord from './find-partially-matching-record/index.js';
|
||||
|
||||
export default [findRecord, createAttachment, executeQuery];
|
||||
export default [findRecord, findPartiallyMatchingRecord, createAttachment, executeQuery];
|
||||
|
@@ -52,7 +52,7 @@ const appConfig = {
|
||||
isDev: appEnv === 'development',
|
||||
isTest: appEnv === 'test',
|
||||
isProd: appEnv === 'production',
|
||||
version: '0.11.0',
|
||||
version: '0.12.0',
|
||||
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
|
||||
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
|
||||
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
@@ -97,7 +97,12 @@ const appConfig = {
|
||||
disableNotificationsPage: process.env.DISABLE_NOTIFICATIONS_PAGE === 'true',
|
||||
disableFavicon: process.env.DISABLE_FAVICON === 'true',
|
||||
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
|
||||
additionalDrawerLinkIcon: process.env.ADDITIONAL_DRAWER_LINK_ICON,
|
||||
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
|
||||
disableSeedUser: process.env.DISABLE_SEED_USER === 'true',
|
||||
httpProxy: process.env.http_proxy,
|
||||
httpsProxy: process.env.https_proxy,
|
||||
noProxy: process.env.no_proxy,
|
||||
};
|
||||
|
||||
if (!appConfig.encryptionKey) {
|
||||
|
@@ -0,0 +1,10 @@
|
||||
import User from '../../../../../models/user.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const id = request.params.userId;
|
||||
|
||||
const user = await User.query().findById(id).throwIfNotFound();
|
||||
await user.softRemove();
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,43 @@
|
||||
import { describe, it, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import Crypto from 'crypto';
|
||||
import app from '../../../../../app.js';
|
||||
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
|
||||
import { createUser } from '../../../../../../test/factories/user';
|
||||
import { createRole } from '../../../../../../test/factories/role';
|
||||
|
||||
describe('DELETE /api/v1/admin/users/:userId', () => {
|
||||
let currentUser, currentUserRole, anotherUser, token;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUserRole = await createRole({ key: 'admin' });
|
||||
currentUser = await createUser({ roleId: currentUserRole.id });
|
||||
|
||||
anotherUser = await createUser();
|
||||
|
||||
token = await createAuthTokenByUserId(currentUser.id);
|
||||
});
|
||||
|
||||
it('should soft delete user and respond with no content', async () => {
|
||||
await request(app)
|
||||
.delete(`/api/v1/admin/users/${anotherUser.id}`)
|
||||
.set('Authorization', token)
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should return not found response for not existing user UUID', async () => {
|
||||
const notExistingUserUUID = Crypto.randomUUID();
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/v1/admin/users/${notExistingUserUUID}`)
|
||||
.set('Authorization', token)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return bad request response for invalid UUID', async () => {
|
||||
await request(app)
|
||||
.delete('/api/v1/admin/users/invalidUserUUID')
|
||||
.set('Authorization', token)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
@@ -1,11 +1,10 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import app from '../../../../../app';
|
||||
import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id';
|
||||
import { createRole } from '../../../../../../test/factories/role';
|
||||
import { createUser } from '../../../../../../test/factories/user';
|
||||
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', () => {
|
||||
let currentUser, currentUserRole, anotherUser, anotherUserRole, token;
|
||||
@@ -32,8 +31,6 @@ describe('GET /api/v1/admin/users', () => {
|
||||
});
|
||||
|
||||
it('should return users data', async () => {
|
||||
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/v1/admin/users')
|
||||
.set('Authorization', token)
|
||||
|
@@ -7,6 +7,7 @@ export default async (request, response) => {
|
||||
disableNotificationsPage: appConfig.disableNotificationsPage,
|
||||
disableFavicon: appConfig.disableFavicon,
|
||||
additionalDrawerLink: appConfig.additionalDrawerLink,
|
||||
additionalDrawerLinkIcon: appConfig.additionalDrawerLinkIcon,
|
||||
additionalDrawerLinkText: appConfig.additionalDrawerLinkText,
|
||||
};
|
||||
|
||||
|
@@ -4,6 +4,7 @@ import { createConfig } from '../../../../../test/factories/config.js';
|
||||
import app from '../../../../app.js';
|
||||
import configMock from '../../../../../test/mocks/rest/api/v1/automatisch/config.js';
|
||||
import * as license from '../../../../helpers/license.ee.js';
|
||||
import appConfig from '../../../../config/app.js';
|
||||
|
||||
describe('GET /api/v1/automatisch/config', () => {
|
||||
it('should return Automatisch config', async () => {
|
||||
@@ -48,4 +49,18 @@ describe('GET /api/v1/automatisch/config', () => {
|
||||
|
||||
expect(response.body).toEqual(expectedPayload);
|
||||
});
|
||||
|
||||
it('should return additional environment variables', async () => {
|
||||
vi.spyOn(appConfig, 'disableNotificationsPage', 'get').mockReturnValue(true);
|
||||
vi.spyOn(appConfig, 'disableFavicon', 'get').mockReturnValue(true);
|
||||
vi.spyOn(appConfig, 'additionalDrawerLink', 'get').mockReturnValue('link');
|
||||
vi.spyOn(appConfig, 'additionalDrawerLinkIcon', 'get').mockReturnValue('icon');
|
||||
vi.spyOn(appConfig, 'additionalDrawerLinkText', 'get').mockReturnValue('text');
|
||||
|
||||
expect(appConfig.disableNotificationsPage).toEqual(true);
|
||||
expect(appConfig.disableFavicon).toEqual(true);
|
||||
expect(appConfig.additionalDrawerLink).toEqual('link');
|
||||
expect(appConfig.additionalDrawerLinkIcon).toEqual('icon');
|
||||
expect(appConfig.additionalDrawerLinkText).toEqual('text');
|
||||
});
|
||||
});
|
||||
|
@@ -7,6 +7,7 @@ export default async (request, response) => {
|
||||
isCloud: appConfig.isCloud,
|
||||
isMation: appConfig.isMation,
|
||||
isEnterprise: await hasValidLicense(),
|
||||
docsUrl: appConfig.docsUrl,
|
||||
};
|
||||
|
||||
renderObject(response, info);
|
||||
|
@@ -10,6 +10,7 @@ describe('GET /api/v1/automatisch/info', () => {
|
||||
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
|
||||
vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(false);
|
||||
vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true);
|
||||
vi.spyOn(appConfig, 'docsUrl', 'get').mockReturnValue('https://automatisch.io/docs');
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/v1/automatisch/info')
|
||||
|
@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
|
||||
|
||||
const expectedPayload = {
|
||||
data: {
|
||||
version: '0.11.0',
|
||||
version: '0.12.0',
|
||||
},
|
||||
meta: {
|
||||
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,21 @@
|
||||
import User from '../../../../models/user.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { token, password } = request.body;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Invitation token is required!');
|
||||
}
|
||||
|
||||
const user = await User.query()
|
||||
.findOne({ invitation_token: token })
|
||||
.throwIfNotFound();
|
||||
|
||||
if (!user.isInvitationTokenValid()) {
|
||||
return response.status(422).end();
|
||||
}
|
||||
|
||||
await user.acceptInvitation(password);
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,13 @@
|
||||
import User from '../../../../models/user.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const user = await User.query()
|
||||
.findOne({ email: email.toLowerCase() })
|
||||
.throwIfNotFound();
|
||||
|
||||
await user.sendResetPasswordEmail();
|
||||
|
||||
response.status(204).end();
|
||||
};
|
@@ -0,0 +1,30 @@
|
||||
import { describe, it, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import app from '../../../../app.js';
|
||||
import { createUser } from '../../../../../test/factories/user';
|
||||
|
||||
describe('POST /api/v1/users/forgot-password', () => {
|
||||
let currentUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = await createUser();
|
||||
});
|
||||
|
||||
it('should respond with no content', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/forgot-password')
|
||||
.send({
|
||||
email: currentUser.email,
|
||||
})
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should return not found response for not existing user UUID', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/forgot-password')
|
||||
.send({
|
||||
email: 'nonexisting@automatisch.io',
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
});
|
@@ -0,0 +1,23 @@
|
||||
import User from '../../../../models/user.js';
|
||||
import { renderError } from '../../../../helpers/renderer.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
const { token, password } = request.body;
|
||||
|
||||
const user = await User.query()
|
||||
.findOne({
|
||||
reset_password_token: token,
|
||||
})
|
||||
.throwIfNotFound();
|
||||
|
||||
if (!user.isResetPasswordTokenValid()) {
|
||||
return renderError(response, [{ general: [invalidTokenErrorMessage] }]);
|
||||
}
|
||||
|
||||
await user.resetPassword(password);
|
||||
|
||||
response.status(204).end();
|
||||
};
|
||||
|
||||
const invalidTokenErrorMessage =
|
||||
'Reset password link is not valid or expired. Try generating a new link.';
|
@@ -0,0 +1,49 @@
|
||||
import { describe, it, beforeEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { DateTime } from 'luxon';
|
||||
import app from '../../../../app.js';
|
||||
import { createUser } from '../../../../../test/factories/user';
|
||||
|
||||
describe('POST /api/v1/users/reset-password', () => {
|
||||
let currentUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
currentUser = await createUser({
|
||||
resetPasswordToken: 'sampleResetPasswordToken',
|
||||
resetPasswordTokenSentAt: DateTime.now().toISO(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with no content', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/reset-password')
|
||||
.send({
|
||||
token: currentUser.resetPasswordToken,
|
||||
password: 'newPassword',
|
||||
})
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('should return not found response for not existing user', async () => {
|
||||
await request(app)
|
||||
.post('/api/v1/users/reset-password')
|
||||
.send({
|
||||
token: 'nonExistingResetPasswordToken',
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return unprocessable entity for existing user with expired reset password token', async () => {
|
||||
const user = await createUser({
|
||||
resetPasswordToken: 'anotherResetPasswordToken',
|
||||
resetPasswordTokenSentAt: DateTime.now().minus({ days: 2 }).toISO(),
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.post('/api/v1/users/reset-password')
|
||||
.send({
|
||||
token: user.resetPasswordToken,
|
||||
})
|
||||
.expect(422);
|
||||
});
|
||||
});
|
@@ -5,9 +5,11 @@ export async function up(knex) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.alterTable('steps', (table) => {
|
||||
table.string('key').notNullable().alter();
|
||||
table.string('app_key').notNullable().alter();
|
||||
});
|
||||
export async function down() {
|
||||
// We can't use down migration here since there are null values which needs to be set!
|
||||
// We don't want to set those values by default key and app key since it will mislead users.
|
||||
// return knex.schema.alterTable('steps', (table) => {
|
||||
// table.string('key').notNullable().alter();
|
||||
// table.string('app_key').notNullable().alter();
|
||||
// });
|
||||
}
|
||||
|
@@ -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();
|
||||
});
|
||||
}
|
@@ -0,0 +1,11 @@
|
||||
export async function up(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.string('status').defaultTo('active');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.dropColumn('status');
|
||||
});
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
export async function up(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.string('invitation_token');
|
||||
table.timestamp('invitation_token_sent_at');
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.dropColumn('invitation_token');
|
||||
table.dropColumn('invitation_token_sent_at');
|
||||
});
|
||||
}
|
@@ -10,15 +10,11 @@ import deleteCurrentUser from './mutations/delete-current-user.ee.js';
|
||||
import deleteFlow from './mutations/delete-flow.js';
|
||||
import deleteRole from './mutations/delete-role.ee.js';
|
||||
import deleteStep from './mutations/delete-step.js';
|
||||
import deleteUser from './mutations/delete-user.ee.js';
|
||||
import duplicateFlow from './mutations/duplicate-flow.js';
|
||||
import executeFlow from './mutations/execute-flow.js';
|
||||
import forgotPassword from './mutations/forgot-password.ee.js';
|
||||
import generateAuthUrl from './mutations/generate-auth-url.js';
|
||||
import login from './mutations/login.js';
|
||||
import registerUser from './mutations/register-user.ee.js';
|
||||
import resetConnection from './mutations/reset-connection.js';
|
||||
import resetPassword from './mutations/reset-password.ee.js';
|
||||
import updateAppAuthClient from './mutations/update-app-auth-client.ee.js';
|
||||
import updateAppConfig from './mutations/update-app-config.ee.js';
|
||||
import updateConfig from './mutations/update-config.ee.js';
|
||||
@@ -46,15 +42,11 @@ const mutationResolvers = {
|
||||
deleteFlow,
|
||||
deleteRole,
|
||||
deleteStep,
|
||||
deleteUser,
|
||||
duplicateFlow,
|
||||
executeFlow,
|
||||
forgotPassword,
|
||||
generateAuthUrl,
|
||||
login,
|
||||
registerUser,
|
||||
resetConnection,
|
||||
resetPassword,
|
||||
updateAppAuthClient,
|
||||
updateAppConfig,
|
||||
updateConfig,
|
||||
|
@@ -1,10 +1,16 @@
|
||||
import appConfig from '../../config/app.js';
|
||||
import User from '../../models/user.js';
|
||||
import Role from '../../models/role.js';
|
||||
import emailQueue from '../../queues/email.js';
|
||||
import {
|
||||
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
} from '../../helpers/remove-job-configuration.js';
|
||||
|
||||
const createUser = async (_parent, params, context) => {
|
||||
context.currentUser.can('create', 'User');
|
||||
|
||||
const { fullName, email, password } = params.input;
|
||||
const { fullName, email } = params.input;
|
||||
|
||||
const existingUser = await User.query().findOne({
|
||||
email: email.toLowerCase(),
|
||||
@@ -17,7 +23,7 @@ const createUser = async (_parent, params, context) => {
|
||||
const userPayload = {
|
||||
fullName,
|
||||
email,
|
||||
password,
|
||||
status: 'invited',
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -32,7 +38,29 @@ const createUser = async (_parent, params, context) => {
|
||||
|
||||
const user = await User.query().insert(userPayload);
|
||||
|
||||
return user;
|
||||
await user.generateInvitationToken();
|
||||
|
||||
const jobName = `Invitation Email - ${user.id}`;
|
||||
const acceptInvitationUrl = `${appConfig.webAppUrl}/accept-invitation?token=${user.invitationToken}`;
|
||||
|
||||
const jobPayload = {
|
||||
email: user.email,
|
||||
subject: 'You are invited!',
|
||||
template: 'invitation-instructions',
|
||||
params: {
|
||||
fullName: user.fullName,
|
||||
acceptInvitationUrl,
|
||||
},
|
||||
};
|
||||
|
||||
const jobOptions = {
|
||||
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
};
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return { user, acceptInvitationUrl };
|
||||
};
|
||||
|
||||
export default createUser;
|
||||
|
@@ -1,24 +0,0 @@
|
||||
import { Duration } from 'luxon';
|
||||
import User from '../../models/user.js';
|
||||
import deleteUserQueue from '../../queues/delete-user.ee.js';
|
||||
|
||||
const deleteUser = async (_parent, params, context) => {
|
||||
context.currentUser.can('delete', 'User');
|
||||
|
||||
const id = params.input.id;
|
||||
|
||||
await User.query().deleteById(id);
|
||||
|
||||
const jobName = `Delete user - ${id}`;
|
||||
const jobPayload = { id };
|
||||
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
||||
const jobOptions = {
|
||||
delay: millisecondsFor30Days,
|
||||
};
|
||||
|
||||
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default deleteUser;
|
@@ -1,43 +0,0 @@
|
||||
import appConfig from '../../config/app.js';
|
||||
import User from '../../models/user.js';
|
||||
import emailQueue from '../../queues/email.js';
|
||||
import {
|
||||
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
} from '../../helpers/remove-job-configuration.js';
|
||||
|
||||
const forgotPassword = async (_parent, params) => {
|
||||
const { email } = params.input;
|
||||
|
||||
const user = await User.query().findOne({ email: email.toLowerCase() });
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Email address not found!');
|
||||
}
|
||||
|
||||
await user.generateResetPasswordToken();
|
||||
|
||||
const jobName = `Reset Password Email - ${user.id}`;
|
||||
|
||||
const jobPayload = {
|
||||
email: user.email,
|
||||
subject: 'Reset Password',
|
||||
template: 'reset-password-instructions',
|
||||
params: {
|
||||
token: user.resetPasswordToken,
|
||||
webAppUrl: appConfig.webAppUrl,
|
||||
fullName: user.fullName,
|
||||
},
|
||||
};
|
||||
|
||||
const jobOptions = {
|
||||
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
};
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default forgotPassword;
|
@@ -1,17 +0,0 @@
|
||||
import User from '../../models/user.js';
|
||||
import createAuthTokenByUserId from '../../helpers/create-auth-token-by-user-id.js';
|
||||
|
||||
const login = async (_parent, params) => {
|
||||
const user = await User.query().findOne({
|
||||
email: params.input.email.toLowerCase(),
|
||||
});
|
||||
|
||||
if (user && (await user.login(params.input.password))) {
|
||||
const token = await createAuthTokenByUserId(user.id);
|
||||
return { token, user };
|
||||
}
|
||||
|
||||
throw new Error('User could not be found.');
|
||||
};
|
||||
|
||||
export default login;
|
@@ -1,23 +0,0 @@
|
||||
import User from '../../models/user.js';
|
||||
|
||||
const resetPassword = async (_parent, params) => {
|
||||
const { token, password } = params.input;
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Reset password token is required!');
|
||||
}
|
||||
|
||||
const user = await User.query().findOne({ reset_password_token: token });
|
||||
|
||||
if (!user || !user.isResetPasswordTokenValid()) {
|
||||
throw new Error(
|
||||
'Reset password link is not valid or expired. Try generating a new link.'
|
||||
);
|
||||
}
|
||||
|
||||
await user.resetPassword(password);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default resetPassword;
|
@@ -8,21 +8,17 @@ type Mutation {
|
||||
createFlow(input: CreateFlowInput): Flow
|
||||
createRole(input: CreateRoleInput): Role
|
||||
createStep(input: CreateStepInput): Step
|
||||
createUser(input: CreateUserInput): User
|
||||
createUser(input: CreateUserInput): UserWithAcceptInvitationUrl
|
||||
deleteConnection(input: DeleteConnectionInput): Boolean
|
||||
deleteCurrentUser: Boolean
|
||||
deleteFlow(input: DeleteFlowInput): Boolean
|
||||
deleteRole(input: DeleteRoleInput): Boolean
|
||||
deleteStep(input: DeleteStepInput): Step
|
||||
deleteUser(input: DeleteUserInput): Boolean
|
||||
duplicateFlow(input: DuplicateFlowInput): Flow
|
||||
executeFlow(input: ExecuteFlowInput): executeFlowType
|
||||
forgotPassword(input: ForgotPasswordInput): Boolean
|
||||
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
|
||||
login(input: LoginInput): Auth
|
||||
registerUser(input: RegisterUserInput): User
|
||||
resetConnection(input: ResetConnectionInput): Connection
|
||||
resetPassword(input: ResetPasswordInput): Boolean
|
||||
updateAppAuthClient(input: UpdateAppAuthClientInput): AppAuthClient
|
||||
updateAppConfig(input: UpdateAppConfigInput): AppConfig
|
||||
updateConfig(input: JSONObject): JSONObject
|
||||
@@ -154,11 +150,6 @@ enum ArgumentEnumType {
|
||||
string
|
||||
}
|
||||
|
||||
type Auth {
|
||||
user: User
|
||||
token: String
|
||||
}
|
||||
|
||||
type AuthenticationStep {
|
||||
type: String
|
||||
name: String
|
||||
@@ -375,7 +366,6 @@ input DeleteStepInput {
|
||||
input CreateUserInput {
|
||||
fullName: String!
|
||||
email: String!
|
||||
password: String!
|
||||
role: UserRoleInput!
|
||||
}
|
||||
|
||||
@@ -390,10 +380,6 @@ input UpdateUserInput {
|
||||
role: UserRoleInput
|
||||
}
|
||||
|
||||
input DeleteUserInput {
|
||||
id: String!
|
||||
}
|
||||
|
||||
input RegisterUserInput {
|
||||
fullName: String!
|
||||
email: String!
|
||||
@@ -406,20 +392,6 @@ input UpdateCurrentUserInput {
|
||||
fullName: String
|
||||
}
|
||||
|
||||
input ForgotPasswordInput {
|
||||
email: String!
|
||||
}
|
||||
|
||||
input ResetPasswordInput {
|
||||
token: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
input LoginInput {
|
||||
email: String!
|
||||
password: String!
|
||||
}
|
||||
|
||||
input PermissionInput {
|
||||
action: String!
|
||||
subject: String!
|
||||
@@ -520,6 +492,11 @@ type User {
|
||||
updatedAt: String
|
||||
}
|
||||
|
||||
type UserWithAcceptInvitationUrl {
|
||||
user: User
|
||||
acceptInvitationUrl: String
|
||||
}
|
||||
|
||||
type Role {
|
||||
id: String
|
||||
name: String
|
||||
|
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();
|
||||
};
|
@@ -53,10 +53,7 @@ const isAuthenticatedRule = rule()(isAuthenticated);
|
||||
export const authenticationRules = {
|
||||
Mutation: {
|
||||
'*': isAuthenticatedRule,
|
||||
forgotPassword: allow,
|
||||
login: allow,
|
||||
registerUser: allow,
|
||||
resetPassword: allow,
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import axios from 'axios';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import appConfig from '../config/app.js';
|
||||
|
||||
const config = axios.defaults;
|
||||
const httpProxyUrl = process.env.http_proxy;
|
||||
const httpsProxyUrl = process.env.https_proxy;
|
||||
const httpProxyUrl = appConfig.httpProxy;
|
||||
const httpsProxyUrl = appConfig.httpsProxy;
|
||||
const supportsProxy = httpProxyUrl || httpsProxyUrl;
|
||||
const noProxyEnv = process.env.no_proxy;
|
||||
const noProxyEnv = appConfig.noProxy;
|
||||
const noProxyHosts = noProxyEnv ? noProxyEnv.split(',').map(host => host.trim()) : [];
|
||||
|
||||
if (supportsProxy) {
|
||||
@@ -29,8 +30,12 @@ function shouldSkipProxy(hostname) {
|
||||
});
|
||||
};
|
||||
|
||||
axiosWithProxyInstance.interceptors.request.use(function skipProxyIfInNoProxy(requestConfig) {
|
||||
const hostname = new URL(requestConfig.url).hostname;
|
||||
/**
|
||||
* The interceptors are executed in the reverse order they are added.
|
||||
*/
|
||||
axiosWithProxyInstance.interceptors.request.use(
|
||||
function skipProxyIfInNoProxy(requestConfig) {
|
||||
const hostname = new URL(requestConfig.baseURL).hostname;
|
||||
|
||||
if (supportsProxy && shouldSkipProxy(hostname)) {
|
||||
requestConfig.httpAgent = undefined;
|
||||
@@ -38,6 +43,30 @@ axiosWithProxyInstance.interceptors.request.use(function skipProxyIfInNoProxy(re
|
||||
}
|
||||
|
||||
return requestConfig;
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
{ synchronous: true }
|
||||
);
|
||||
|
||||
axiosWithProxyInstance.interceptors.request.use(
|
||||
function removeBaseUrlForAbsoluteUrls(requestConfig) {
|
||||
/**
|
||||
* If the URL is an absolute URL, we remove its origin out of the URL
|
||||
* and set it as baseURL. This lets us streamlines the requests made by Automatisch
|
||||
* and requests made by app integrations.
|
||||
*/
|
||||
try {
|
||||
const url = new URL(requestConfig.url);
|
||||
requestConfig.baseURL = url.origin;
|
||||
requestConfig.url = url.pathname + url.search;
|
||||
|
||||
return requestConfig;
|
||||
} catch {
|
||||
return requestConfig;
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
{ synchronous: true}
|
||||
);
|
||||
|
||||
export default axiosWithProxyInstance;
|
||||
|
119
packages/backend/src/helpers/axios-with-proxy.test.js
Normal file
119
packages/backend/src/helpers/axios-with-proxy.test.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
|
||||
describe('Axios with proxy', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should have two interceptors by default', async () => {
|
||||
const axios = (await import('./axios-with-proxy.js')).default;
|
||||
const requestInterceptors = axios.interceptors.request.handlers;
|
||||
|
||||
expect(requestInterceptors.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should have default interceptors in a certain order', async () => {
|
||||
const axios = (await import('./axios-with-proxy.js')).default;
|
||||
|
||||
const requestInterceptors = axios.interceptors.request.handlers;
|
||||
const firstRequestInterceptor = requestInterceptors[0];
|
||||
const secondRequestInterceptor = requestInterceptors[1];
|
||||
|
||||
expect(firstRequestInterceptor.fulfilled.name).toBe('skipProxyIfInNoProxy');
|
||||
expect(secondRequestInterceptor.fulfilled.name).toBe('removeBaseUrlForAbsoluteUrls');
|
||||
});
|
||||
|
||||
describe('skipProxyIfInNoProxy', () => {
|
||||
let appConfig, axios;
|
||||
beforeEach(async() => {
|
||||
appConfig = (await import('../config/app.js')).default;
|
||||
|
||||
vi.spyOn(appConfig, 'httpProxy', 'get').mockReturnValue('http://proxy.automatisch.io');
|
||||
vi.spyOn(appConfig, 'httpsProxy', 'get').mockReturnValue('http://proxy.automatisch.io');
|
||||
vi.spyOn(appConfig, 'noProxy', 'get').mockReturnValue('name.tld,automatisch.io');
|
||||
|
||||
axios = (await import('./axios-with-proxy.js')).default;
|
||||
});
|
||||
|
||||
it('should skip proxy for hosts in no_proxy environment variable', async () => {
|
||||
const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
baseURL: 'https://automatisch.io'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.httpAgent).toBeUndefined();
|
||||
expect(interceptedRequestConfig.httpsAgent).toBeUndefined();
|
||||
expect(interceptedRequestConfig.proxy).toBe(false);
|
||||
});
|
||||
|
||||
it('should not skip proxy for hosts not in no_proxy environment variable', async () => {
|
||||
const skipProxyIfInNoProxy = axios.interceptors.request.handlers[0].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
// beware the intentional typo!
|
||||
baseURL: 'https://automatish.io'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = skipProxyIfInNoProxy(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.httpAgent).toBeDefined();
|
||||
expect(interceptedRequestConfig.httpsAgent).toBeDefined();
|
||||
expect(interceptedRequestConfig.proxy).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBaseUrlForAbsoluteUrls', () => {
|
||||
let axios;
|
||||
beforeEach(async() => {
|
||||
axios = (await import('./axios-with-proxy.js')).default;
|
||||
});
|
||||
|
||||
it('should trim the baseUrl from absolute urls', async () => {
|
||||
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
url: 'https://automatisch.io/path'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
|
||||
expect(interceptedRequestConfig.url).toBe('/path');
|
||||
});
|
||||
|
||||
it('should not mutate separate baseURL and urls', async () => {
|
||||
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
baseURL: 'https://automatisch.io',
|
||||
url: '/path?query=1'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
|
||||
expect(interceptedRequestConfig.url).toBe('/path?query=1');
|
||||
});
|
||||
|
||||
it('should not strip querystring from url', async () => {
|
||||
const removeBaseUrlForAbsoluteUrls = axios.interceptors.request.handlers[1].fulfilled;
|
||||
|
||||
const mockRequestConfig = {
|
||||
...axios.defaults,
|
||||
url: 'https://automatisch.io/path?query=1'
|
||||
};
|
||||
|
||||
const interceptedRequestConfig = removeBaseUrlForAbsoluteUrls(mockRequestConfig);
|
||||
|
||||
expect(interceptedRequestConfig.baseURL).toBe('https://automatisch.io');
|
||||
expect(interceptedRequestConfig.url).toBe('/path?query=1');
|
||||
});
|
||||
});
|
||||
});
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from 'url';
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const compileEmail = (emailPath, replacements = {}) => {
|
||||
const filePath = path.join(__dirname, `../views/emails/${emailPath}.ee.hbs`);
|
||||
const filePath = path.join(__dirname, `../views/emails/${emailPath}.hbs`);
|
||||
const source = fs.readFileSync(filePath, 'utf-8').toString();
|
||||
const template = handlebars.compile(source);
|
||||
return template(replacements);
|
||||
|
@@ -4,12 +4,13 @@ import AccessToken from '../models/access-token.js';
|
||||
|
||||
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 token = await crypto.randomBytes(48).toString('hex');
|
||||
|
||||
await AccessToken.query().insert({
|
||||
token,
|
||||
samlSessionId,
|
||||
userId: user.id,
|
||||
expiresIn: TOKEN_EXPIRES_IN,
|
||||
});
|
||||
|
@@ -98,9 +98,9 @@ const globalVariable = async (options) => {
|
||||
});
|
||||
|
||||
return {
|
||||
key: datastore.key,
|
||||
value: datastore.value,
|
||||
[datastore.key]: datastore.value,
|
||||
key: key,
|
||||
value: datastore?.value ?? null,
|
||||
[key]: datastore?.value ?? null,
|
||||
};
|
||||
},
|
||||
set: async ({ key, value }) => {
|
||||
|
@@ -1,41 +1,38 @@
|
||||
import { URL } from 'node:url';
|
||||
import HttpError from '../../errors/http.js';
|
||||
import axios from '../axios-with-proxy.js';
|
||||
|
||||
const removeBaseUrlForAbsoluteUrls = (requestConfig) => {
|
||||
try {
|
||||
const url = new URL(requestConfig.url);
|
||||
requestConfig.baseURL = url.origin;
|
||||
requestConfig.url = url.pathname + url.search;
|
||||
|
||||
return requestConfig;
|
||||
} catch {
|
||||
return requestConfig;
|
||||
// Mutates the `toInstance` by copying the request interceptors from `fromInstance`
|
||||
const copyRequestInterceptors = (fromInstance, toInstance) => {
|
||||
// Copy request interceptors
|
||||
fromInstance.interceptors.request.forEach(interceptor => {
|
||||
toInstance.interceptors.request.use(
|
||||
interceptor.fulfilled,
|
||||
interceptor.rejected,
|
||||
{
|
||||
synchronous: interceptor.synchronous,
|
||||
runWhen: interceptor.runWhen
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default function createHttpClient({ $, baseURL, beforeRequest = [] }) {
|
||||
const instance = axios.create({
|
||||
baseURL,
|
||||
});
|
||||
|
||||
// 1. apply the beforeRequest functions from the app
|
||||
instance.interceptors.request.use((requestConfig) => {
|
||||
const newRequestConfig = removeBaseUrlForAbsoluteUrls(requestConfig);
|
||||
|
||||
const result = beforeRequest.reduce((newConfig, beforeRequestFunc) => {
|
||||
return beforeRequestFunc($, newConfig);
|
||||
}, newRequestConfig);
|
||||
}, requestConfig);
|
||||
|
||||
/**
|
||||
* axios seems to want InternalAxiosRequestConfig returned not AxioRequestConfig
|
||||
* anymore even though requests do require AxiosRequestConfig.
|
||||
*
|
||||
* Since both interfaces are very similar (InternalAxiosRequestConfig
|
||||
* extends AxiosRequestConfig), we can utilize an assertion below
|
||||
**/
|
||||
return result;
|
||||
});
|
||||
|
||||
// 2. inherit the request inceptors from the parent instance
|
||||
copyRequestInterceptors(axios, instance);
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
|
@@ -5,8 +5,11 @@ import passport from 'passport';
|
||||
import appConfig from '../config/app.js';
|
||||
import createAuthTokenByUserId from './create-auth-token-by-user-id.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';
|
||||
|
||||
const asyncNoop = async () => { };
|
||||
|
||||
export default function configurePassport(app) {
|
||||
app.use(
|
||||
passport.initialize({
|
||||
@@ -19,6 +22,10 @@ export default function configurePassport(app) {
|
||||
{
|
||||
passReqToCallback: true,
|
||||
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 notFoundIssuer = new Error('Issuer cannot be found!');
|
||||
|
||||
@@ -35,7 +42,7 @@ export default function configurePassport(app) {
|
||||
return done(null, authProvider.config);
|
||||
},
|
||||
},
|
||||
async function (request, user, done) {
|
||||
async function signonVerify(request, user, done) {
|
||||
const { issuer } = request.params;
|
||||
const notFoundIssuer = new Error('Issuer cannot be found!');
|
||||
|
||||
@@ -53,10 +60,38 @@ export default function configurePassport(app) {
|
||||
user,
|
||||
authProvider
|
||||
);
|
||||
|
||||
request.samlSessionId = user.sessionIndex;
|
||||
|
||||
return done(null, foundUserWithIdentity);
|
||||
},
|
||||
function (request, user, done) {
|
||||
return done(null, null);
|
||||
async function logoutVerify(request, user, done) {
|
||||
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',
|
||||
passport.authenticate('saml', {
|
||||
session: false,
|
||||
failureRedirect: '/',
|
||||
failureFlash: true,
|
||||
}),
|
||||
async (req, res) => {
|
||||
const token = await createAuthTokenByUserId(req.currentUser.id);
|
||||
async (request, response) => {
|
||||
const token = await createAuthTokenByUserId(request.currentUser.id, request.samlSessionId);
|
||||
|
||||
const redirectUrl = new URL(
|
||||
`/login/callback?token=${token}`,
|
||||
appConfig.webAppUrl
|
||||
).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' },
|
||||
userId: { type: 'string', format: 'uuid' },
|
||||
token: { type: 'string', minLength: 32 },
|
||||
samlSessionId: { type: ['string', 'null'] },
|
||||
expiresIn: { type: 'integer' },
|
||||
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() {
|
||||
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' },
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
@@ -45,6 +45,10 @@ class Role extends Base {
|
||||
get isAdmin() {
|
||||
return this.key === 'admin';
|
||||
}
|
||||
|
||||
static async findAdmin() {
|
||||
return await this.query().findOne({ key: 'admin' });
|
||||
}
|
||||
}
|
||||
|
||||
export default Role;
|
||||
|
@@ -1,5 +1,7 @@
|
||||
import { URL } from 'node:url';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import appConfig from '../config/app.js';
|
||||
import axios from '../helpers/axios-with-proxy.js';
|
||||
import Base from './base.js';
|
||||
import Identity from './identity.ee.js';
|
||||
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
||||
@@ -61,27 +63,71 @@ class SamlAuthProvider extends Base {
|
||||
});
|
||||
|
||||
static get virtualAttributes() {
|
||||
return ['loginUrl'];
|
||||
return ['loginUrl', 'remoteLogoutUrl'];
|
||||
}
|
||||
|
||||
get loginUrl() {
|
||||
return new URL(`/login/saml/${this.issuer}`, appConfig.baseUrl).toString();
|
||||
}
|
||||
|
||||
get config() {
|
||||
const callbackUrl = new URL(
|
||||
get loginCallBackUrl() {
|
||||
return new URL(
|
||||
`/login/saml/${this.issuer}/callback`,
|
||||
appConfig.baseUrl
|
||||
).toString();
|
||||
}
|
||||
|
||||
get remoteLogoutUrl() {
|
||||
return this.entryPoint;
|
||||
}
|
||||
|
||||
get config() {
|
||||
return {
|
||||
callbackUrl,
|
||||
callbackUrl: this.loginCallBackUrl,
|
||||
cert: this.certificate,
|
||||
entryPoint: this.entryPoint,
|
||||
issuer: this.issuer,
|
||||
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;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
import { DateTime } from 'luxon';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import appConfig from '../config/app.js';
|
||||
@@ -10,6 +10,7 @@ import Base from './base.js';
|
||||
import App from './app.js';
|
||||
import AccessToken from './access-token.js';
|
||||
import Connection from './connection.js';
|
||||
import Config from './config.js';
|
||||
import Execution from './execution.js';
|
||||
import Flow from './flow.js';
|
||||
import Identity from './identity.ee.js';
|
||||
@@ -20,6 +21,13 @@ import Subscription from './subscription.ee.js';
|
||||
import UsageData from './usage-data.ee.js';
|
||||
import Billing from '../helpers/billing/index.ee.js';
|
||||
|
||||
import deleteUserQueue from '../queues/delete-user.ee.js';
|
||||
import emailQueue from '../queues/email.js';
|
||||
import {
|
||||
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
} from '../helpers/remove-job-configuration.js';
|
||||
|
||||
class User extends Base {
|
||||
static tableName = 'users';
|
||||
|
||||
@@ -32,8 +40,21 @@ class User extends Base {
|
||||
fullName: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
|
||||
password: { type: 'string' },
|
||||
resetPasswordToken: { type: 'string' },
|
||||
resetPasswordTokenSentAt: { type: 'string' },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['active', 'invited'],
|
||||
default: 'active',
|
||||
},
|
||||
resetPasswordToken: { type: ['string', 'null'] },
|
||||
resetPasswordTokenSentAt: {
|
||||
type: ['string', 'null'],
|
||||
format: 'date-time',
|
||||
},
|
||||
invitationToken: { type: ['string', 'null'] },
|
||||
invitationTokenSentAt: {
|
||||
type: ['string', 'null'],
|
||||
format: 'date-time',
|
||||
},
|
||||
trialExpiryDate: { type: 'string' },
|
||||
roleId: { type: 'string', format: 'uuid' },
|
||||
deletedAt: { type: 'string' },
|
||||
@@ -201,6 +222,13 @@ class User extends Base {
|
||||
await this.$query().patch({ resetPasswordToken, resetPasswordTokenSentAt });
|
||||
}
|
||||
|
||||
async generateInvitationToken() {
|
||||
const invitationToken = crypto.randomBytes(64).toString('hex');
|
||||
const invitationTokenSentAt = new Date().toISOString();
|
||||
|
||||
await this.$query().patch({ invitationToken, invitationTokenSentAt });
|
||||
}
|
||||
|
||||
async resetPassword(password) {
|
||||
return await this.$query().patch({
|
||||
resetPasswordToken: null,
|
||||
@@ -209,7 +237,53 @@ class User extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
async isResetPasswordTokenValid() {
|
||||
async acceptInvitation(password) {
|
||||
return await this.$query().patch({
|
||||
invitationToken: null,
|
||||
invitationTokenSentAt: null,
|
||||
status: 'active',
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
async softRemove() {
|
||||
await this.$query().delete();
|
||||
|
||||
const jobName = `Delete user - ${this.id}`;
|
||||
const jobPayload = { id: this.id };
|
||||
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
|
||||
const jobOptions = {
|
||||
delay: millisecondsFor30Days,
|
||||
};
|
||||
|
||||
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail() {
|
||||
await this.generateResetPasswordToken();
|
||||
|
||||
const jobName = `Reset Password Email - ${this.id}`;
|
||||
|
||||
const jobPayload = {
|
||||
email: this.email,
|
||||
subject: 'Reset Password',
|
||||
template: 'reset-password-instructions.ee',
|
||||
params: {
|
||||
token: this.resetPasswordToken,
|
||||
webAppUrl: appConfig.webAppUrl,
|
||||
fullName: this.fullName,
|
||||
},
|
||||
};
|
||||
|
||||
const jobOptions = {
|
||||
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
|
||||
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
|
||||
};
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
}
|
||||
|
||||
isResetPasswordTokenValid() {
|
||||
if (!this.resetPasswordTokenSentAt) {
|
||||
return false;
|
||||
}
|
||||
@@ -221,6 +295,18 @@ class User extends Base {
|
||||
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
|
||||
}
|
||||
|
||||
isInvitationTokenValid() {
|
||||
if (!this.invitationTokenSentAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sentAt = new Date(this.invitationTokenSentAt);
|
||||
const now = new Date();
|
||||
const seventyTwoHoursInMilliseconds = 1000 * 60 * 60 * 72;
|
||||
|
||||
return now.getTime() - sentAt.getTime() < seventyTwoHoursInMilliseconds;
|
||||
}
|
||||
|
||||
async generateHash() {
|
||||
if (this.password) {
|
||||
this.password = await bcrypt.hash(this.password, 10);
|
||||
@@ -373,6 +459,21 @@ class User extends Base {
|
||||
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) {
|
||||
await super.$beforeInsert(queryContext);
|
||||
|
||||
|
@@ -2,26 +2,26 @@ import { Router } from 'express';
|
||||
import asyncHandler from 'express-async-handler';
|
||||
import { authenticateUser } from '../../../../helpers/authentication.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 getUserAction from '../../../../controllers/api/v1/admin/users/get-user.ee.js';
|
||||
import deleteUserAction from '../../../../controllers/api/v1/admin/users/delete-user.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
authenticateUser,
|
||||
authorizeAdmin,
|
||||
checkIsEnterprise,
|
||||
asyncHandler(getUsersAction)
|
||||
);
|
||||
router.get('/', authenticateUser, authorizeAdmin, asyncHandler(getUsersAction));
|
||||
|
||||
router.get(
|
||||
'/:userId',
|
||||
authenticateUser,
|
||||
authorizeAdmin,
|
||||
checkIsEnterprise,
|
||||
asyncHandler(getUserAction)
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:userId',
|
||||
authenticateUser,
|
||||
authorizeAdmin,
|
||||
asyncHandler(deleteUserAction)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
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;
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user