Compare commits

...

64 Commits

Author SHA1 Message Date
Ali BARIN
321019d36a sample js usage in ts 2023-12-22 12:24:32 +00:00
QAComet
d070e976b0 test(e2e-tests): run only on relevant changes in pull requests (#1495) 2023-12-15 18:51:58 +01:00
Ali BARIN
0caf6bfabb Merge pull request #1494 from automatisch/aut-551
feat: hide notifications page in mation instances
2023-12-15 18:00:08 +01:00
Ali BARIN
b842d7938f feat: hide notifications page in mation instances 2023-12-15 16:28:41 +00:00
Ali BARIN
cebbf84375 Merge pull request #1491 from automatisch/aut-548
feat: apply conditional mation styling
2023-12-15 11:40:12 +01:00
Ali BARIN
8608431490 feat: add conditional mation logo by default 2023-12-14 15:59:37 +00:00
Ali BARIN
78ba18b176 feat: apply conditional mation styling 2023-12-14 14:28:07 +00:00
Ali BARIN
f8c30c8526 Merge pull request #1475 from automatisch/aut-547
feat(queries/getAutomatischInfo): add mation
2023-12-14 11:15:35 +01:00
QAComet
693c9b85a5 test: run UI workflow only on changes outside of backend apps (#1462) 2023-12-14 10:45:15 +01:00
Ali BARIN
70bb7defd1 feat(queries/getAutomatischInfo): add mation 2023-12-12 17:37:27 +00:00
Ali BARIN
160377ca31 Merge pull request #1473 from automatisch/aut-545
docs(salesforce): update connection steps
2023-12-12 18:17:07 +01:00
Ali BARIN
2c0ce77a4e docs(salesforce): update connection steps 2023-12-12 17:08:15 +00:00
Ali BARIN
77fbb0c9da Merge pull request #1470 from automatisch/aut-538
fix(odoo): introduce secure connection option
2023-12-11 16:44:11 +01:00
Ali BARIN
5971425d23 fix(odoo): introduce secure connection option 2023-12-11 15:36:50 +00:00
Ali BARIN
aefff5c861 Merge pull request #1449 from automatisch/AUT-414
feat(zendesk): add new users trigger
2023-11-30 12:55:02 +01:00
Rıdvan Akca
a296b5e645 feat(zendesk): add new users trigger 2023-11-30 14:42:25 +03:00
Ali BARIN
eb486a3a07 Merge pull request #1456 from automatisch/AUT-431
feat(notion): add updated database items trigger
2023-11-29 16:54:45 +01:00
Ali BARIN
062b8521ba Merge pull request #1454 from automatisch/AUT-418
feat(zendesk): add delete user action
2023-11-29 14:37:42 +01:00
Rıdvan Akca
1b07f3195a feat(zendesk): add delete user action 2023-11-29 16:30:05 +03:00
Rıdvan Akca
dfa7d4cb8d feat(notion): add updated database items trigger 2023-11-29 16:18:38 +03:00
Ali BARIN
a14dd9666c Merge pull request #1451 from automatisch/AUT-415
feat(zendesk): add create user action
2023-11-29 14:10:46 +01:00
Rıdvan Akca
b07bd4374f feat(zendesk): add create user action 2023-11-29 16:01:55 +03:00
Ali BARIN
b4e12b0ea8 Merge pull request #1448 from automatisch/AUT-413
feat(zendesk): add delete ticket action
2023-11-29 13:35:58 +01:00
Ali BARIN
ee5c17bb85 Merge pull request #1447 from automatisch/AUT-412
feat(zendesk): add find ticket action
2023-11-29 13:32:29 +01:00
Rıdvan Akca
16c9d3400c feat(zendesk): add delete ticket action 2023-11-29 14:27:54 +03:00
Rıdvan Akca
4dd994348d feat(zendesk): add find ticket action 2023-11-29 13:17:04 +03:00
Ali BARIN
f0cbfafc24 Merge pull request #1443 from automatisch/AUT-411
feat(zendesk): add update ticket action
2023-11-28 15:02:21 +01:00
Ali BARIN
d3f38f5488 Merge pull request #1455 from automatisch/rename-discord-scheduled-event-action
feat(discord/create-scheduled-event): remove new prefix
2023-11-28 13:14:25 +01:00
Ali BARIN
737090a67a feat(discord/create-scheduled-event): remove new prefix 2023-11-28 11:56:36 +00:00
Ali BARIN
4f66a4d090 Merge pull request #1446 from automatisch/AUT-465
feat: embed external fonts used in the codebase
2023-11-28 12:47:32 +01:00
Rıdvan Akca
df54f909c1 feat(zendesk): add update ticket action 2023-11-28 14:40:26 +03:00
Ali BARIN
772b195eca Merge pull request #1441 from automatisch/AUT-409
feat(zendesk): add new tickets trigger
2023-11-28 11:52:22 +01:00
Ali BARIN
87866e34ed Merge pull request #1450 from felifluid/feature/action-discord-create-event
feat(discord): add createEvent action
2023-11-27 18:08:20 +01:00
Ali BARIN
c98ac05097 feat(discord/create-new-scheduled-event): rework fields 2023-11-27 17:01:40 +00:00
DerKlobold
36f991b6f9 feat: discord createNewEvent action in docs
adds a simple entry in the docs for the createNewEvent action
2023-11-26 18:59:01 +01:00
DerKlobold
a81c5164fc feat: discord createNewEvent action
adds the action of creating a new event on a discord server. provides some sort of logic-check to make sure the correct fields have been filled, depending of the type given.
2023-11-26 18:58:09 +01:00
DerKlobold
5942482690 feat: list discord voice channel dynamic data
adds a helper function to provide dynamic data of voice and stage channels of a discord server
2023-11-26 18:56:50 +01:00
Rıdvan Akca
4f538ca2fc feat(zendesk): add new tickets trigger 2023-11-24 15:22:35 +03:00
Kasia
9f2281a3e2 feat: embed external fonts used in the codebase 2023-11-24 12:20:28 +00:00
Ali BARIN
b0d2f28c78 Merge pull request #1444 from automatisch/AUT-487
fix(zendesk): get after_cursor from meta field
2023-11-23 13:42:01 +01:00
Rıdvan Akca
d4380a4426 fix(zendesk): get after_cursor from meta field 2023-11-23 15:17:11 +03:00
Ali BARIN
ae2738d4cc Merge pull request #1435 from mohammedzaher/removebg
feat(removebg): add `remove image background` action
2023-11-22 16:28:55 +01:00
Ali BARIN
aa5ae028b2 feat(removebg/remove-image-background): update wording 2023-11-22 15:01:02 +00:00
Mohammed Zaher
7ab8c76aa0 docs(removebg): Add Remove image background action 2023-11-16 15:43:22 +00:00
Mohammed Zaher
8075b65e14 feat(removebg): Add Remove image background action 2023-11-16 15:39:46 +00:00
Ali BARIN
073ce3bf1b Merge pull request #1433 from automatisch/AUT-445
feat(reddit): provide user-agent header
2023-11-14 17:25:29 +01:00
Rıdvan Akca
80fcbfe01b feat(reddit): provide user-agent header 2023-11-14 14:08:01 +03:00
Ali BARIN
dba0041e5f Merge pull request #1430 from automatisch/dependabot/npm_and_yarn/axios-1.6.0
chore(deps): bump axios from 0.24.0 to 1.6.0
2023-11-13 17:56:57 +01:00
Ali BARIN
b8a44afd25 refactor: re-type interceptors for axios@1.6.0 2023-11-13 16:14:03 +00:00
dependabot[bot]
e2445bf585 chore(deps): bump axios from 0.24.0 to 1.6.0
Bumps [axios](https://github.com/axios/axios) from 0.24.0 to 1.6.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.24.0...v1.6.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-11-13 16:14:03 +00:00
Ali BARIN
50706c524e Merge pull request #1431 from QAComet/qacomet/admin-roles-loader
test: add page title test ids to await and await mounting loader components
2023-11-13 10:52:49 +01:00
QAComet
11e0cb9398 test: add page title test ids to await and await mounting loader components 2023-11-12 16:46:38 -07:00
Ali BARIN
1e82e40802 Merge pull request #1428 from automatisch/AUT-398
feat(reddit): add create link post action
2023-11-10 16:27:25 +01:00
Rıdvan Akca
ff00644e62 feat(reddit): add create link post action 2023-11-10 18:21:04 +03:00
Ali BARIN
97bcd3792b Merge pull request #1427 from automatisch/AUT-397
feat(reddit): add new posts matching search trigger
2023-11-10 16:03:35 +01:00
Rıdvan Akca
5738a09771 feat(reddit): add new posts matching search trigger 2023-11-10 16:50:26 +03:00
kattoczko
c461cc4878 feat: introduce application auth clients tab in the admin panel (#1423)
* feat: introduce application auth clients tab in the admin panel

* feat: introduce improvements

* feat: use loading state returned from useMutation

* feat: use error returned by useMutation hook
2023-11-10 14:09:23 +01:00
Ali BARIN
878fab347a Merge pull request #1426 from automatisch/AUT-396
feat(reddit): add reddit integration
2023-11-10 11:16:46 +01:00
Rıdvan Akca
354b331b08 feat(reddit): add reddit integration 2023-11-10 12:51:52 +03:00
Ali BARIN
3b9aadb90f Merge pull request #1421 from automatisch/AUT-392
feat(xero): add new payments trigger
2023-11-09 16:30:42 +01:00
Ali BARIN
7193d018ce Merge pull request #1425 from automatisch/add-sf-execute-query-in-docs
docs(salesforce): list execute query in actions
2023-11-09 16:09:43 +01:00
Ali BARIN
d5cea034ac docs(salesforce): list execute query in actions 2023-11-09 15:02:58 +00:00
Ömer Faruk Aydın
a2760c10b3 Merge pull request #1422 from automatisch/release/0.10.0
Release v0.10.0
2023-11-09 16:12:19 +03:00
Rıdvan Akca
3593cf3808 feat(xero): add new payments trigger 2023-11-09 11:57:47 +03:00
125 changed files with 2774 additions and 280 deletions

View File

@@ -4,6 +4,11 @@ on:
branches:
- main
pull_request:
paths:
- 'packages/backend/**'
- 'packages/e2e-tests/**'
- 'packages/web/**'
- '!packages/backend/src/apps/**'
workflow_dispatch:
env:

View File

@@ -38,7 +38,7 @@
"@types/xmlrpc": "^1.3.7",
"accounting": "^0.4.1",
"ajv-formats": "^2.1.1",
"axios": "0.24.0",
"axios": "1.6.0",
"bcrypt": "^5.0.1",
"bullmq": "^3.0.0",
"copyfiles": "^2.4.1",

View File

@@ -0,0 +1,102 @@
import defineAction from '../../../../helpers/define-action';
export default defineAction({
name: 'Create a scheduled event',
key: 'createScheduledEvent',
description: 'Creates a scheduled event',
arguments: [
{
label: 'Type',
key: 'entityType',
type: 'dropdown' as const,
required: true,
variables: true,
options: [
{ label: 'Stage channel', value: 1 },
{ label: 'Voice channel', value: 2 },
{ label: 'External', value: 3 }
],
additionalFields: {
type: 'query',
name: 'getDynamicFields',
arguments: [
{
name: 'key',
value: 'listExternalScheduledEventFields',
},
{
name: 'parameters.entityType',
value: '{parameters.entityType}',
},
],
},
},
{
label: 'Name',
key: 'name',
type: 'string' as const,
required: true,
variables: true,
},
{
label: 'Description',
key: 'description',
type: 'string' as const,
required: false,
variables: true,
},
{
label: 'Image',
key: 'image',
type: 'string' as const,
required: false,
description: 'Image as DataURI scheme [data:image/<jpeg/png/gif>;base64,BASE64_ENCODED_<JPEG/PNG/GIF>_IMAGE_DATA]',
variables: true,
},
],
async run($) {
type entity_metadata = {
location: string
}
type guild_event = {
channel_id: number,
name: string,
privacy_level: number,
scheduled_start_time: string,
scheduled_end_time?: string,
description?: string,
entity_type?: number,
entity_metadata?: entity_metadata,
image?: string, //data:image/jpeg;base64,BASE64_ENCODED_JPEG_IMAGE_DATA
}
const data: guild_event = {
channel_id: $.step.parameters.channel_id as number,
name: $.step.parameters.name as string,
privacy_level: 2,
scheduled_start_time: $.step.parameters.scheduledStartTime as string,
scheduled_end_time: $.step.parameters.scheduledEndTime as string,
description: $.step.parameters.description as string,
entity_type: $.step.parameters.entityType as number,
image: $.step.parameters.image as string,
};
const isExternal = $.step.parameters.entityType === 3;
if (isExternal) {
data.entity_metadata = {
location: $.step.parameters.location as string,
};
data.channel_id = null;
}
const response = await $.http?.post(
`/guilds/${$.auth.data.guildId}/scheduled-events`,
data
);
$.setActionItem({ raw: response.data });
},
});

View File

@@ -1,3 +1,4 @@
import sendMessageToChannel from './send-message-to-channel';
import createScheduledEvent from './create-scheduled-event';
export default [sendMessageToChannel];
export default [sendMessageToChannel, createScheduledEvent];

View File

@@ -1,3 +1,4 @@
import listChannels from './list-channels';
import listVoiceChannels from './list-voice-channels';
export default [listChannels];
export default [listChannels, listVoiceChannels];

View File

@@ -0,0 +1,34 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List voice channels',
key: 'listVoiceChannels',
async run($: IGlobalVariable) {
const channels: {
data: IJSONObject[];
error: IJSONObject | null;
} = {
data: [],
error: null,
};
const response = await $.http.get(
`/guilds/${$.auth.data.guildId}/channels`
);
channels.data = response.data
.filter((channel: IJSONObject) => {
// filter in voice and stage channels only
return channel.type === 2 || channel.type === 13;
})
.map((channel: IJSONObject) => {
return {
value: channel.id,
name: channel.name,
};
});
return channels;
},
};

View File

@@ -0,0 +1,3 @@
import listExternalScheduledEventFields from './list-external-scheduled-event-fields';
export default [listExternalScheduledEventFields];

View File

@@ -0,0 +1,83 @@
import { IGlobalVariable } from '@automatisch/types';
export default {
name: 'List external scheduled event fields',
key: 'listExternalScheduledEventFields',
async run($: IGlobalVariable) {
const isExternal = $.step.parameters.entityType === 3;
if (isExternal) {
return [
{
label: 'Location',
key: 'location',
type: 'string' as const,
required: true,
description: 'The location of the event (1-100 characters). This will be omitted if type is NOT EXTERNAL',
variables: true,
},
{
label: 'Start-Time',
key: 'scheduledStartTime',
type: 'string' as const,
required: true,
description: 'The time the event will start [ISO8601]',
variables: true,
},
{
label: 'End-Time',
key: 'scheduledEndTime',
type: 'string' as const,
required: true,
description: 'The time the event will end [ISO8601]. This will be omitted if type is NOT EXTERNAL',
variables: true,
},
];
}
return [
{
label: 'Channel',
key: 'channel_id',
type: 'dropdown' as const,
required: true,
description: 'Pick a voice or stage channel to link the event to. This will be omitted if type is EXTERNAL',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listVoiceChannels',
},
],
},
},
{
label: 'Location',
key: 'location',
type: 'string' as const,
required: false,
description: 'The location of the event (1-100 characters). This will be omitted if type is NOT EXTERNAL',
variables: true,
},
{
label: 'Start-Time',
key: 'scheduledStartTime',
type: 'string' as const,
required: true,
description: 'The time the event will start [ISO8601]',
variables: true,
},
{
label: 'End-Time',
key: 'scheduledEndTime',
type: 'string' as const,
required: false,
description: 'The time the event will end [ISO8601]. This will be omitted if type is NOT EXTERNAL',
variables: true,
},
];
},
};

View File

@@ -4,6 +4,7 @@ import auth from './auth';
import dynamicData from './dynamic-data';
import actions from './actions';
import triggers from './triggers';
import dynamicFields from './dynamic-fields';
export default defineApp({
name: 'Discord',
@@ -17,6 +18,7 @@ export default defineApp({
beforeRequest: [addAuthHeader],
auth,
dynamicData,
dynamicFields,
triggers,
actions,
});

View File

@@ -1,3 +1,4 @@
import newDatabaseItems from './new-database-items';
import updatedDatabaseItems from './updated-database-items';
export default [newDatabaseItems];
export default [newDatabaseItems, updatedDatabaseItems];

View File

@@ -0,0 +1,33 @@
import defineTrigger from '../../../../helpers/define-trigger';
import updatedDatabaseItems from './updated-database-items';
export default defineTrigger({
name: 'Updated database items',
key: 'updatedDatabaseItems',
pollInterval: 15,
description:
'Triggers when there is an update to an item in a chosen database',
arguments: [
{
label: 'Database',
key: 'databaseId',
type: 'dropdown' as const,
required: false,
variables: false,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listDatabases',
},
],
},
},
],
async run($) {
await updatedDatabaseItems($);
},
});

View File

@@ -0,0 +1,51 @@
import { IGlobalVariable } from '@automatisch/types';
type DatabaseItem = {
id: string;
last_edited_time: string;
};
type ResponseData = {
results: DatabaseItem[];
next_cursor?: string;
};
type Payload = {
sorts: [
{
timestamp: 'created_time' | 'last_edited_time';
direction: 'ascending' | 'descending';
}
];
start_cursor?: string;
};
const updatedDatabaseItems = async ($: IGlobalVariable) => {
const payload: Payload = {
sorts: [
{
timestamp: 'last_edited_time',
direction: 'descending',
},
],
};
const databaseId = $.step.parameters.databaseId as string;
const path = `/v1/databases/${databaseId}/query`;
do {
const response = await $.http.post<ResponseData>(path, payload);
payload.start_cursor = response.data.next_cursor;
for (const databaseItem of response.data.results) {
$.pushTriggerItem({
raw: databaseItem,
meta: {
internalId: `${databaseItem.id}-${databaseItem.last_edited_time}`,
},
});
}
} while (payload.start_cursor);
};
export default updatedDatabaseItems;

View File

@@ -11,7 +11,7 @@ export default {
readOnly: false,
value: null,
placeholder: null,
description: 'Host name of your Odoo Server',
description: 'Host name of your Odoo Server (e.g. sub.domain.com without the protocol)',
clickToCopy: false,
},
{
@@ -25,6 +25,27 @@ export default {
description: 'Port that the host is running on, defaults to 443 (HTTPS)',
clickToCopy: false,
},
{
key: 'secure',
label: 'Secure',
type: 'dropdown' as const,
required: true,
readOnly: false,
value: 'true',
description: 'True if the host communicates via secure protocol.',
variables: false,
clickToCopy: false,
options: [
{
label: 'True',
value: 'true',
},
{
label: 'False',
value: 'false',
},
],
},
{
key: 'databaseName',
label: 'Database Name',
@@ -40,7 +61,7 @@ export default {
key: 'email',
label: 'Email Address',
type: 'string' as const,
requires: true,
required: true,
readOnly: false,
value: null,
placeholder: null,

View File

@@ -32,8 +32,10 @@ export const asyncMethodCall = async <T = number>($: IGlobalVariable, { method,
export const getClient = ($: IGlobalVariable, { path = 'common' }) => {
const host = $.auth.data.host as string;
const port = Number($.auth.data.port as string);
const secure = $.auth.data.secure === 'true';
const createClientFunction = secure ? xmlrpc.createSecureClient : xmlrpc.createClient;
return xmlrpc.createClient(
return createClientFunction(
{
host,
port,

View File

@@ -0,0 +1,53 @@
import defineAction from '../../../../helpers/define-action';
import { URLSearchParams } from 'url';
export default defineAction({
name: 'Create link post',
key: 'createLinkPost',
description: 'Create a new link post within a subreddit.',
arguments: [
{
label: 'Title',
key: 'title',
type: 'string' as const,
required: true,
description:
'Heading for the recent post. Limited to 300 characters or less.',
variables: true,
},
{
label: 'Subreddit',
key: 'subreddit',
type: 'string' as const,
required: true,
description: 'The subreddit for posting. Note: Exclude /r/.',
variables: true,
},
{
label: 'Url',
key: 'url',
type: 'string' as const,
required: true,
description: '',
variables: true,
},
],
async run($) {
const { title, subreddit, url } = $.step.parameters;
const params = new URLSearchParams({
kind: 'link',
api_type: 'json',
title: title as string,
sr: subreddit as string,
url: url as string,
});
const { data } = await $.http.post('/api/submit', params.toString());
$.setActionItem({
raw: data,
});
},
});

View File

@@ -0,0 +1,3 @@
import createLinkPost from './create-link-post';
export default [createLinkPost];

View File

@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="_1O4jTk-dZ-VIxsCuYB6OR8 " width="40" height="48" ><g><circle fill="#FF4500" cx="10" cy="10" r="10"></circle><path fill="#FFFFFF" d="M16.67,10A1.46,1.46,0,0,0,14.2,9a7.12,7.12,0,0,0-3.85-1.23L11,4.65,13.14,5.1a1,1,0,1,0,.13-0.61L10.82,4a0.31,0.31,0,0,0-.37.24L9.71,7.71a7.14,7.14,0,0,0-3.9,1.23A1.46,1.46,0,1,0,4.2,11.33a2.87,2.87,0,0,0,0,.44c0,2.24,2.61,4.06,5.83,4.06s5.83-1.82,5.83-4.06a2.87,2.87,0,0,0,0-.44A1.46,1.46,0,0,0,16.67,10Zm-10,1a1,1,0,1,1,1,1A1,1,0,0,1,6.67,11Zm5.81,2.75a3.84,3.84,0,0,1-2.47.77,3.84,3.84,0,0,1-2.47-.77,0.27,0.27,0,0,1,.38-0.38A3.27,3.27,0,0,0,10,14a3.28,3.28,0,0,0,2.09-.61A0.27,0.27,0,1,1,12.48,13.79Zm-0.18-1.71a1,1,0,1,1,1-1A1,1,0,0,1,12.29,12.08Z"></path></g></svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -0,0 +1,26 @@
import { IField, IGlobalVariable } from '@automatisch/types';
import { URLSearchParams } from 'url';
import authScope from '../common/auth-scope';
export default async function generateAuthUrl($: IGlobalVariable) {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value as string;
const state = Math.random().toString() as string;
const searchParams = new URLSearchParams({
client_id: $.auth.data.clientId as string,
response_type: 'code',
redirect_uri: redirectUri,
duration: 'permanent',
scope: authScope.join(' '),
state,
});
const url = `https://www.reddit.com/api/v1/authorize?${searchParams.toString()}`;
await $.auth.set({
url,
originalState: state,
});
}

View File

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

View File

@@ -0,0 +1,9 @@
import { IGlobalVariable } from '@automatisch/types';
import getCurrentUser from '../common/get-current-user';
const isStillVerified = async ($: IGlobalVariable) => {
const currentUser = await getCurrentUser($);
return !!currentUser.id;
};
export default isStillVerified;

View File

@@ -0,0 +1,29 @@
import { URLSearchParams } from 'node:url';
import { IGlobalVariable } from '@automatisch/types';
const refreshToken = async ($: IGlobalVariable) => {
const headers = {
Authorization: `Basic ${Buffer.from(
$.auth.data.clientId + ':' + $.auth.data.clientSecret
).toString('base64')}`,
};
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: $.auth.data.refreshToken as string,
});
const { data } = await $.http.post(
'https://www.reddit.com/api/v1/access_token',
params.toString(),
{ headers }
);
await $.auth.set({
accessToken: data.access_token,
expiresIn: data.expires_in,
scope: data.scope,
tokenType: data.token_type,
});
};
export default refreshToken;

View File

@@ -0,0 +1,48 @@
import { IField, IGlobalVariable } from '@automatisch/types';
import getCurrentUser from '../common/get-current-user';
import { URLSearchParams } from 'url';
const verifyCredentials = async ($: IGlobalVariable) => {
if ($.auth.data.originalState !== $.auth.data.state) {
throw new Error(`The 'state' parameter does not match.`);
}
const oauthRedirectUrlField = $.app.auth.fields.find(
(field: IField) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value as string;
const headers = {
Authorization: `Basic ${Buffer.from(
$.auth.data.clientId + ':' + $.auth.data.clientSecret
).toString('base64')}`,
};
const params = new URLSearchParams({
grant_type: 'authorization_code',
code: $.auth.data.code as string,
redirect_uri: redirectUri,
});
const { data } = await $.http.post(
'https://www.reddit.com/api/v1/access_token',
params.toString(),
{ headers }
);
await $.auth.set({
accessToken: data.access_token,
tokenType: data.token_type,
});
const currentUser = await getCurrentUser($);
const screenName = currentUser?.name;
await $.auth.set({
clientId: $.auth.data.clientId,
clientSecret: $.auth.data.clientSecret,
scope: $.auth.data.scope,
expiresIn: data.expires_in,
refreshToken: data.refresh_token,
screenName,
});
};
export default verifyCredentials;

View File

@@ -0,0 +1,23 @@
import { TBeforeRequest } from '@automatisch/types';
import appConfig from '../../../config/app';
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
const screenName = $.auth.data?.screenName as string;
if ($.auth.data?.accessToken) {
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
}
if (screenName) {
requestConfig.headers[
'User-Agent'
] = `web:automatisch:${appConfig.version} (by /u/${screenName})`;
} else {
requestConfig.headers[
'User-Agent'
] = `web:automatisch:${appConfig.version}`;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -0,0 +1,3 @@
const authScope: string[] = ['identity', 'read', 'account', 'submit'];
export default authScope;

View File

@@ -0,0 +1,8 @@
import { IGlobalVariable } from '@automatisch/types';
const getCurrentUser = async ($: IGlobalVariable) => {
const { data: currentUser } = await $.http.get('/api/v1/me');
return currentUser;
};
export default getCurrentUser;

View File

View File

@@ -0,0 +1,20 @@
import defineApp from '../../helpers/define-app';
import addAuthHeader from './common/add-auth-header';
import auth from './auth';
import triggers from './triggers';
import actions from './actions';
export default defineApp({
name: 'Reddit',
key: 'reddit',
baseUrl: 'https://www.reddit.com',
apiBaseUrl: 'https://oauth.reddit.com',
iconUrl: '{BASE_URL}/apps/reddit/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/reddit/connection',
primaryColor: 'FF4500',
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
triggers,
actions,
});

View File

@@ -0,0 +1,3 @@
import newPostsMatchingSearch from './new-posts-matching-search';
export default [newPostsMatchingSearch];

View File

@@ -0,0 +1,48 @@
import defineTrigger from '../../../../helpers/define-trigger';
export default defineTrigger({
name: 'New posts matching search',
key: 'newPostsMatchingSearch',
pollInterval: 15,
description: 'Triggers when a search string matches a new post.',
arguments: [
{
label: 'Search Query',
key: 'searchQuery',
type: 'string' as const,
required: true,
description:
'The term or expression to look for, restricted to 512 characters. If your query contains periods (e.g., automatisch.io), ensure it is enclosed in quotes ("automatisch.io").',
variables: true,
},
],
async run($) {
const { searchQuery } = $.step.parameters;
const params = {
q: searchQuery,
type: 'link',
sort: 'new',
limit: 100,
after: undefined as unknown as string,
};
do {
const { data } = await $.http.get('/search', {
params,
});
params.after = data.data.after;
if (data.data.children?.length) {
for (const item of data.data.children) {
$.pushTriggerItem({
raw: item,
meta: {
internalId: item.data.id,
},
});
}
}
} while (params.after);
},
});

View File

@@ -0,0 +1,3 @@
import removeImageBackground from './remove-image-background';
export default [removeImageBackground];

View File

@@ -0,0 +1,82 @@
import defineAction from '../../../../helpers/define-action';
export default defineAction({
name: 'Remove image background',
key: 'removeImageBackground',
description:
'Removes the background of an image.',
arguments: [
{
label: 'Image file',
key: 'imageFileB64',
type: 'string' as const,
required: true,
variables: true,
description: 'Provide a JPG or PNG file in Base64 format, up to 12 MB (see remove.bg/supported-images)',
},
{
label: 'Size',
key: 'size',
type: 'dropdown' as const,
required: true,
value: 'auto',
options: [
{ label: 'Auto', value: 'auto' },
{ label: 'Preview (up to 0.25 megapixels)', value: 'preview' },
{ label: 'Full (up to 10 megapixels)', value: 'full' },
]
},
{
label: 'Background color',
key: 'bgColor',
type: 'string' as const,
description: 'Adds a solid color background. Can be a hex color code (e.g. 81d4fa, fff) or a color name (e.g. green)',
required: false,
},
{
label: 'Background image URL',
key: 'bgImageUrl',
type: 'string' as const,
description: 'Adds a background image from a URL.',
required: false,
},
{
label: 'Output image format',
key: 'outputFormat',
type: 'dropdown' as const,
description: 'Note: Use PNG to preserve transparency',
required: true,
value: 'auto',
options: [
{ label: 'Auto', value: 'auto' },
{ label: 'PNG', value: 'png' },
{ label: 'JPG', value: 'jpg' },
{ label: 'ZIP', value: 'zip' }
]
}
],
async run($) {
const imageFileB64 = $.step.parameters.imageFileB64 as string;
const size = $.step.parameters.size as string;
const bgColor = $.step.parameters.bgColor as string;
const bgImageUrl = $.step.parameters.bgImageUrl as string;
const outputFormat = $.step.parameters.outputFormat as string;
const body = JSON.stringify({
image_file_b64: imageFileB64,
size: size,
bg_color: bgColor,
bg_image_url: bgImageUrl,
format: outputFormat
});
const response = await $.http.post('/removebg', body, {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
});
$.setActionItem({ raw: response.data });
}
});

View File

@@ -1,6 +1,7 @@
import defineApp from '../../helpers/define-app';
import addAuthHeader from './common/add-auth-header';
import auth from './auth';
import actions from './actions';
export default defineApp({
name: 'Remove.bg',
@@ -13,4 +14,5 @@ export default defineApp({
primaryColor: '55636c',
beforeRequest: [addAuthHeader],
auth,
actions,
});

View File

@@ -1,3 +1,4 @@
import newBankTransactions from './new-bank-transactions';
import newPayments from './new-payments';
export default [newBankTransactions];
export default [newBankTransactions, newPayments];

View File

@@ -0,0 +1,109 @@
import defineTrigger from '../../../../helpers/define-trigger';
type Params = {
page: number;
order: string;
where?: string;
};
export default defineTrigger({
name: 'New payments',
key: 'newPayments',
pollInterval: 15,
description: 'Triggers when a new payment is received.',
arguments: [
{
label: 'Organization',
key: 'organizationId',
type: 'dropdown' as const,
required: true,
description: '',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listOrganizations',
},
],
},
},
{
label: 'Payment Type',
key: 'paymentType',
type: 'dropdown' as const,
required: false,
description: '',
variables: true,
value: '',
options: [
{ label: 'Accounts Receivable', value: 'ACCRECPAYMENT' },
{ label: 'Accounts Payable', value: 'ACCPAYPAYMENT' },
{
label: 'Accounts Receivable Credit (Refund)',
value: 'ARCREDITPAYMENT',
},
{
label: 'Accounts Payable Credit (Refund)',
value: 'APCREDITPAYMENT',
},
{
label: 'Accounts Receivable Overpayment (Refund)',
value: 'AROVERPAYMENTPAYMENT',
},
{
label: 'Accounts Receivable Prepayment (Refund)',
value: 'ARPREPAYMENTPAYMENT',
},
{
label: 'Accounts Payable Prepayment (Refund)',
value: 'APPREPAYMENTPAYMENT',
},
{
label: 'Accounts Payable Overpayment (Refund)',
value: 'APOVERPAYMENTPAYMENT',
},
],
},
],
async run($) {
const paymentType = $.step.parameters.paymentType;
const params: Params = {
page: 1,
order: 'Date DESC',
};
if (paymentType) {
params.where = `PaymentType="${paymentType}"`;
}
let nextPage = false;
do {
const { data } = await $.http.get('/api.xro/2.0/Payments', {
params,
});
params.page = params.page + 1;
if (data.Payments?.length) {
for (const payment of data.Payments) {
$.pushTriggerItem({
raw: payment,
meta: {
internalId: payment.PaymentID,
},
});
}
}
if (data.Payments?.length === 100) {
nextPage = true;
} else {
nextPage = false;
}
} while (nextPage);
},
});

View File

@@ -0,0 +1,102 @@
export const fields = [
{
label: 'Name',
key: 'name',
type: 'string' as const,
required: true,
variables: true,
description: '',
},
{
label: 'Email',
key: 'email',
type: 'string' as const,
required: true,
variables: true,
description:
'It is essential to be distinctive. Zendesk prohibits the existence of identical users sharing the same email address.',
},
{
label: 'Details',
key: 'details',
type: 'string' as const,
required: false,
variables: true,
description: '',
},
{
label: 'Notes',
key: 'notes',
type: 'string' as const,
required: false,
variables: true,
description:
'Within this field, you have the capability to save any remarks or comments you may have concerning the user.',
},
{
label: 'Phone',
key: 'phone',
type: 'string' as const,
required: false,
variables: true,
description:
"The user's contact number should be entered in the following format: +1 (555) 123-4567.",
},
{
label: 'Tags',
key: 'tags',
type: 'string' as const,
required: false,
variables: true,
description: 'A comma separated list of tags.',
},
{
label: 'Role',
key: 'role',
type: 'string' as const,
required: false,
variables: true,
description:
"It can take on one of the designated roles: 'end-user', 'agent', or 'admin'. If a different value is set or none is specified, the default is 'end-user.'",
},
{
label: 'Organization',
key: 'organizationId',
type: 'dropdown' as const,
required: false,
variables: true,
description: 'Assign this user to a specific organization.',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listOrganizations',
},
],
},
},
{
label: 'External Id',
key: 'externalId',
type: 'string' as const,
required: false,
variables: true,
description:
'An exclusive external identifier; you can utilize this to link organizations with an external record.',
},
{
label: 'Verified',
key: 'verified',
type: 'dropdown' as const,
required: false,
description:
"Specify if you can verify that the user's assertion of their identity is accurate.",
variables: true,
options: [
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
],
},
];

View File

@@ -0,0 +1,53 @@
import { IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
import { fields } from './fields';
type Payload = {
user: IJSONObject;
};
export default defineAction({
name: 'Create user',
key: 'createUser',
description: 'Creates a new user.',
arguments: fields,
async run($) {
const {
name,
email,
details,
notes,
phone,
role,
organizationId,
externalId,
verified,
} = $.step.parameters;
const tags = $.step.parameters.tags as string;
const formattedTags = tags.split(',');
const payload: Payload = {
user: {
name,
email,
details,
notes,
phone,
organization_id: organizationId,
external_id: externalId,
verified: verified || 'false',
tags: formattedTags,
},
};
if (role) {
payload.user.role = role;
}
const response = await $.http.post('/api/v2/users', payload);
$.setActionItem({ raw: response.data });
},
});

View File

@@ -0,0 +1,35 @@
import defineAction from '../../../../helpers/define-action';
export default defineAction({
name: 'Delete ticket',
key: 'deleteTicket',
description: 'Deletes an existing ticket.',
arguments: [
{
label: 'Ticket',
key: 'ticketId',
type: 'dropdown' as const,
required: true,
variables: true,
description: 'Select the ticket you want to delete.',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listFirstPageOfTickets',
},
],
},
},
],
async run($) {
const ticketId = $.step.parameters.ticketId;
const response = await $.http.delete(`/api/v2/tickets/${ticketId}`);
$.setActionItem({ raw: { data: response.data } });
},
});

View File

@@ -0,0 +1,43 @@
import defineAction from '../../../../helpers/define-action';
export default defineAction({
name: 'Delete user',
key: 'deleteUser',
description: 'Deletes an existing user.',
arguments: [
{
label: 'User',
key: 'userId',
type: 'dropdown' as const,
required: true,
variables: true,
description: 'Select the user you want to modify.',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listUsers',
},
{
name: 'parameters.showUserRole',
value: 'true',
},
{
name: 'parameters.includeAllUsers',
value: 'true',
},
],
},
},
],
async run($) {
const userId = $.step.parameters.userId;
const response = await $.http.delete(`/api/v2/users/${userId}`);
$.setActionItem({ raw: response.data });
},
});

View File

@@ -0,0 +1,32 @@
import defineAction from '../../../../helpers/define-action';
export default defineAction({
name: 'Find ticket',
key: 'findTicket',
description: 'Finds an existing ticket.',
arguments: [
{
label: 'Query',
key: 'query',
type: 'string' as const,
required: true,
variables: true,
description:
'Write a search string that specifies the way we will search for the ticket in Zendesk.',
},
],
async run($) {
const query = $.step.parameters.query;
const params = {
query: `type:ticket ${query}`,
sort_by: 'created_at',
sort_order: 'desc',
};
const response = await $.http.get('/api/v2/search', { params });
$.setActionItem({ raw: response.data.results[0] });
},
});

View File

@@ -1,3 +1,15 @@
import createTicket from './create-ticket';
import createUser from './create-user';
import deleteTicket from './delete-ticket';
import deleteUser from './delete-user';
import findTicket from './find-ticket';
import updateTicket from './update-ticket';
export default [createTicket];
export default [
createTicket,
createUser,
deleteTicket,
deleteUser,
findTicket,
updateTicket,
];

View File

@@ -0,0 +1,167 @@
export const fields = [
{
label: 'Ticket',
key: 'ticketId',
type: 'dropdown' as const,
required: true,
variables: true,
description: 'Select the ticket you want to change.',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listFirstPageOfTickets',
},
],
},
},
{
label: 'Subject',
key: 'subject',
type: 'string' as const,
required: false,
variables: true,
description: '',
},
{
label: 'Assignee',
key: 'assigneeId',
type: 'dropdown' as const,
required: false,
variables: true,
description:
'Note: An error occurs if the assignee is not in the default group (or the specific group chosen below).',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listUsers',
},
{
name: 'parameters.showUserRole',
value: 'true',
},
{
name: 'parameters.includeAdmins',
value: 'true',
},
],
},
},
{
label: 'Group',
key: 'groupId',
type: 'dropdown' as const,
required: false,
variables: true,
description: 'Allocate this ticket to a specific group.',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listGroups',
},
],
},
},
{
label: 'New Status',
key: 'status',
type: 'dropdown' as const,
required: false,
variables: true,
description: '',
options: [
{ label: 'New', value: 'new' },
{ label: 'Open', value: 'open' },
{ label: 'Pending', value: 'pending' },
{ label: 'Hold', value: 'hold' },
{ label: 'Solved', value: 'solved' },
{ label: 'Closed', value: 'closed' },
],
},
{
label: 'New comment to add to the ticket',
key: 'comment',
type: 'string' as const,
required: false,
variables: true,
description: '',
},
{
label: 'Should the first comment be public?',
key: 'publicOrNot',
type: 'dropdown' as const,
required: false,
variables: true,
description: '',
options: [
{ label: 'Yes', value: 'yes' },
{ label: 'No', value: 'no' },
],
},
{
label: 'Tags',
key: 'tags',
type: 'string' as const,
required: false,
variables: true,
description: 'A comma separated list of tags.',
},
{
label: 'Type',
key: 'type',
type: 'dropdown' as const,
required: false,
variables: true,
description: '',
options: [
{ label: 'Problem', value: 'problem' },
{ label: 'Incident', value: 'incident' },
{ label: 'Question', value: 'question' },
{ label: 'Task', value: 'task' },
],
},
{
label: 'Priority',
key: 'priority',
type: 'dropdown' as const,
required: false,
variables: true,
description: '',
options: [
{ label: 'Urgent', value: 'urgent' },
{ label: 'High', value: 'high' },
{ label: 'Normal', value: 'normal' },
{ label: 'Low', value: 'low' },
],
},
{
label: 'Submitter',
key: 'submitterId',
type: 'dropdown' as const,
required: false,
variables: true,
description: '',
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listUsers',
},
{
name: 'parameters.includeAdmins',
value: 'false',
},
],
},
},
];

View File

@@ -0,0 +1,57 @@
import defineAction from '../../../../helpers/define-action';
import { fields } from './fields';
import isEmpty from 'lodash/isEmpty';
import omitBy from 'lodash/omitBy';
export default defineAction({
name: 'Update ticket',
key: 'updateTicket',
description: 'Modify the status of an existing ticket or append comments.',
arguments: fields,
async run($) {
const {
ticketId,
subject,
assigneeId,
groupId,
status,
comment,
publicOrNot,
type,
priority,
submitterId,
} = $.step.parameters;
const tags = $.step.parameters.tags as string;
const formattedTags = tags.split(',');
const payload = {
subject,
assignee_id: assigneeId,
group_id: groupId,
status,
comment: {
body: comment,
public: publicOrNot,
},
tags: formattedTags,
type,
priority,
submitter_id: submitterId,
};
const fieldsToRemoveIfEmpty = ['group_id', 'status', 'type', 'priority'];
const filteredPayload = omitBy(
payload,
(value, key) => fieldsToRemoveIfEmpty.includes(key) && isEmpty(value)
);
const response = await $.http.put(`/api/v2/tickets/${ticketId}`, {
ticket: filteredPayload,
});
$.setActionItem({ raw: response.data });
},
});

View File

@@ -1,13 +1,20 @@
import listUsers from './list-users';
import listBrands from './list-brands';
import listFirstPageOfTickets from './list-first-page-of-tickets';
import listGroups from './list-groups';
import listOrganizations from './list-organizations';
import listSharingAgreements from './list-sharing-agreements';
import listTicketForms from './list-ticket-forms';
import listViews from './list-views';
export default [
listUsers,
listBrands,
listFirstPageOfTickets,
listGroups,
listOrganizations,
listSharingAgreements,
listFirstPageOfTickets,
listTicketForms,
listViews,
];

View File

@@ -0,0 +1,33 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List first page of tickets',
key: 'listFirstPageOfTickets',
async run($: IGlobalVariable) {
const tickets: {
data: IJSONObject[];
} = {
data: [],
};
const params = {
'page[size]': 100,
sort: '-id',
};
const response = await $.http.get('/api/v2/tickets', { params });
const allTickets = response.data.tickets;
if (allTickets?.length) {
for (const ticket of allTickets) {
tickets.data.push({
value: ticket.id,
name: ticket.subject,
});
}
}
return tickets;
},
};

View File

@@ -21,7 +21,7 @@ export default {
const response = await $.http.get('/api/v2/groups', { params });
const allGroups = response?.data?.groups;
hasMore = response?.data?.meta?.has_more;
params['page[after]'] = response.data.links?.after_cursor;
params['page[after]'] = response.data.meta?.after_cursor;
if (allGroups?.length) {
for (const group of allGroups) {

View File

@@ -0,0 +1,38 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List organizations',
key: 'listOrganizations',
async run($: IGlobalVariable) {
const organizations: {
data: IJSONObject[];
} = {
data: [],
};
let hasMore;
const params = {
'page[size]': 100,
'page[after]': undefined as unknown as string,
};
do {
const response = await $.http.get('/api/v2/organizations', { params });
const allOrganizations = response?.data?.organizations;
hasMore = response?.data?.meta?.has_more;
params['page[after]'] = response.data.meta?.after_cursor;
if (allOrganizations?.length) {
for (const organization of allOrganizations) {
organizations.data.push({
value: organization.id,
name: organization.name,
});
}
}
} while (hasMore);
return organizations;
},
};

View File

@@ -25,7 +25,7 @@ export default {
const response = await $.http.get('/api/v2/users', { params });
const allUsers = response?.data?.users;
hasMore = response?.data?.meta?.has_more;
params['page[after]'] = response.data.links?.after_cursor;
params['page[after]'] = response.data.meta?.after_cursor;
if (allUsers?.length) {
for (const user of allUsers) {

View File

@@ -0,0 +1,38 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
export default {
name: 'List views',
key: 'listViews',
async run($: IGlobalVariable) {
const views: {
data: IJSONObject[];
} = {
data: [],
};
let hasMore;
const params = {
'page[size]': 100,
'page[after]': undefined as unknown as string,
};
do {
const response = await $.http.get('/api/v2/views', { params });
const allViews = response?.data?.views;
hasMore = response?.data?.meta?.has_more;
params['page[after]'] = response.data.meta?.after_cursor;
if (allViews?.length) {
for (const view of allViews) {
views.data.push({
value: view.id,
name: view.title,
});
}
}
} while (hasMore);
return views;
},
};

View File

@@ -1,6 +1,7 @@
import defineApp from '../../helpers/define-app';
import addAuthHeader from './common/add-auth-headers';
import auth from './auth';
import triggers from './triggers';
import actions from './actions';
import dynamicData from './dynamic-data';
@@ -15,6 +16,7 @@ export default defineApp({
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
triggers,
actions,
dynamicData,
});

View File

@@ -0,0 +1,4 @@
import newTickets from './new-tickets';
import newUsers from './new-users';
export default [newTickets, newUsers];

View File

@@ -0,0 +1,59 @@
import defineTrigger from '../../../../helpers/define-trigger';
export default defineTrigger({
name: 'New tickets',
key: 'newTickets',
pollInterval: 15,
description: 'Triggers when a new ticket is created in a specific view.',
arguments: [
{
label: 'View',
key: 'viewId',
type: 'dropdown' as const,
required: true,
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listViews',
},
],
},
},
],
async run($) {
const viewId = $.step.parameters.viewId;
const params = {
'page[size]': 100,
'page[after]': undefined as unknown as string,
sort_by: 'nice_id',
sort_order: 'desc',
};
let hasMore;
do {
const response = await $.http.get(`/api/v2/views/${viewId}/tickets`, {
params,
});
const allTickets = response?.data?.tickets;
hasMore = response?.data?.meta?.has_more;
params['page[after]'] = response.data.meta?.after_cursor;
if (allTickets?.length) {
for (const ticket of allTickets) {
$.pushTriggerItem({
raw: ticket,
meta: {
internalId: ticket.id.toString(),
},
});
}
}
} while (hasMore);
},
});

View File

@@ -0,0 +1,83 @@
import Crypto from 'crypto';
import defineTrigger from '../../../../helpers/define-trigger';
export default defineTrigger({
name: 'New users',
key: 'newUsers',
type: 'webhook',
description: 'Triggers upon the creation of a new user.',
async run($) {
const dataItem = {
raw: $.request.body,
meta: {
internalId: Crypto.randomUUID(),
},
};
$.pushTriggerItem(dataItem);
},
async testRun($) {
const params = {
query: 'type:user',
sort_by: 'created_at',
sort_order: 'desc',
};
const response = await $.http.get('/api/v2/search', { params });
const lastUser = response.data.results[0];
const computedWebhookEvent = {
id: Crypto.randomUUID(),
time: lastUser.created_at,
type: 'zen:event-type:user.created',
event: {},
detail: {
id: lastUser.id,
role: lastUser.role,
email: lastUser.email,
created_at: lastUser.created_at,
updated_at: lastUser.updated_at,
external_id: lastUser.external_id,
organization_id: lastUser.organization_id,
default_group_id: lastUser.default_group_id,
},
subject: `zen:user:${lastUser.id}`,
account_id: '',
zendesk_event_version: '2022-11-06',
};
const dataItem = {
raw: computedWebhookEvent,
meta: {
internalId: computedWebhookEvent.id,
},
};
$.pushTriggerItem(dataItem);
},
async registerHook($) {
const payload = {
webhook: {
name: `Flow ID: ${$.flow.id}`,
status: 'active',
subscriptions: ['zen:event-type:user.created'],
endpoint: $.webhookUrl,
http_method: 'POST',
request_format: 'json',
},
};
const response = await $.http.post('/api/v2/webhooks', payload);
const id = response.data.webhook.id;
await $.flow.setRemoteWebhookId(id);
},
async unregisterHook($) {
await $.http.delete(`/api/v2/webhooks/${$.flow.remoteWebhookId}`);
},
});

View File

@@ -49,6 +49,7 @@ type AppConfig = {
smtpPassword: string;
fromEmail: string;
isCloud: boolean;
isMation: boolean;
isSelfHosted: boolean;
paddleVendorId: number;
paddleVendorAuthCode: string;
@@ -127,6 +128,7 @@ const appConfig: AppConfig = {
fromEmail: process.env.FROM_EMAIL,
isCloud: process.env.AUTOMATISCH_CLOUD === 'true',
isSelfHosted: process.env.AUTOMATISCH_CLOUD !== 'true',
isMation: process.env.MATION === 'true',
paddleVendorId: Number(process.env.PADDLE_VENDOR_ID),
paddleVendorAuthCode: process.env.PADDLE_VENDOR_AUTH_CODE,
paddlePublicKey: process.env.PADDLE_PUBLIC_KEY,

View File

@@ -9,6 +9,7 @@ describe('graphQL getAutomatischInfo query', () => {
query {
getAutomatischInfo {
isCloud
isMation
license {
id
name
@@ -24,6 +25,7 @@ describe('graphQL getAutomatischInfo query', () => {
jest.spyOn(license, 'getLicense').mockResolvedValue(false);
jest.replaceProperty(appConfig, 'isCloud', false);
jest.replaceProperty(appConfig, 'isMation', false);
});
it('should return empty license data', async () => {
@@ -36,6 +38,7 @@ describe('graphQL getAutomatischInfo query', () => {
data: {
getAutomatischInfo: {
isCloud: false,
isMation: false,
license: {
id: null,
name: null,
@@ -77,6 +80,7 @@ describe('graphQL getAutomatischInfo query', () => {
data: {
getAutomatischInfo: {
isCloud: true,
isMation: false,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
@@ -105,6 +109,69 @@ describe('graphQL getAutomatischInfo query', () => {
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isCloud: false,
isMation: false,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
name: 'Test License',
verified: true,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with mation flag enabled', () => {
beforeEach(async () => {
jest.replaceProperty(appConfig, 'isCloud', false);
jest.replaceProperty(appConfig, 'isMation', true);
});
it('should return all license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isCloud: false,
isMation: true,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
name: 'Test License',
verified: true,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with mation flag disabled', () => {
beforeEach(async () => {
jest.replaceProperty(appConfig, 'isCloud', false);
jest.replaceProperty(appConfig, 'isMation', false);
});
it('should return all license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isMation: false,
isCloud: false,
license: {
expireAt: '2025-08-09T10:56:54.144Z',

View File

@@ -13,6 +13,7 @@ const getAutomatischInfo = async () => {
return {
isCloud: appConfig.isCloud,
isMation: appConfig.isMation,
license: computedLicense,
};
};

View File

@@ -0,0 +1,10 @@
import appConfig from '../../config/app';
const getUseJsFile = async () => {
return {
canInvoke: true,
appConfig,
};
};
export default getUseJsFile;

View File

@@ -6,6 +6,7 @@ import getApps from './queries/get-apps';
import getAutomatischInfo from './queries/get-automatisch-info';
import getBillingAndUsage from './queries/get-billing-and-usage.ee';
import getConfig from './queries/get-config.ee';
import getUseJsFile from './queries/get-use-js-file.js';
import getConnectedApps from './queries/get-connected-apps';
import getCurrentUser from './queries/get-current-user';
import getDynamicData from './queries/get-dynamic-data';
@@ -68,6 +69,7 @@ const queryResolvers = {
healthcheck,
listSamlAuthProviders,
testConnection,
getUseJsFile,
};
export default queryResolvers;

View File

@@ -11,6 +11,7 @@ type Query {
getConnectedApps(name: String): [App]
testConnection(id: String!): Connection
getFlow(id: String!): Flow
getUseJsFile: JSONObject
getFlows(
limit: Int!
offset: Int!
@@ -646,6 +647,7 @@ type AppHealth {
type GetAutomatischInfo {
isCloud: Boolean
isMation: Boolean
license: License
}

View File

@@ -1,5 +1,5 @@
import { IHttpClientParams } from '@automatisch/types';
import { AxiosRequestConfig } from 'axios';
import { InternalAxiosRequestConfig } from 'axios';
import { URL } from 'node:url';
export { AxiosInstance as IHttpClient } from 'axios';
@@ -7,8 +7,8 @@ import HttpError from '../../errors/http';
import axios from '../axios-with-proxy';
const removeBaseUrlForAbsoluteUrls = (
requestConfig: AxiosRequestConfig
): AxiosRequestConfig => {
requestConfig: InternalAxiosRequestConfig
): InternalAxiosRequestConfig => {
try {
const url = new URL(requestConfig.url);
requestConfig.baseURL = url.origin;
@@ -30,12 +30,21 @@ export default function createHttpClient({
});
instance.interceptors.request.use(
(requestConfig: AxiosRequestConfig): AxiosRequestConfig => {
(requestConfig: InternalAxiosRequestConfig): InternalAxiosRequestConfig => {
const newRequestConfig = removeBaseUrlForAbsoluteUrls(requestConfig);
return beforeRequest.reduce((newConfig, beforeRequestFunc) => {
const result = beforeRequest.reduce((newConfig, beforeRequestFunc) => {
return beforeRequestFunc($, newConfig);
}, newRequestConfig);
/**
* 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 as InternalAxiosRequestConfig;
}
);

View File

@@ -2,11 +2,12 @@
"compilerOptions": {
"baseUrl": ".",
"declaration": true,
"allowJs": true,
"esModuleInterop": true,
"lib": ["es2021"],
"module": "commonjs",
"moduleResolution": "node",
"noImplicitAny": true,
"noImplicitAny": false,
"outDir": "dist",
"paths": {
"*": ["../../node_modules/*", "node_modules/*", "src/types/*"]

View File

@@ -289,11 +289,24 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/pushover/connection' },
],
},
{
text: 'Reddit',
collapsible: true,
collapsed: true,
items: [
{ text: 'Triggers', link: '/apps/reddit/triggers' },
{ text: 'Actions', link: '/apps/reddit/actions' },
{ text: 'Connection', link: '/apps/reddit/connection' },
],
},
{
text: 'Remove.bg',
collapsible: true,
collapsed: true,
items: [{ text: 'Connection', link: '/apps/removebg/connection' }],
items: [
{ text: 'Actions', link: '/apps/removebg/actions' },
{ text: 'Connection', link: '/apps/removebg/connection' }
],
},
{
text: 'RSS',
@@ -453,6 +466,15 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/wordpress/connection' },
],
},
{
text: 'Xero',
collapsible: true,
collapsed: true,
items: [
{ text: 'Triggers', link: '/apps/xero/triggers' },
{ text: 'Connection', link: '/apps/xero/connection' },
],
},
{
text: 'Youtube',
collapsible: true,

View File

@@ -3,6 +3,8 @@ favicon: /favicons/discord.svg
items:
- name: Send a message to channel
desc: Sends a message to a specific channel you specify.
- name: Create a scheduled event
desc: Creates a scheduled event.
---
<script setup>

View File

@@ -3,6 +3,8 @@ favicon: /favicons/notion.svg
items:
- name: New database items
desc: Triggers when a new database item is created.
- name: Updated database items
desc: Triggers when there is an update to an item in a chosen database.
---
<script setup>

View File

@@ -0,0 +1,12 @@
---
favicon: /favicons/reddit.svg
items:
- name: Create link post
desc: Create a new link post within a subreddit.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -0,0 +1,15 @@
# Reddit
:::info
This page explains the steps you need to follow to set up the Reddit
connection in Automatisch. If any of the steps are outdated, please let us know!
:::
1. Go to [Reddit apps page](https://www.reddit.com/prefs/apps).
2. Click on the **"are you a developer? create an app..."** button in order to create an app.
3. Fill the **Name** field and choose **web app**.
4. Copy **OAuth Redirect URL** from Automatisch to **redirect uri** field.
5. Click on the **create app** button.
6. Copy the client id below **web app** text to the `Client ID` field on Automatisch.
7. Copy the **secret** value to the `Client Secret` field on Automatisch.
8. Start using Reddit integration with Automatisch!

View File

@@ -0,0 +1,12 @@
---
favicon: /favicons/reddit.svg
items:
- name: New posts matching search
desc: Triggers when a search string matches a new post.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -0,0 +1,12 @@
---
favicon: /favicons/removebg.svg
items:
- name: Remove Image Background
desc: Remove backgrounds 100% automatically in 5 seconds with one click.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -5,6 +5,8 @@ items:
desc: Creates an attachment of a specified object by given parent ID.
- name: Find record
desc: Finds a record of a specified object by a field and value.
- name: Execute query
desc: Executes a SOQL query in Salesforce.
---
<script setup>

View File

@@ -12,7 +12,9 @@ connection in Automatisch. If any of the steps are outdated, please let us know!
1. Enter necessary information in the form.
1. Check **Enable OAuth Settings** checkbox.
1. Copy **OAuth Redirect URL** from Automatisch and paste it to the **Callback URL** field.
1. Add any scopes you plan to use in the **Selected OAuth Scopes** section.
1. Add any scopes you plan to use in the **Selected OAuth Scopes** section. We suggest `full` and `refresh_token, offline_access` scopes.
1. Uncheck "Require Proof Key for Code Exchange (PKCE) Extension for Supported Authorization Flows" checkbox.
1. Check "Enable Authorization Code and Credentials Flow" checkbox
1. Click on the **Save** button at the bottom of the page.
1. Acknowledge the information and click on the **Continue** button.
1. In the **API (Enable OAuth Settings)** section, click the **Manager Consumer Details** button.

View File

@@ -3,6 +3,8 @@ favicon: /favicons/xero.svg
items:
- name: New bank transactions
desc: Triggers when a new bank transaction occurs.
- name: New payments
desc: Triggers when a new payment is received.
---
<script setup>

View File

@@ -3,6 +3,16 @@ favicon: /favicons/zendesk.svg
items:
- name: Create ticket
desc: Creates a new ticket.
- name: Create user
desc: Creates a new user.
- name: Delete ticket
desc: Deletes an existing ticket.
- name: Delete user
desc: Deletes an existing user.
- name: Find ticket
desc: Finds an existing ticket.
- name: Update ticket
desc: Modify the status of an existing ticket or append comments.
---
<script setup>

View File

@@ -0,0 +1,14 @@
---
favicon: /favicons/zendesk.svg
items:
- name: New tickets
desc: Triggers when a new ticket is created in a specific view.
- name: New users
desc: Triggers upon the creation of a new user.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -30,7 +30,8 @@ The following integrations are currently supported by Automatisch.
- [Placetel](/apps/placetel/triggers)
- [PostgreSQL](/apps/postgresql/actions)
- [Pushover](/apps/pushover/actions)
- [Remove.bg](/apps/removebg/connection)
- [Reddit](/apps/reddit/triggers)
- [Remove.bg](/apps/removebg/actions)
- [RSS](/apps/rss/triggers)
- [Salesforce](/apps/salesforce/triggers)
- [Scheduler](/apps/scheduler/triggers)
@@ -48,5 +49,6 @@ The following integrations are currently supported by Automatisch.
- [Typeform](/apps/typeform/triggers)
- [Webhooks](/apps/webhooks/triggers)
- [WordPress](/apps/wordpress/triggers)
- [Xero](/apps/xero/triggers)
- [Youtube](/apps/youtube/triggers)
- [Zendesk](/apps/zendesk/actions)

View File

@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="_1O4jTk-dZ-VIxsCuYB6OR8 " width="40" height="48" ><g><circle fill="#FF4500" cx="10" cy="10" r="10"></circle><path fill="#FFFFFF" d="M16.67,10A1.46,1.46,0,0,0,14.2,9a7.12,7.12,0,0,0-3.85-1.23L11,4.65,13.14,5.1a1,1,0,1,0,.13-0.61L10.82,4a0.31,0.31,0,0,0-.37.24L9.71,7.71a7.14,7.14,0,0,0-3.9,1.23A1.46,1.46,0,1,0,4.2,11.33a2.87,2.87,0,0,0,0,.44c0,2.24,2.61,4.06,5.83,4.06s5.83-1.82,5.83-4.06a2.87,2.87,0,0,0,0-.44A1.46,1.46,0,0,0,16.67,10Zm-10,1a1,1,0,1,1,1,1A1,1,0,0,1,6.67,11Zm5.81,2.75a3.84,3.84,0,0,1-2.47.77,3.84,3.84,0,0,1-2.47-.77,0.27,0.27,0,0,1,.38-0.38A3.27,3.27,0,0,0,10,14a3.28,3.28,0,0,0,2.09-.61A0.27,0.27,0,1,1,12.48,13.79Zm-0.18-1.71a1,1,0,1,1,1-1A1,1,0,0,1,12.29,12.08Z"></path></g></svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -2,7 +2,7 @@ const { AuthenticatedPage } = require('../authenticated-page');
const { RoleConditionsModal } = require('./role-conditions-modal');
export class AdminCreateRolePage extends AuthenticatedPage {
screenshotPath = '/admin/create-role'
screenshotPath = '/admin/create-role';
/**
* @param {import('@playwright/test').Page} page
@@ -15,6 +15,7 @@ export class AdminCreateRolePage extends AuthenticatedPage {
this.connectionRow = page.getByTestId('Connection-permission-row');
this.executionRow = page.getByTestId('Execution-permission-row');
this.flowRow = page.getByTestId('Flow-permission-row');
this.pageTitle = page.getByTestId('create-role-title');
}
/**
@@ -30,12 +31,12 @@ export class AdminCreateRolePage extends AuthenticatedPage {
for (let subject of subjects) {
const row = this.getSubjectRow(subject);
const actionInputs = await this.getRowInputs(row);
Object.keys(actionInputs).forEach(action => {
Object.keys(actionInputs).forEach((action) => {
permissionConfigs.push({
action,
locator: actionInputs[action],
subject,
row
row,
});
});
}
@@ -49,11 +50,11 @@ export class AdminCreateRolePage extends AuthenticatedPage {
* )} subject
*/
getSubjectRow(subject) {
const k = `${subject.toLowerCase()}Row`
const k = `${subject.toLowerCase()}Row`;
if (this[k]) {
return this[k]
return this[k];
} else {
throw 'Unknown row'
throw 'Unknown row';
}
}
@@ -63,14 +64,14 @@ export class AdminCreateRolePage extends AuthenticatedPage {
async getRowInputs(row) {
const inputs = {
// settingsButton: row.getByTestId('permission-settings-button')
}
};
for (let input of ['create', 'read', 'update', 'delete', 'publish']) {
const testId = `${input}-checkbox`
if (await row.getByTestId(testId).count() > 0) {
const testId = `${input}-checkbox`;
if ((await row.getByTestId(testId).count()) > 0) {
inputs[input] = row.getByTestId(testId).locator('input');
}
}
return inputs
return inputs;
}
/**
@@ -100,7 +101,7 @@ export class AdminCreateRolePage extends AuthenticatedPage {
}
}
} else {
throw new Error(`${subject} does not have action ${action}`)
throw new Error(`${subject} does not have action ${action}`);
}
}
}

View File

@@ -14,6 +14,7 @@ export class AdminCreateUserPage extends AuthenticatedPage {
this.passwordInput = page.getByTestId('password-input');
this.roleInput = page.getByTestId('role.id-autocomplete');
this.createButton = page.getByTestId('create-button');
this.pageTitle = page.getByTestId('create-user-title');
}
seed(seed) {
@@ -24,7 +25,7 @@ export class AdminCreateUserPage extends AuthenticatedPage {
return {
fullName: faker.person.fullName(),
email: faker.internet.email().toLowerCase(),
password: faker.internet.password()
}
password: faker.internet.password(),
};
}
}

View File

@@ -5,5 +5,6 @@ export class AdminEditRolePage extends AdminCreateRolePage {
super(page);
delete this.createButton;
this.updateButton = page.getByTestId('update-button');
this.pageTitle = page.getByTestId('edit-role-title');
}
}

View File

@@ -15,6 +15,7 @@ export class AdminEditUserPage extends AuthenticatedPage {
this.emailInput = page.getByTestId('email-input');
this.roleInput = page.getByTestId('role.id-autocomplete');
this.updateButton = page.getByTestId('update-button');
this.pageTitle = page.getByTestId('edit-user-title');
}
generateUser () {

View File

@@ -14,6 +14,7 @@ export class AdminRolesPage extends AuthenticatedPage {
this.deleteRoleModal = new DeleteRoleModal(page);
this.roleRow = page.getByTestId('role-row');
this.rolesLoader = page.getByTestId('roles-list-loader');
this.pageTitle = page.getByTestId('roles-page-title');
}
/**
@@ -28,8 +29,9 @@ export class AdminRolesPage extends AuthenticatedPage {
await this.drawerMenuButton.click();
}
await this.roleDrawerLink.click();
await this.isMounted();
await this.rolesLoader.waitFor({
state: 'detached',
state: 'detached'
});
}

View File

@@ -20,14 +20,16 @@ export class AdminUsersPage extends AuthenticatedPage {
this.nextPageButton = page.getByTestId('next-page-button');
this.lastPageButton = page.getByTestId('last-page-button');
this.usersLoader = page.getByTestId('users-list-loader');
this.pageTitle = page.getByTestId('users-page-title');
}
async navigateTo() {
await this.profileMenuButton.click();
await this.adminMenuItem.click();
await this.isMounted();
if (await this.usersLoader.isVisible()) {
await this.usersLoader.waitFor({
state: 'detached'
state: 'detached',
});
}
}
@@ -38,8 +40,8 @@ export class AdminUsersPage extends AuthenticatedPage {
async getUserRowByEmail(email) {
return this.userRow.filter({
has: this.page.getByTestId('user-email').filter({
hasText: email
})
hasText: email,
}),
});
}
@@ -50,8 +52,8 @@ export class AdminUsersPage extends AuthenticatedPage {
return {
fullName: await row.getByTestId('user-full-name').textContent(),
email: await row.getByTestId('user-email').textContent(),
role: await row.getByTestId('user-role').textContent()
}
role: await row.getByTestId('user-role').textContent(),
};
}
/**
@@ -76,7 +78,7 @@ export class AdminUsersPage extends AuthenticatedPage {
async findUserPageWithEmail(email) {
if (await this.usersLoader.isVisible()) {
await this.usersLoader.waitFor({
state: 'detached'
state: 'detached',
});
}
// start at the first page
@@ -88,10 +90,11 @@ export class AdminUsersPage extends AuthenticatedPage {
while (true) {
if (await this.usersLoader.isVisible()) {
await this.usersLoader.waitFor({
state: 'detached'
state: 'detached',
});
}
const rowLocator = await this.getUserRowByEmail(email);
console.log('rowLocator.count', email, await rowLocator.count());
if ((await rowLocator.count()) === 1) {
return rowLocator;
}

View File

@@ -12,7 +12,9 @@ export class AuthenticatedPage extends BasePage {
this.profileMenuButton = this.page.getByTestId('profile-menu-button');
this.adminMenuItem = this.page.getByRole('menuitem', { name: 'Admin' });
this.userInterfaceDrawerItem = this.page.getByTestId('user-interface-drawer-link');
this.userInterfaceDrawerItem = this.page.getByTestId(
'user-interface-drawer-link'
);
this.appBar = this.page.getByTestId('app-bar');
this.drawerMenuButton = this.page.getByTestId('drawer-menu-button');
this.goToDashboardButton = this.page.getByTestId('go-back-drawer-link');

View File

@@ -15,6 +15,7 @@ export class BasePage {
constructor(page) {
this.page = page;
this.snackbar = page.locator('*[data-test^="snackbar"]');
this.pageTitle = this.page.getByTestId('page-title');
}
/**
@@ -35,18 +36,18 @@ export class BasePage {
const snack = this.page.getByTestId(testId);
return {
variant: await snack.getAttribute('data-snackbar-variant'),
text: await snack.evaluate(node => node.innerText),
dataset: await snack.evaluate(node => {
text: await snack.evaluate((node) => node.innerText),
dataset: await snack.evaluate((node) => {
function getChildren(n) {
return [n].concat(
...Array.from(n.children).map(c => getChildren(c))
...Array.from(n.children).map((c) => getChildren(c))
);
}
const datasets = getChildren(node).map(
n => Object.assign({}, n.dataset)
const datasets = getChildren(node).map((n) =>
Object.assign({}, n.dataset)
);
return Object.assign({}, ...datasets);
})
}),
};
}
@@ -78,4 +79,8 @@ export class BasePage {
return await this.page.screenshot({ path: computedPath, ...restOptions });
}
async isMounted() {
await this.pageTitle.waitFor({ state: 'attached' });
}
}

View File

@@ -25,6 +25,7 @@ export class LoginPage extends BasePage {
this.emailTextField = this.page.getByTestId('email-text-field');
this.passwordTextField = this.page.getByTestId('password-text-field');
this.loginButton = this.page.getByTestId('login-button');
this.pageTitle = this.page.getByTestId('login-form-title');
}
async open() {

View File

@@ -22,6 +22,7 @@ test.describe('Role management page', () => {
await test.step('Create a new role', async () => {
await adminRolesPage.navigateTo();
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.isMounted();
await adminCreateRolePage.nameInput.fill('Create Edit Test');
await adminCreateRolePage.descriptionInput.fill('Test description');
await adminCreateRolePage.createButton.click();
@@ -54,6 +55,7 @@ test.describe('Role management page', () => {
await test.step('Edit the role', async () => {
await adminRolesPage.clickEditRole(roleRow);
await adminEditRolePage.isMounted();
await adminEditRolePage.nameInput.fill('Create Update Test');
await adminEditRolePage.descriptionInput.fill('Update test description');
await adminEditRolePage.updateButton.click();
@@ -70,6 +72,7 @@ test.describe('Role management page', () => {
roleRow = await test.step(
'Make sure changes reflected on roles page',
async () => {
await adminRolesPage.isMounted();
const roleRow = await adminRolesPage.getRoleRowByName(
'Create Update Test'
);
@@ -110,6 +113,8 @@ test.describe('Role management page', () => {
// This test breaks right now
test.skip('Make sure create/edit role page is scrollable', async ({
adminRolesPage,
adminEditRolePage,
adminCreateRolePage,
page,
}) => {
const initViewportSize = page.viewportSize;
@@ -121,6 +126,7 @@ test.describe('Role management page', () => {
await test.step('Ensure create role page is scrollable', async () => {
await adminRolesPage.navigateTo(true);
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.isMounted();
const initScrollTop = await page.evaluate(() => {
return document.documentElement.scrollTop;
@@ -138,6 +144,7 @@ test.describe('Role management page', () => {
await adminRolesPage.navigateTo(true);
const adminRow = await adminRolesPage.getRoleRowByName('Admin');
await adminRolesPage.clickEditRole(adminRow);
await adminEditRolePage.isMounted();
const initScrollTop = await page.evaluate(() => {
return document.documentElement.scrollTop;
@@ -166,6 +173,7 @@ test.describe('Role management page', () => {
await adminRolesPage.navigateTo();
await test.step('Create a new role', async () => {
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.isMounted();
await adminCreateRolePage.nameInput.fill('Delete Role');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
@@ -268,6 +276,7 @@ test.describe('Role management page', () => {
await adminRolesPage.navigateTo();
await test.step('Create a new role', async () => {
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.isMounted();
await adminCreateRolePage.nameInput.fill('Cannot Delete Role');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
@@ -282,6 +291,7 @@ test.describe('Role management page', () => {
await test.step('Create a new user with this role', async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.isMounted();
await adminCreateUserPage.fullNameInput.fill('User Delete Role Test');
await adminCreateUserPage.emailInput.fill(
'user-delete-role-test@automatisch.io'
@@ -306,6 +316,7 @@ test.describe('Role management page', () => {
const row = await adminUsersPage.findUserPageWithEmail(
'user-delete-role-test@automatisch.io'
);
// await test.waitForTimeout(10000);
const modal = await adminUsersPage.clickDeleteUser(row);
await modal.deleteButton.click();
await adminUsersPage.snackbar.waitFor({
@@ -348,6 +359,7 @@ test('Accessibility of role management page', async ({
await test.step('Create the basic test role', async () => {
await adminRolesPage.navigateTo();
await adminRolesPage.createRoleButton.click();
await adminCreateRolePage.isMounted();
await adminCreateRolePage.nameInput.fill('Basic Test');
await adminCreateRolePage.createButton.click();
await adminCreateRolePage.snackbar.waitFor({
@@ -363,6 +375,7 @@ test('Accessibility of role management page', async ({
await test.step('Create a new user with the basic role', async () => {
await adminUsersPage.navigateTo();
await adminUsersPage.createUserButton.click();
await adminCreateUserPage.isMounted();
await adminCreateUserPage.fullNameInput.fill('Role Test');
await adminCreateUserPage.emailInput.fill('basic-role-test@automatisch.io');
await adminCreateUserPage.passwordInput.fill('sample');
@@ -378,7 +391,7 @@ test('Accessibility of role management page', async ({
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar();
await adminCreateUserPage.closeSnackbar();
});
await test.step('Logout and login to the basic role user', async () => {
@@ -386,6 +399,7 @@ test('Accessibility of role management page', async ({
await page.getByTestId('logout-item').click();
// await page.reload({ waitUntil: 'networkidle' });
const loginPage = new LoginPage(page);
// await loginPage.isMounted();
await loginPage.login('basic-role-test@automatisch.io', 'sample');
await expect(loginPage.loginButton).not.toBeVisible();
await expect(page).toHaveURL('/flows');
@@ -414,6 +428,7 @@ test('Accessibility of role management page', async ({
await page.getByTestId('profile-menu-button').click();
await page.getByTestId('logout-item').click();
const loginPage = new LoginPage(page);
await loginPage.isMounted();
await loginPage.login();
});
@@ -423,6 +438,7 @@ test('Accessibility of role management page', async ({
'basic-role-test@automatisch.io'
);
await adminUsersPage.clickEditUser(row);
await adminEditUserPage.isMounted();
await adminEditUserPage.roleInput.click();
await adminEditUserPage.page.getByRole('option', { name: 'Admin' }).click();
await adminEditUserPage.updateButton.click();

View File

@@ -462,6 +462,7 @@ type AppAuthClient = {
appConfigId: string;
authDefaults: string;
formattedAuthDefaults: IJSONObject;
active: boolean;
};
type Notification = {

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -25,12 +25,52 @@
-->
<title>Automatisch</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap"
rel="stylesheet"
rel="preload"
href="/fonts/Inter-Regular.ttf"
as="font"
crossorigin
type="font/ttf"
/>
<link
rel="preload"
href="/fonts/Inter-Medium.ttf"
as="font"
crossorigin
type="font/ttf"
/>
<link
rel="preload"
href="/fonts/Inter-Bold.ttf"
as="font"
crossorigin
type="font/ttf"
/>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Inter';
src: url('/fonts/Inter-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@@ -0,0 +1,104 @@
import React from 'react';
import type { IField } from '@automatisch/types';
import LoadingButton from '@mui/lab/LoadingButton';
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import CircularProgress from '@mui/material/CircularProgress';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import type { UseFormProps } from 'react-hook-form';
import type { ApolloError } from '@apollo/client';
import useFormatMessage from 'hooks/useFormatMessage';
import InputCreator from 'components/InputCreator';
import Switch from 'components/Switch';
import TextField from 'components/TextField';
import { Form } from './style';
type AdminApplicationAuthClientDialogProps = {
title: string;
authFields?: IField[];
defaultValues: UseFormProps['defaultValues'];
loading: boolean;
submitting: boolean;
disabled?: boolean;
error?: ApolloError;
submitHandler: SubmitHandler<FieldValues>;
onClose: () => void;
};
export default function AdminApplicationAuthClientDialog(
props: AdminApplicationAuthClientDialogProps
): React.ReactElement {
const {
error,
onClose,
title,
loading,
submitHandler,
authFields,
submitting,
defaultValues,
disabled = false,
} = props;
const formatMessage = useFormatMessage();
return (
<Dialog open={true} onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
{error && (
<Alert
severity="error"
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
>
{error.message}
</Alert>
)}
<DialogContent>
{loading ? (
<CircularProgress
data-test="search-for-app-loader"
sx={{ display: 'block', margin: '20px auto' }}
/>
) : (
<DialogContentText tabIndex={-1} component="div">
<Form
onSubmit={submitHandler}
defaultValues={defaultValues}
render={({ formState: { isDirty } }) => (
<>
<Switch
name="active"
label={formatMessage('authClient.inputActive')}
/>
<TextField
required={true}
name="name"
label={formatMessage('authClient.inputName')}
fullWidth
/>
{authFields?.map((field: IField) => (
<InputCreator key={field.key} schema={field} />
))}
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={submitting}
disabled={disabled || !isDirty}
>
{formatMessage('authClient.buttonSubmit')}
</LoadingButton>
</>
)}
></Form>
</DialogContentText>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,9 @@
import { styled } from '@mui/material/styles';
import BaseForm from 'components/Form';
export const Form = styled(BaseForm)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
paddingTop: theme.spacing(1),
}));

View File

@@ -0,0 +1,89 @@
import { Link } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack';
import Card from '@mui/material/Card';
import CardActionArea from '@mui/material/CardActionArea';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Button from '@mui/material/Button';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import NoResultFound from 'components/NoResultFound';
type AdminApplicationAuthClientsProps = {
appKey: string;
};
function AdminApplicationAuthClients(
props: AdminApplicationAuthClientsProps
): React.ReactElement {
const { appKey } = props;
const formatMessage = useFormatMessage();
const { appAuthClients, loading } = useAppAuthClients({ appKey });
if (loading)
return <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />;
if (!appAuthClients?.length) {
return (
<NoResultFound
to={URLS.ADMIN_APP_AUTH_CLIENTS_CREATE(appKey)}
text={formatMessage('adminAppsAuthClients.noAuthClients')}
/>
);
}
const sortedAuthClients = appAuthClients.slice().sort((a, b) => {
if (a.id < b.id) {
return -1;
}
if (a.id > b.id) {
return 1;
}
return 0;
});
return (
<div>
{sortedAuthClients.map((client) => (
<Card sx={{ mb: 1 }} key={client.id}>
<CardActionArea
component={Link}
to={URLS.ADMIN_APP_AUTH_CLIENT(appKey, client.id)}
>
<CardContent>
<Stack direction="row" justifyContent="space-between">
<Typography variant="h6" noWrap>
{client.name}
</Typography>
<Chip
size="small"
color={client?.active ? 'success' : 'info'}
variant={client?.active ? 'filled' : 'outlined'}
label={formatMessage(
client?.active
? 'adminAppsAuthClients.statusActive'
: 'adminAppsAuthClients.statusInactive'
)}
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
))}
<Stack justifyContent="flex-end" direction="row">
<Link to={URLS.ADMIN_APP_AUTH_CLIENTS_CREATE(appKey)}>
<Button variant="contained" sx={{ mt: 2 }} component="div">
{formatMessage('createAuthClient.button')}
</Button>
</Link>
</Stack>
</div>
);
}
export default AdminApplicationAuthClients;

View File

@@ -0,0 +1,112 @@
import React, { useCallback, useMemo } from 'react';
import type { IApp } from '@automatisch/types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { CREATE_APP_AUTH_CLIENT } from 'graphql/mutations/create-app-auth-client';
import useAppConfig from 'hooks/useAppConfig.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
type AdminApplicationCreateAuthClientProps = {
appKey: string;
application: IApp;
onClose: () => void;
};
export default function AdminApplicationCreateAuthClient(
props: AdminApplicationCreateAuthClientProps
): React.ReactElement {
const { appKey, application, onClose } = props;
const { auth } = application;
const formatMessage = useFormatMessage();
const { appConfig, loading: loadingAppConfig } = useAppConfig(appKey);
const [
createAppConfig,
{ loading: loadingCreateAppConfig, error: createAppConfigError },
] = useMutation(CREATE_APP_CONFIG, {
refetchQueries: ['GetAppConfig'],
context: { autoSnackbar: false },
});
const [
createAppAuthClient,
{ loading: loadingCreateAppAuthClient, error: createAppAuthClientError },
] = useMutation(CREATE_APP_AUTH_CLIENT, {
refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false },
});
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
let appConfigId = appConfig?.id;
if (!appConfigId) {
const { data: appConfigData } = await createAppConfig({
variables: {
input: {
key: appKey,
allowCustomConnection: false,
shared: false,
disabled: false,
},
},
});
appConfigId = appConfigData.createAppConfig.id;
}
const { name, active, ...formattedAuthDefaults } = values;
await createAppAuthClient({
variables: {
input: {
appConfigId,
name,
active,
formattedAuthDefaults,
},
},
});
onClose();
};
const getAuthFieldsDefaultValues = useCallback(() => {
if (!auth?.fields) {
return {};
}
const defaultValues: {
[key: string]: any;
} = {};
auth.fields.forEach((field) => {
if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value;
} else if (field.type === 'string') {
defaultValues[field.key] = '';
}
});
return defaultValues;
}, [auth?.fields]);
const defaultValues = useMemo(
() => ({
name: '',
active: false,
...getAuthFieldsDefaultValues(),
}),
[getAuthFieldsDefaultValues]
);
return (
<AdminApplicationAuthClientDialog
onClose={onClose}
error={createAppConfigError || createAppAuthClientError}
title={formatMessage('createAuthClient.title')}
loading={loadingAppConfig}
submitHandler={submitHandler}
authFields={auth?.fields}
submitting={loadingCreateAppConfig || loadingCreateAppAuthClient}
defaultValues={defaultValues}
/>
);
}

View File

@@ -0,0 +1,95 @@
import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import type { IApp } from '@automatisch/types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client';
import { UPDATE_APP_AUTH_CLIENT } from 'graphql/mutations/update-app-auth-client';
import useAppAuthClient from 'hooks/useAppAuthClient.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
type AdminApplicationUpdateAuthClientProps = {
application: IApp;
onClose: () => void;
};
export default function AdminApplicationUpdateAuthClient(
props: AdminApplicationUpdateAuthClientProps
): React.ReactElement {
const { application, onClose } = props;
const { auth } = application;
const authFields = auth?.fields?.map((field) => ({
...field,
required: false,
}));
const formatMessage = useFormatMessage();
const { clientId } = useParams();
const { appAuthClient, loading: loadingAuthClient } =
useAppAuthClient(clientId);
const [updateAppAuthClient, { loading: loadingUpdateAppAuthClient, error }] =
useMutation(UPDATE_APP_AUTH_CLIENT, {
refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false },
});
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
if (!appAuthClient) {
return;
}
const { name, active, ...formattedAuthDefaults } = values;
await updateAppAuthClient({
variables: {
input: {
id: appAuthClient.id,
name,
active,
formattedAuthDefaults,
},
},
});
onClose();
};
const getAuthFieldsDefaultValues = useCallback(() => {
if (!authFields) {
return {};
}
const defaultValues: {
[key: string]: any;
} = {};
authFields.forEach((field) => {
if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value;
} else if (field.type === 'string') {
defaultValues[field.key] = '';
}
});
return defaultValues;
}, [auth?.fields]);
const defaultValues = useMemo(
() => ({
name: appAuthClient?.name || '',
active: appAuthClient?.active || false,
...getAuthFieldsDefaultValues(),
}),
[appAuthClient, getAuthFieldsDefaultValues]
);
return (
<AdminApplicationAuthClientDialog
onClose={onClose}
error={error}
title={formatMessage('updateAuthClient.title')}
loading={loadingAuthClient}
submitHandler={submitHandler}
authFields={authFields}
submitting={loadingUpdateAppAuthClient}
defaultValues={defaultValues}
disabled={!appAuthClient}
/>
);
}

View File

@@ -17,7 +17,7 @@ type AppAuthClientsDialogProps = {
export default function AppAuthClientsDialog(props: AppAuthClientsDialogProps) {
const { appKey, onClientClick, onClose } = props;
const { appAuthClients } = useAppAuthClients(appKey);
const { appAuthClients } = useAppAuthClients({ appKey, active: true });
const formatMessage = useFormatMessage();
React.useEffect(

View File

@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
export const LogoImage = styled('img')(() => ({
maxWidth: 200,
maxHeight: 50,
maxHeight: 22,
width: '100%',
height: 'auto',
}));

View File

@@ -0,0 +1,22 @@
import Typography from '@mui/material/Typography';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
import MationLogo from 'components/MationLogo';
import useAutomatischInfo from 'hooks/useAutomatischInfo';
const DefaultLogo = () => {
const { isMation, loading } = useAutomatischInfo();
if (loading) return <React.Fragment />;
if (isMation) return <MationLogo />;
return (
<Typography variant="h6" component="h1" data-test="typography-logo" noWrap>
<FormattedMessage id="brandText" />
</Typography>
);
};
export default DefaultLogo;

View File

@@ -12,6 +12,7 @@ import * as URLS from 'config/urls';
import useVersion from 'hooks/useVersion';
import AppBar from 'components/AppBar';
import Drawer from 'components/Drawer';
import useAutomatischInfo from 'hooks/useAutomatischInfo';
type PublicLayoutProps = {
children: React.ReactNode;
@@ -38,7 +39,22 @@ const drawerLinks = [
},
];
const generateDrawerBottomLinks = ({ notificationBadgeContent = 0 }) => [
type GenerateDrawerBottomLinksOptions = {
isMation: boolean;
loading: boolean;
notificationBadgeContent: number;
};
const generateDrawerBottomLinks = ({
isMation,
loading,
notificationBadgeContent = 0,
}: GenerateDrawerBottomLinksOptions) => {
if (loading || isMation) {
return [];
}
return [
{
Icon: NotificationsIcon,
primary: 'settingsDrawer.notifications',
@@ -46,11 +62,13 @@ const generateDrawerBottomLinks = ({ notificationBadgeContent = 0 }) => [
badgeContent: notificationBadgeContent,
},
];
};
export default function PublicLayout({
children,
}: PublicLayoutProps): React.ReactElement {
const version = useVersion();
const { isMation, loading } = useAutomatischInfo();
const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'));
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
@@ -60,6 +78,8 @@ export default function PublicLayout({
const drawerBottomLinks = generateDrawerBottomLinks({
notificationBadgeContent: version.newVersionCount,
loading,
isMation,
});
return (

View File

@@ -11,15 +11,21 @@ import DeleteIcon from '@mui/icons-material/Delete';
type ListLoaderProps = {
rowsNumber: number;
columnsNumber: number;
'data-test'?: string;
};
const ListLoader = ({ rowsNumber, columnsNumber }: ListLoaderProps) => {
const ListLoader = ({
rowsNumber,
columnsNumber,
'data-test': dataTest,
}: ListLoaderProps) => {
return (
<>
{[...Array(rowsNumber)].map((row, index) => (
<TableRow
key={index}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
data-test={dataTest && index === 0 ? dataTest : undefined}
>
{[...Array(columnsNumber)].map((cell, index) => (
<TableCell key={index} scope="row">

View File

@@ -44,6 +44,7 @@ function LoginForm() {
<Typography
variant="h3"
align="center"
data-test="login-form-title"
sx={{
borderBottom: '1px solid',
borderColor: (theme) => theme.palette.text.disabled,
@@ -78,13 +79,15 @@ function LoginForm() {
sx={{ mb: 1 }}
/>
{isCloud && <Link
{isCloud && (
<Link
component={RouterLink}
to={URLS.FORGOT_PASSWORD}
underline="none"
>
{formatMessage('loginForm.forgotPasswordText')}
</Link>}
</Link>
)}
<LoadingButton
type="submit"
@@ -98,13 +101,15 @@ function LoginForm() {
{formatMessage('loginForm.submit')}
</LoadingButton>
{isCloud && <Typography variant="body1" align="center" mt={3}>
{isCloud && (
<Typography variant="body1" align="center" mt={3}>
{formatMessage('loginForm.noAccount')}
&nbsp;
<Link component={RouterLink} to={URLS.SIGNUP} underline="none">
{formatMessage('loginForm.signUp')}
</Link>
</Typography>}
</Typography>
)}
</Form>
</Paper>
);

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