Merge branch 'automatisch:main' into feature/signalwire-integration
This commit is contained in:
@@ -12,6 +12,8 @@ export async function createUser(
|
||||
const userParams = {
|
||||
email,
|
||||
password,
|
||||
fullName: 'Initial admin',
|
||||
role: 'admin',
|
||||
};
|
||||
|
||||
try {
|
||||
|
@@ -58,6 +58,7 @@
|
||||
"oauth-1.0a": "^2.2.6",
|
||||
"objection": "^3.0.0",
|
||||
"pg": "^8.7.1",
|
||||
"stripe": "^11.13.0",
|
||||
"winston": "^3.7.1"
|
||||
},
|
||||
"contributors": [
|
||||
|
79
packages/backend/src/apps/filter/actions/continue/index.ts
Normal file
79
packages/backend/src/apps/filter/actions/continue/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import defineAction from '../../../../helpers/define-action';
|
||||
|
||||
type TGroupItem = {
|
||||
key: string;
|
||||
operator: keyof TOperators;
|
||||
value: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type TGroup = Record<'and', TGroupItem[]>;
|
||||
|
||||
const isEqual = (a: string, b: string) => a === b;
|
||||
const isNotEqual = (a: string, b: string) => !isEqual(a, b)
|
||||
const isGreaterThan = (a: string, b: string) => Number(a) > Number(b);
|
||||
const isLessThan = (a: string, b: string) => Number(a) < Number(b);
|
||||
const isGreaterThanOrEqual = (a: string, b: string) => Number(a) >= Number(b);
|
||||
const isLessThanOrEqual = (a: string, b: string) => Number(a) <= Number(b);
|
||||
const contains = (a: string, b: string) => a.includes(b);
|
||||
const doesNotContain = (a: string, b: string) => !contains(a, b);
|
||||
|
||||
type TOperatorFunc = (a: string, b: string) => boolean;
|
||||
|
||||
type TOperators = {
|
||||
equal: TOperatorFunc;
|
||||
not_equal: TOperatorFunc;
|
||||
greater_than: TOperatorFunc;
|
||||
less_than: TOperatorFunc;
|
||||
greater_than_or_equal: TOperatorFunc;
|
||||
less_than_or_equal: TOperatorFunc;
|
||||
contains: TOperatorFunc;
|
||||
not_contains: TOperatorFunc;
|
||||
};
|
||||
|
||||
const operators: TOperators = {
|
||||
'equal': isEqual,
|
||||
'not_equal': isNotEqual,
|
||||
'greater_than': isGreaterThan,
|
||||
'less_than': isLessThan,
|
||||
'greater_than_or_equal': isGreaterThanOrEqual,
|
||||
'less_than_or_equal': isLessThanOrEqual,
|
||||
'contains': contains,
|
||||
'not_contains': doesNotContain,
|
||||
};
|
||||
|
||||
const operate = (operation: keyof TOperators, a: string, b: string) => {
|
||||
return operators[operation](a, b);
|
||||
};
|
||||
|
||||
export default defineAction({
|
||||
name: 'Continue if conditions match',
|
||||
key: 'continueIfMatches',
|
||||
description: 'Let the execution continue if the conditions match',
|
||||
arguments: [],
|
||||
|
||||
async run($) {
|
||||
const orGroups = $.step.parameters.or as TGroup[];
|
||||
|
||||
const matchingGroups = orGroups.reduce((groups, group) => {
|
||||
const matchingConditions = group.and
|
||||
.filter((condition) => operate(condition.operator, condition.key, condition.value));
|
||||
|
||||
if (matchingConditions.length) {
|
||||
return groups.concat([{ and: matchingConditions }]);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, []);
|
||||
|
||||
if (matchingGroups.length === 0) {
|
||||
$.execution.exit();
|
||||
}
|
||||
|
||||
$.setActionItem({
|
||||
raw: {
|
||||
or: matchingGroups,
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
3
packages/backend/src/apps/filter/actions/index.ts
Normal file
3
packages/backend/src/apps/filter/actions/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import continueIfMatches from './continue';
|
||||
|
||||
export default [continueIfMatches];
|
8
packages/backend/src/apps/filter/assets/favicon.svg
Normal file
8
packages/backend/src/apps/filter/assets/favicon.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="800px" height="800px" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Shape" fill="#000000" transform="translate(42.666667, 85.333333)">
|
||||
<path d="M3.55271368e-14,1.42108547e-14 L191.565013,234.666667 L192,234.666667 L192,384 L234.666667,384 L234.666667,234.666667 L426.666667,1.42108547e-14 L3.55271368e-14,1.42108547e-14 Z M214.448,192 L211.81248,192 L89.9076267,42.6666667 L336.630187,42.6666667 L214.448,192 Z">
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 628 B |
0
packages/backend/src/apps/filter/index.d.ts
vendored
Normal file
0
packages/backend/src/apps/filter/index.d.ts
vendored
Normal file
14
packages/backend/src/apps/filter/index.ts
Normal file
14
packages/backend/src/apps/filter/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import defineApp from '../../helpers/define-app';
|
||||
import actions from './actions';
|
||||
|
||||
export default defineApp({
|
||||
name: 'Filter',
|
||||
key: 'filter',
|
||||
iconUrl: '{BASE_URL}/apps/filter/assets/favicon.svg',
|
||||
authDocUrl: 'https://automatisch.io/docs/apps/filter/connection',
|
||||
supportsConnections: false,
|
||||
baseUrl: '',
|
||||
apiBaseUrl: '',
|
||||
primaryColor: '001F52',
|
||||
actions,
|
||||
});
|
@@ -1,8 +1,8 @@
|
||||
import { TBeforeRequest } from '@automatisch/types';
|
||||
|
||||
const addAuthHeader: TBeforeRequest = ($, requestConfig) => {
|
||||
if ($.auth.data.apiBaseUrl) {
|
||||
requestConfig.baseURL = $.auth.data.apiBaseUrl as string;
|
||||
if ($.auth.data.serverUrl) {
|
||||
requestConfig.baseURL = $.auth.data.serverUrl as string;
|
||||
}
|
||||
|
||||
if ($.auth.data?.username && $.auth.data?.password) {
|
||||
|
@@ -51,25 +51,20 @@ export default defineAction({
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Bot name',
|
||||
key: 'botName',
|
||||
type: 'string' as const,
|
||||
required: true,
|
||||
value: 'Automatisch',
|
||||
description:
|
||||
'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Bot icon',
|
||||
key: 'botIcon',
|
||||
type: 'string' as const,
|
||||
required: false,
|
||||
description:
|
||||
'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:',
|
||||
variables: true,
|
||||
additionalFields: {
|
||||
type: 'query',
|
||||
name: 'getDynamicFields',
|
||||
arguments: [
|
||||
{
|
||||
name: 'key',
|
||||
value: 'listFieldsAfterSendAsBot',
|
||||
},
|
||||
{
|
||||
name: 'parameters.sendAsBot',
|
||||
value: '{parameters.sendAsBot}',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
|
3
packages/backend/src/apps/slack/dynamic-fields/index.ts
Normal file
3
packages/backend/src/apps/slack/dynamic-fields/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import listFieldsAfterSendAsBot from './send-as-bot';
|
||||
|
||||
export default [listFieldsAfterSendAsBot];
|
@@ -0,0 +1,32 @@
|
||||
import { IGlobalVariable } from '@automatisch/types';
|
||||
|
||||
export default {
|
||||
name: 'List fields after send as bot',
|
||||
key: 'listFieldsAfterSendAsBot',
|
||||
|
||||
async run($: IGlobalVariable) {
|
||||
if ($.step.parameters.sendAsBot) {
|
||||
return [
|
||||
{
|
||||
label: 'Bot name',
|
||||
key: 'botName',
|
||||
type: 'string' as const,
|
||||
required: true,
|
||||
value: 'Automatisch',
|
||||
description:
|
||||
'Specify the bot name which appears as a bold username above the message inside Slack. Defaults to Automatisch.',
|
||||
variables: true,
|
||||
},
|
||||
{
|
||||
label: 'Bot icon',
|
||||
key: 'botIcon',
|
||||
type: 'string' as const,
|
||||
required: false,
|
||||
description:
|
||||
'Either an image url or an emoji available to your team (surrounded by :). For example, https://example.com/icon_256.png or :robot_face:',
|
||||
variables: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
@@ -3,6 +3,7 @@ import addAuthHeader from './common/add-auth-header';
|
||||
import actions from './actions';
|
||||
import auth from './auth';
|
||||
import dynamicData from './dynamic-data';
|
||||
import dynamicFields from './dynamic-fields';
|
||||
|
||||
export default defineApp({
|
||||
name: 'Slack',
|
||||
@@ -17,4 +18,5 @@ export default defineApp({
|
||||
auth,
|
||||
actions,
|
||||
dynamicData,
|
||||
dynamicFields,
|
||||
});
|
||||
|
@@ -38,6 +38,11 @@ type AppConfig = {
|
||||
smtpUser: string;
|
||||
smtpPassword: string;
|
||||
fromEmail: string;
|
||||
isCloud: boolean;
|
||||
stripeSecretKey: string;
|
||||
stripeSigningSecret: string;
|
||||
stripeStarterPriceKey: string;
|
||||
stripeGrowthPriceKey: string;
|
||||
licenseKey: string;
|
||||
};
|
||||
|
||||
@@ -104,6 +109,11 @@ const appConfig: AppConfig = {
|
||||
smtpUser: process.env.SMTP_USER,
|
||||
smtpPassword: process.env.SMTP_PASSWORD,
|
||||
fromEmail: process.env.FROM_EMAIL,
|
||||
isCloud: process.env.AUTOMATISCH_CLOUD === 'true',
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
stripeSigningSecret: process.env.STRIPE_SIGNING_SECRET,
|
||||
stripeStarterPriceKey: process.env.STRIPE_STARTER_PRICE_KEY,
|
||||
stripeGrowthPriceKey: process.env.STRIPE_GROWTH_PRICE_KEY,
|
||||
licenseKey: process.env.LICENSE_KEY,
|
||||
};
|
||||
|
||||
|
23
packages/backend/src/controllers/stripe/webhooks.ee.ts
Normal file
23
packages/backend/src/controllers/stripe/webhooks.ee.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Response } from 'express';
|
||||
import { IRequest } from '@automatisch/types';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
import appConfig from '../../config/app';
|
||||
import logger from '../../helpers/logger';
|
||||
|
||||
export default async (request: IRequest, response: Response) => {
|
||||
const signature = request.headers['stripe-signature'];
|
||||
|
||||
try {
|
||||
const event = Billing.stripe.webhooks.constructEvent(
|
||||
request.rawBody,
|
||||
signature,
|
||||
appConfig.stripeSigningSecret
|
||||
);
|
||||
|
||||
await Billing.handleWebhooks(event);
|
||||
return response.sendStatus(200);
|
||||
} catch (error) {
|
||||
logger.error(`Webhook Error: ${error.message}`);
|
||||
return response.sendStatus(400);
|
||||
}
|
||||
};
|
@@ -0,0 +1,15 @@
|
||||
import { Knex } from 'knex';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
return knex.schema.table('users', async (table) => {
|
||||
table.string('full_name');
|
||||
|
||||
await knex('users').update({ full_name: 'Initial admin' });
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
return knex.schema.table('users', (table) => {
|
||||
table.dropColumn('full_name');
|
||||
});
|
||||
}
|
@@ -0,0 +1,24 @@
|
||||
import { Knex } from 'knex';
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return knex.schema.createTable('payment_plans', (table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||
table.string('name').notNullable();
|
||||
table.integer('task_count').notNullable();
|
||||
table.uuid('user_id').references('id').inTable('users');
|
||||
table.string('stripe_customer_id');
|
||||
table.string('stripe_subscription_id');
|
||||
table.timestamp('current_period_started_at').nullable();
|
||||
table.timestamp('current_period_ends_at').nullable();
|
||||
table.timestamp('deleted_at').nullable();
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
return knex.schema.dropTable('payment_plans');
|
||||
}
|
@@ -0,0 +1,20 @@
|
||||
import { Knex } from 'knex';
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return knex.schema.createTable('usage_data', (table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||
table.uuid('user_id').references('id').inTable('users');
|
||||
table.string('consumed_task_count').notNullable();
|
||||
table.timestamp('next_reset_at').nullable();
|
||||
table.timestamp('deleted_at').nullable();
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
return knex.schema.dropTable('usage_data');
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
import { Knex } from 'knex';
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return knex.schema.alterTable('usage_data', (table) => {
|
||||
table.integer('consumed_task_count').notNullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
return knex.schema.alterTable('usage_data', (table) => {
|
||||
table.string('consumed_task_count').notNullable().alter();
|
||||
});
|
||||
}
|
3
packages/backend/src/errors/already-processed.ts
Normal file
3
packages/backend/src/errors/already-processed.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import BaseError from './base';
|
||||
|
||||
export default class AlreadyProcessedError extends BaseError { }
|
@@ -13,6 +13,7 @@ import createStep from './mutations/create-step';
|
||||
import updateStep from './mutations/update-step';
|
||||
import deleteStep from './mutations/delete-step';
|
||||
import createUser from './mutations/create-user.ee';
|
||||
import deleteUser from './mutations/delete-user.ee';
|
||||
import updateUser from './mutations/update-user';
|
||||
import forgotPassword from './mutations/forgot-password.ee';
|
||||
import resetPassword from './mutations/reset-password.ee';
|
||||
@@ -34,6 +35,7 @@ const mutationResolvers = {
|
||||
updateStep,
|
||||
deleteStep,
|
||||
createUser,
|
||||
deleteUser,
|
||||
updateUser,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
|
@@ -1,14 +1,17 @@
|
||||
import User from '../../models/user';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
type Params = {
|
||||
input: {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
};
|
||||
|
||||
const createUser = async (_parent: unknown, params: Params) => {
|
||||
const { email, password } = params.input;
|
||||
const { fullName, email, password } = params.input;
|
||||
|
||||
const existingUser = await User.query().findOne({ email });
|
||||
|
||||
@@ -17,11 +20,16 @@ const createUser = async (_parent: unknown, params: Params) => {
|
||||
}
|
||||
|
||||
const user = await User.query().insert({
|
||||
fullName,
|
||||
email,
|
||||
password,
|
||||
role: 'user',
|
||||
});
|
||||
|
||||
if (appConfig.isCloud) {
|
||||
Billing.createSubscription(user);
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
|
22
packages/backend/src/graphql/mutations/delete-user.ee.ts
Normal file
22
packages/backend/src/graphql/mutations/delete-user.ee.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import Context from '../../types/express/context';
|
||||
import deleteUserQueue from '../../queues/delete-user.ee';
|
||||
import { Duration } from 'luxon';
|
||||
|
||||
const deleteUser = async (_parent: unknown, params: never, context: Context) => {
|
||||
const id = context.currentUser.id;
|
||||
|
||||
await context.currentUser.$query().delete();
|
||||
|
||||
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,3 +1,4 @@
|
||||
import appConfig from '../../config/app';
|
||||
import User from '../../models/user';
|
||||
import emailQueue from '../../queues/email';
|
||||
import {
|
||||
@@ -30,6 +31,8 @@ const forgotPassword = async (_parent: unknown, params: Params) => {
|
||||
template: 'reset-password-instructions',
|
||||
params: {
|
||||
token: user.resetPasswordToken,
|
||||
webAppUrl: appConfig.webAppUrl,
|
||||
fullName: user.fullName,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,7 +43,7 @@ const forgotPassword = async (_parent: unknown, params: Params) => {
|
||||
|
||||
await emailQueue.add(jobName, jobPayload, jobOptions);
|
||||
|
||||
return;
|
||||
return true;
|
||||
};
|
||||
|
||||
export default forgotPassword;
|
||||
|
@@ -24,7 +24,7 @@ const resetPassword = async (_parent: unknown, params: Params) => {
|
||||
|
||||
await user.resetPassword(password);
|
||||
|
||||
return;
|
||||
return true;
|
||||
};
|
||||
|
||||
export default resetPassword;
|
||||
|
@@ -0,0 +1,9 @@
|
||||
import appConfig from '../../config/app';
|
||||
|
||||
const getAutomatischInfo = async () => {
|
||||
return {
|
||||
isCloud: appConfig.isCloud,
|
||||
};
|
||||
};
|
||||
|
||||
export default getAutomatischInfo;
|
48
packages/backend/src/graphql/queries/get-dynamic-fields.ts
Normal file
48
packages/backend/src/graphql/queries/get-dynamic-fields.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { IDynamicFields, IJSONObject } from '@automatisch/types';
|
||||
import Context from '../../types/express/context';
|
||||
import App from '../../models/app';
|
||||
import globalVariable from '../../helpers/global-variable';
|
||||
|
||||
type Params = {
|
||||
stepId: string;
|
||||
key: string;
|
||||
parameters: IJSONObject;
|
||||
};
|
||||
|
||||
const getDynamicFields = async (
|
||||
_parent: unknown,
|
||||
params: Params,
|
||||
context: Context
|
||||
) => {
|
||||
const step = await context.currentUser
|
||||
.$relatedQuery('steps')
|
||||
.withGraphFetched({
|
||||
connection: true,
|
||||
flow: true,
|
||||
})
|
||||
.findById(params.stepId);
|
||||
|
||||
if (!step) return null;
|
||||
|
||||
const connection = step.connection;
|
||||
|
||||
if (!connection || !step.appKey) return null;
|
||||
|
||||
const app = await App.findOneByKey(step.appKey);
|
||||
const $ = await globalVariable({ connection, app, flow: step.flow, step });
|
||||
|
||||
const command = app.dynamicFields.find(
|
||||
(data: IDynamicFields) => data.key === params.key
|
||||
);
|
||||
|
||||
for (const parameterKey in params.parameters) {
|
||||
const parameterValue = params.parameters[parameterKey];
|
||||
$.step.parameters[parameterKey] = parameterValue;
|
||||
}
|
||||
|
||||
const additionalFields = await command.run($) || [];
|
||||
|
||||
return additionalFields;
|
||||
};
|
||||
|
||||
export default getDynamicFields;
|
@@ -1,11 +0,0 @@
|
||||
import checkLicense from '../../helpers/check-license.ee';
|
||||
|
||||
const getLicense = async () => {
|
||||
const license = await checkLicense();
|
||||
|
||||
return {
|
||||
type: license ? 'ee' : 'ce',
|
||||
};
|
||||
};
|
||||
|
||||
export default getLicense;
|
@@ -0,0 +1,16 @@
|
||||
import appConfig from '../../config/app';
|
||||
import Context from '../../types/express/context';
|
||||
import Billing from '../../helpers/billing/index.ee';
|
||||
|
||||
const getPaymentPortalUrl = async (
|
||||
_parent: unknown,
|
||||
_params: unknown,
|
||||
context: Context
|
||||
) => {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
const url = Billing.createPaymentPortalUrl(context.currentUser);
|
||||
return { url };
|
||||
};
|
||||
|
||||
export default getPaymentPortalUrl;
|
30
packages/backend/src/graphql/queries/get-usage-data.ee.ts
Normal file
30
packages/backend/src/graphql/queries/get-usage-data.ee.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import appConfig from '../../config/app';
|
||||
import Context from '../../types/express/context';
|
||||
|
||||
const getUsageData = async (
|
||||
_parent: unknown,
|
||||
_params: unknown,
|
||||
context: Context
|
||||
) => {
|
||||
if (!appConfig.isCloud) return;
|
||||
|
||||
const usageData = await context.currentUser
|
||||
.$relatedQuery('usageData')
|
||||
.throwIfNotFound();
|
||||
|
||||
const paymentPlan = await context.currentUser
|
||||
.$relatedQuery('paymentPlan')
|
||||
.throwIfNotFound();
|
||||
|
||||
const computedUsageData = {
|
||||
name: paymentPlan.name,
|
||||
allowedTaskCount: paymentPlan.taskCount,
|
||||
consumedTaskCount: usageData.consumedTaskCount,
|
||||
remainingTaskCount: paymentPlan.taskCount - usageData.consumedTaskCount,
|
||||
nextResetAt: usageData.nextResetAt,
|
||||
};
|
||||
|
||||
return computedUsageData;
|
||||
};
|
||||
|
||||
export default getUsageData;
|
@@ -9,8 +9,11 @@ import getExecution from './queries/get-execution';
|
||||
import getExecutions from './queries/get-executions';
|
||||
import getExecutionSteps from './queries/get-execution-steps';
|
||||
import getDynamicData from './queries/get-dynamic-data';
|
||||
import getDynamicFields from './queries/get-dynamic-fields';
|
||||
import getCurrentUser from './queries/get-current-user';
|
||||
import getLicense from './queries/get-license.ee';
|
||||
import getUsageData from './queries/get-usage-data.ee';
|
||||
import getPaymentPortalUrl from './queries/get-payment-portal-url.ee';
|
||||
import getAutomatischInfo from './queries/get-automatisch-info';
|
||||
import healthcheck from './queries/healthcheck';
|
||||
|
||||
const queryResolvers = {
|
||||
@@ -25,8 +28,11 @@ const queryResolvers = {
|
||||
getExecutions,
|
||||
getExecutionSteps,
|
||||
getDynamicData,
|
||||
getDynamicFields,
|
||||
getCurrentUser,
|
||||
getLicense,
|
||||
getUsageData,
|
||||
getPaymentPortalUrl,
|
||||
getAutomatischInfo,
|
||||
healthcheck,
|
||||
};
|
||||
|
||||
|
@@ -28,8 +28,15 @@ type Query {
|
||||
key: String!
|
||||
parameters: JSONObject
|
||||
): JSONObject
|
||||
getDynamicFields(
|
||||
stepId: String!
|
||||
key: String!
|
||||
parameters: JSONObject
|
||||
): [SubstepArgument]
|
||||
getCurrentUser: User
|
||||
getLicense: GetLicense
|
||||
getUsageData: GetUsageData
|
||||
getPaymentPortalUrl: GetPaymentPortalUrl
|
||||
getAutomatischInfo: GetAutomatischInfo
|
||||
healthcheck: AppHealth
|
||||
}
|
||||
|
||||
@@ -49,6 +56,7 @@ type Mutation {
|
||||
updateStep(input: UpdateStepInput): Step
|
||||
deleteStep(input: DeleteStepInput): Step
|
||||
createUser(input: CreateUserInput): User
|
||||
deleteUser: Boolean
|
||||
updateUser(input: UpdateUserInput): User
|
||||
forgotPassword(input: ForgotPasswordInput): Boolean
|
||||
resetPassword(input: ResetPasswordInput): Boolean
|
||||
@@ -65,38 +73,64 @@ directive @specifiedBy(
|
||||
url: String!
|
||||
) on SCALAR
|
||||
|
||||
type Trigger {
|
||||
name: String
|
||||
key: String
|
||||
description: String
|
||||
pollInterval: Int
|
||||
type: String
|
||||
substeps: [Substep]
|
||||
}
|
||||
|
||||
type Action {
|
||||
name: String
|
||||
key: String
|
||||
description: String
|
||||
substeps: [ActionSubstep]
|
||||
substeps: [Substep]
|
||||
}
|
||||
|
||||
type ActionSubstep {
|
||||
type Substep {
|
||||
key: String
|
||||
name: String
|
||||
arguments: [ActionSubstepArgument]
|
||||
arguments: [SubstepArgument]
|
||||
}
|
||||
|
||||
type ActionSubstepArgument {
|
||||
type SubstepArgument {
|
||||
label: String
|
||||
key: String
|
||||
type: String
|
||||
description: String
|
||||
required: Boolean
|
||||
variables: Boolean
|
||||
options: [ArgumentOption]
|
||||
source: ActionSubstepArgumentSource
|
||||
options: [SubstepArgumentOption]
|
||||
source: SubstepArgumentSource
|
||||
additionalFields: SubstepArgumentAdditionalFields
|
||||
dependsOn: [String]
|
||||
}
|
||||
|
||||
type ActionSubstepArgumentSource {
|
||||
type: String
|
||||
name: String
|
||||
arguments: [ActionSubstepArgumentSourceArgument]
|
||||
type SubstepArgumentOption {
|
||||
label: String
|
||||
value: JSONObject
|
||||
}
|
||||
|
||||
type ActionSubstepArgumentSourceArgument {
|
||||
type SubstepArgumentSource {
|
||||
type: String
|
||||
name: String
|
||||
arguments: [SubstepArgumentSourceArgument]
|
||||
}
|
||||
|
||||
type SubstepArgumentSourceArgument {
|
||||
name: String
|
||||
value: String
|
||||
}
|
||||
|
||||
type SubstepArgumentAdditionalFields {
|
||||
type: String
|
||||
name: String
|
||||
arguments: [SubstepArgumentAdditionalFieldsArgument]
|
||||
}
|
||||
|
||||
type SubstepArgumentAdditionalFieldsArgument {
|
||||
name: String
|
||||
value: String
|
||||
}
|
||||
@@ -198,7 +232,7 @@ type Field {
|
||||
description: String
|
||||
docUrl: String
|
||||
clickToCopy: Boolean
|
||||
options: [ArgumentOption]
|
||||
options: [SubstepArgumentOption]
|
||||
}
|
||||
|
||||
type FlowConnection {
|
||||
@@ -304,6 +338,7 @@ input DeleteStepInput {
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
fullName: String!
|
||||
email: String!
|
||||
password: String!
|
||||
}
|
||||
@@ -394,52 +429,11 @@ input StepInput {
|
||||
previousStep: PreviousStepInput
|
||||
}
|
||||
|
||||
type Trigger {
|
||||
name: String
|
||||
key: String
|
||||
description: String
|
||||
pollInterval: Int
|
||||
type: String
|
||||
substeps: [TriggerSubstep]
|
||||
}
|
||||
|
||||
type TriggerSubstep {
|
||||
key: String
|
||||
name: String
|
||||
arguments: [TriggerSubstepArgument]
|
||||
}
|
||||
|
||||
type TriggerSubstepArgument {
|
||||
label: String
|
||||
key: String
|
||||
type: String
|
||||
description: String
|
||||
required: Boolean
|
||||
variables: Boolean
|
||||
source: TriggerSubstepArgumentSource
|
||||
dependsOn: [String]
|
||||
options: [ArgumentOption]
|
||||
}
|
||||
|
||||
type TriggerSubstepArgumentSource {
|
||||
type: String
|
||||
name: String
|
||||
arguments: [TriggerSubstepArgumentSourceArgument]
|
||||
}
|
||||
|
||||
type ArgumentOption {
|
||||
label: String
|
||||
value: JSONObject
|
||||
}
|
||||
|
||||
type TriggerSubstepArgumentSourceArgument {
|
||||
name: String
|
||||
value: String
|
||||
}
|
||||
|
||||
type User {
|
||||
id: String
|
||||
fullName: String
|
||||
email: String
|
||||
role: String
|
||||
createdAt: String
|
||||
updatedAt: String
|
||||
}
|
||||
@@ -471,8 +465,20 @@ type AppHealth {
|
||||
version: String
|
||||
}
|
||||
|
||||
type GetLicense {
|
||||
type: String
|
||||
type GetAutomatischInfo {
|
||||
isCloud: Boolean
|
||||
}
|
||||
|
||||
type GetUsageData {
|
||||
name: String
|
||||
allowedTaskCount: Int
|
||||
consumedTaskCount: Int
|
||||
remainingTaskCount: Int
|
||||
nextResetAt: String
|
||||
}
|
||||
|
||||
type GetPaymentPortalUrl {
|
||||
url: String
|
||||
}
|
||||
|
||||
schema {
|
||||
|
@@ -24,6 +24,7 @@ const authentication = shield(
|
||||
{
|
||||
Query: {
|
||||
'*': isAuthenticated,
|
||||
getAutomatischInfo: allow,
|
||||
healthcheck: allow,
|
||||
},
|
||||
Mutation: {
|
||||
@@ -31,6 +32,7 @@ const authentication = shield(
|
||||
login: allow,
|
||||
createUser: allow,
|
||||
forgotPassword: allow,
|
||||
resetPassword: allow,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
100
packages/backend/src/helpers/billing/index.ee.ts
Normal file
100
packages/backend/src/helpers/billing/index.ee.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import Stripe from 'stripe';
|
||||
import User from '../../models/user';
|
||||
import PaymentPlan from '../../models/payment-plan.ee';
|
||||
import UsageData from '../../models/usage-data.ee';
|
||||
import appConfig from '../../config/app';
|
||||
import handleWebhooks from './webhooks.ee';
|
||||
|
||||
const plans = [
|
||||
{
|
||||
price: appConfig.stripeStarterPriceKey,
|
||||
name: 'Starter',
|
||||
taskCount: 1000,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
price: appConfig.stripeGrowthPriceKey,
|
||||
name: 'Growth',
|
||||
taskCount: 10000,
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
const stripe = new Stripe(appConfig.stripeSecretKey, {
|
||||
apiVersion: '2022-11-15',
|
||||
});
|
||||
|
||||
const createStripeCustomer = async (user: User) => {
|
||||
const params: Stripe.CustomerCreateParams = {
|
||||
email: user.email,
|
||||
name: user.fullName,
|
||||
description: `User ID: ${user.id}`,
|
||||
};
|
||||
|
||||
return await stripe.customers.create(params);
|
||||
};
|
||||
|
||||
const defaultPlan = plans.find((plan) => plan.default);
|
||||
|
||||
const createStripeSubscription = async (
|
||||
user: User,
|
||||
stripeCustomer: Stripe.Customer
|
||||
) => {
|
||||
const params: Stripe.SubscriptionCreateParams = {
|
||||
customer: stripeCustomer.id,
|
||||
items: [{ price: defaultPlan.price }],
|
||||
};
|
||||
|
||||
return await stripe.subscriptions.create(params);
|
||||
};
|
||||
|
||||
const createSubscription = async (user: User) => {
|
||||
const stripeCustomer = await createStripeCustomer(user);
|
||||
const stripeSubscription = await createStripeSubscription(
|
||||
user,
|
||||
stripeCustomer
|
||||
);
|
||||
|
||||
await PaymentPlan.query().insert({
|
||||
name: defaultPlan.name,
|
||||
taskCount: defaultPlan.taskCount,
|
||||
userId: user.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
currentPeriodStartedAt: new Date(
|
||||
stripeSubscription.current_period_start * 1000
|
||||
).toISOString(),
|
||||
currentPeriodEndsAt: new Date(
|
||||
stripeSubscription.current_period_end * 1000
|
||||
).toISOString(),
|
||||
});
|
||||
|
||||
await UsageData.query().insert({
|
||||
userId: user.id,
|
||||
consumedTaskCount: 0,
|
||||
nextResetAt: new Date(
|
||||
stripeSubscription.current_period_end * 1000
|
||||
).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const createPaymentPortalUrl = async (user: User) => {
|
||||
const paymentPlan = await user.$relatedQuery('paymentPlan');
|
||||
|
||||
const userSession = await stripe.billingPortal.sessions.create({
|
||||
customer: paymentPlan.stripeCustomerId,
|
||||
return_url: 'https://cloud.automatisch.io',
|
||||
});
|
||||
|
||||
return userSession.url;
|
||||
};
|
||||
|
||||
const billing = {
|
||||
createSubscription,
|
||||
createPaymentPortalUrl,
|
||||
handleWebhooks,
|
||||
stripe,
|
||||
plans,
|
||||
};
|
||||
|
||||
export default billing;
|
42
packages/backend/src/helpers/billing/webhooks.ee.ts
Normal file
42
packages/backend/src/helpers/billing/webhooks.ee.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import Stripe from 'stripe';
|
||||
import PaymentPlan from '../../models/payment-plan.ee';
|
||||
import Billing from './index.ee';
|
||||
|
||||
const handleWebhooks = async (event: Stripe.Event) => {
|
||||
const trackedWebhookTypes = [
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
];
|
||||
|
||||
if (!trackedWebhookTypes.includes(event.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updatePaymentPlan(event);
|
||||
};
|
||||
|
||||
const updatePaymentPlan = async (event: Stripe.Event) => {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const priceKey = subscription.items.data[0].plan.id;
|
||||
const plan = Billing.plans.find((plan) => plan.price === priceKey);
|
||||
|
||||
const paymentPlan = await PaymentPlan.query().findOne({
|
||||
stripe_customer_id: subscription.customer,
|
||||
});
|
||||
|
||||
await paymentPlan.$query().patchAndFetch({
|
||||
name: plan.name,
|
||||
taskCount: plan.taskCount,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
});
|
||||
|
||||
const user = await paymentPlan.$relatedQuery('user');
|
||||
const usageData = await user.$relatedQuery('usageData');
|
||||
|
||||
await usageData.$query().patchAndFetch({
|
||||
nextResetAt: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
export default handleWebhooks;
|
@@ -38,6 +38,13 @@ export default function computeParameters(
|
||||
};
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
...result,
|
||||
[key]: value.map(item => computeParameters(item, executionSteps)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
[key]: value,
|
||||
|
@@ -4,6 +4,8 @@ import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
|
||||
import flowQueue from '../queues/flow';
|
||||
import triggerQueue from '../queues/trigger';
|
||||
import actionQueue from '../queues/action';
|
||||
import emailQueue from '../queues/email';
|
||||
import deleteUserQueue from '../queues/delete-user.ee';
|
||||
import appConfig from '../config/app';
|
||||
|
||||
const serverAdapter = new ExpressAdapter();
|
||||
@@ -21,6 +23,8 @@ const createBullBoardHandler = async (serverAdapter: ExpressAdapter) => {
|
||||
new BullMQAdapter(flowQueue),
|
||||
new BullMQAdapter(triggerQueue),
|
||||
new BullMQAdapter(actionQueue),
|
||||
new BullMQAdapter(emailQueue),
|
||||
new BullMQAdapter(deleteUserQueue),
|
||||
],
|
||||
serverAdapter: serverAdapter,
|
||||
});
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
IRequest,
|
||||
} from '@automatisch/types';
|
||||
import EarlyExitError from '../errors/early-exit';
|
||||
import AlreadyProcessedError from '../errors/already-processed';
|
||||
|
||||
type GlobalVariableOptions = {
|
||||
connection?: Connection;
|
||||
@@ -77,6 +78,9 @@ const globalVariable = async (
|
||||
execution: {
|
||||
id: execution?.id,
|
||||
testRun,
|
||||
exit: () => {
|
||||
throw new EarlyExitError();
|
||||
}
|
||||
},
|
||||
lastExecutionStep: (await step?.getLastExecutionStep())?.toJSON(),
|
||||
triggerOutput: {
|
||||
@@ -93,7 +97,7 @@ const globalVariable = async (
|
||||
!$.execution.testRun
|
||||
) {
|
||||
// early exit as we do not want to process duplicate items in actual executions
|
||||
throw new EarlyExitError();
|
||||
throw new AlreadyProcessedError();
|
||||
}
|
||||
|
||||
$.triggerOutput.data.push(triggerItem);
|
||||
|
@@ -28,7 +28,7 @@ const graphQLInstance = graphqlHTTP({
|
||||
delete (error.originalError as HttpError).response;
|
||||
}
|
||||
|
||||
return error.originalError;
|
||||
return error;
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -40,8 +40,9 @@ export default function createHttpClient({
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const { config } = error;
|
||||
const { status } = error.response;
|
||||
const { config, response } = error;
|
||||
// Do not destructure `status` from `error.response` because it might not exist
|
||||
const status = response?.status;
|
||||
|
||||
if (
|
||||
// TODO: provide a `shouldRefreshToken` function in the app
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import type { QueryContext } from 'objection';
|
||||
import { IJSONObject } from '@automatisch/types';
|
||||
import appConfig from '../config/app';
|
||||
import Base from './base';
|
||||
import Execution from './execution';
|
||||
import Step from './step';
|
||||
import Telemetry from '../helpers/telemetry';
|
||||
import { IJSONObject } from '@automatisch/types';
|
||||
|
||||
class ExecutionStep extends Base {
|
||||
id!: string;
|
||||
@@ -14,6 +15,7 @@ class ExecutionStep extends Base {
|
||||
errorDetails: IJSONObject;
|
||||
status: 'success' | 'failure';
|
||||
step: Step;
|
||||
execution?: Execution;
|
||||
|
||||
static tableName = 'execution_steps';
|
||||
|
||||
@@ -57,6 +59,18 @@ class ExecutionStep extends Base {
|
||||
async $afterInsert(queryContext: QueryContext) {
|
||||
await super.$afterInsert(queryContext);
|
||||
Telemetry.executionStepCreated(this);
|
||||
|
||||
if (appConfig.isCloud) {
|
||||
const execution = await this.$relatedQuery('execution');
|
||||
|
||||
if (!execution.testRun && !this.isFailed) {
|
||||
const flow = await execution.$relatedQuery('flow');
|
||||
const user = await flow.$relatedQuery('user');
|
||||
const usageData = await user.$relatedQuery('usageData');
|
||||
|
||||
await usageData.increaseConsumedTaskCountByOne();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -10,6 +10,7 @@ class Execution extends Base {
|
||||
testRun: boolean;
|
||||
internalId: string;
|
||||
executionSteps: ExecutionStep[];
|
||||
flow?: Flow;
|
||||
|
||||
static tableName = 'executions';
|
||||
|
||||
|
@@ -3,6 +3,7 @@ import type { ModelOptions, QueryContext } from 'objection';
|
||||
import ExtendedQueryBuilder from './query-builder';
|
||||
import Base from './base';
|
||||
import Step from './step';
|
||||
import User from './user';
|
||||
import Execution from './execution';
|
||||
import Telemetry from '../helpers/telemetry';
|
||||
|
||||
@@ -15,6 +16,7 @@ class Flow extends Base {
|
||||
published_at: string;
|
||||
remoteWebhookId: string;
|
||||
executions?: Execution[];
|
||||
user?: User;
|
||||
|
||||
static tableName = 'flows';
|
||||
|
||||
@@ -51,6 +53,14 @@ class Flow extends Base {
|
||||
to: 'executions.flow_id',
|
||||
},
|
||||
},
|
||||
user: {
|
||||
relation: Base.HasOneRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'flows.user_id',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async lastInternalId() {
|
||||
|
53
packages/backend/src/models/payment-plan.ee.ts
Normal file
53
packages/backend/src/models/payment-plan.ee.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import Base from './base';
|
||||
import User from './user';
|
||||
|
||||
class PaymentPlan extends Base {
|
||||
id!: string;
|
||||
name!: string;
|
||||
taskCount: number;
|
||||
userId!: string;
|
||||
stripeCustomerId!: string;
|
||||
stripeSubscriptionId!: string;
|
||||
currentPeriodStartedAt!: string;
|
||||
currentPeriodEndsAt!: string;
|
||||
user?: User;
|
||||
|
||||
static tableName = 'payment_plans';
|
||||
|
||||
static jsonSchema = {
|
||||
type: 'object',
|
||||
required: [
|
||||
'name',
|
||||
'taskCount',
|
||||
'userId',
|
||||
'stripeCustomerId',
|
||||
'stripeSubscriptionId',
|
||||
'currentPeriodStartedAt',
|
||||
'currentPeriodEndsAt',
|
||||
],
|
||||
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
taskCount: { type: 'integer' },
|
||||
userId: { type: 'string', format: 'uuid' },
|
||||
stripeCustomerId: { type: 'string' },
|
||||
stripeSubscriptionId: { type: 'string' },
|
||||
currentPeriodStartedAt: { type: 'string' },
|
||||
currentPeriodEndsAt: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
static relationMappings = () => ({
|
||||
user: {
|
||||
relation: Base.BelongsToOneRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'payment_plans.user_id',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default PaymentPlan;
|
@@ -149,6 +149,22 @@ class Step extends Base {
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
async getSetupFields() {
|
||||
let setupSupsteps;
|
||||
|
||||
if (this.isTrigger) {
|
||||
setupSupsteps = (await this.getTriggerCommand()).substeps;
|
||||
} else {
|
||||
setupSupsteps = (await this.getActionCommand()).substeps;
|
||||
}
|
||||
|
||||
const existingArguments = setupSupsteps.find(
|
||||
(substep) => substep.key === 'chooseTrigger'
|
||||
).arguments;
|
||||
|
||||
return existingArguments;
|
||||
}
|
||||
}
|
||||
|
||||
export default Step;
|
||||
|
41
packages/backend/src/models/usage-data.ee.ts
Normal file
41
packages/backend/src/models/usage-data.ee.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { raw } from 'objection';
|
||||
import Base from './base';
|
||||
import User from './user';
|
||||
|
||||
class UsageData extends Base {
|
||||
id!: string;
|
||||
userId!: string;
|
||||
consumedTaskCount!: number;
|
||||
nextResetAt!: string;
|
||||
|
||||
static tableName = 'usage_data';
|
||||
|
||||
static jsonSchema = {
|
||||
type: 'object',
|
||||
required: ['userId', 'consumedTaskCount', 'nextResetAt'],
|
||||
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
userId: { type: 'string', format: 'uuid' },
|
||||
consumedTaskCount: { type: 'integer' },
|
||||
nextResetAt: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
static relationMappings = () => ({
|
||||
user: {
|
||||
relation: Base.BelongsToOneRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'usage_data.user_id',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async increaseConsumedTaskCountByOne() {
|
||||
return await this.$query().patch({ consumedTaskCount: raw('consumed_task_count + 1') });
|
||||
}
|
||||
}
|
||||
|
||||
export default UsageData;
|
@@ -6,9 +6,12 @@ import Step from './step';
|
||||
import Execution from './execution';
|
||||
import bcrypt from 'bcrypt';
|
||||
import crypto from 'crypto';
|
||||
import PaymentPlan from './payment-plan.ee';
|
||||
import UsageData from './usage-data.ee';
|
||||
|
||||
class User extends Base {
|
||||
id!: string;
|
||||
fullName!: string;
|
||||
email!: string;
|
||||
password!: string;
|
||||
role: string;
|
||||
@@ -18,15 +21,18 @@ class User extends Base {
|
||||
flows?: Flow[];
|
||||
steps?: Step[];
|
||||
executions?: Execution[];
|
||||
paymentPlan?: PaymentPlan;
|
||||
usageData?: UsageData;
|
||||
|
||||
static tableName = 'users';
|
||||
|
||||
static jsonSchema = {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
required: ['fullName', 'email', 'password'],
|
||||
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
fullName: { type: 'string', minLength: 1 },
|
||||
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
|
||||
password: { type: 'string', minLength: 1, maxLength: 255 },
|
||||
role: { type: 'string', enum: ['admin', 'user'] },
|
||||
@@ -74,6 +80,22 @@ class User extends Base {
|
||||
to: 'executions.flow_id',
|
||||
},
|
||||
},
|
||||
paymentPlan: {
|
||||
relation: Base.HasOneRelation,
|
||||
modelClass: PaymentPlan,
|
||||
join: {
|
||||
from: 'payment_plans.user_id',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
usageData: {
|
||||
relation: Base.HasOneRelation,
|
||||
modelClass: UsageData,
|
||||
join: {
|
||||
from: 'usage_data.user_id',
|
||||
to: 'users.id',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
login(password: string) {
|
||||
|
25
packages/backend/src/queues/delete-user.ee.ts
Normal file
25
packages/backend/src/queues/delete-user.ee.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import process from 'process';
|
||||
import { Queue } from 'bullmq';
|
||||
import redisConfig from '../config/redis';
|
||||
import logger from '../helpers/logger';
|
||||
|
||||
const CONNECTION_REFUSED = 'ECONNREFUSED';
|
||||
|
||||
const redisConnection = {
|
||||
connection: redisConfig,
|
||||
};
|
||||
|
||||
const deleteUserQueue = new Queue('delete-user', redisConnection);
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await deleteUserQueue.close();
|
||||
});
|
||||
|
||||
deleteUserQueue.on('error', (err) => {
|
||||
if ((err as any).code === CONNECTION_REFUSED) {
|
||||
logger.error('Make sure you have installed Redis and it is running.', err);
|
||||
process.exit();
|
||||
}
|
||||
});
|
||||
|
||||
export default deleteUserQueue;
|
@@ -1,10 +1,12 @@
|
||||
import { Router } from 'express';
|
||||
import graphQLInstance from '../helpers/graphql-instance';
|
||||
import webhooksRouter from './webhooks';
|
||||
import stripeRouter from './stripe.ee';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use('/graphql', graphQLInstance);
|
||||
router.use('/webhooks', webhooksRouter);
|
||||
router.use('/stripe', stripeRouter);
|
||||
|
||||
export default router;
|
||||
|
23
packages/backend/src/routes/stripe.ee.ts
Normal file
23
packages/backend/src/routes/stripe.ee.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import express, { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { IRequest } from '@automatisch/types';
|
||||
import appConfig from '../config/app';
|
||||
import stripeWebhooksAction from '../controllers/stripe/webhooks.ee';
|
||||
|
||||
const router = Router();
|
||||
const upload = multer();
|
||||
|
||||
router.use(upload.none());
|
||||
|
||||
router.use(
|
||||
express.text({
|
||||
limit: appConfig.requestBodySizeLimit,
|
||||
verify(req, res, buf) {
|
||||
(req as IRequest).rawBody = buf;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
router.post('/webhooks', stripeWebhooksAction);
|
||||
|
||||
export default router;
|
@@ -5,6 +5,8 @@ import ExecutionStep from '../models/execution-step';
|
||||
import computeParameters from '../helpers/compute-parameters';
|
||||
import globalVariable from '../helpers/global-variable';
|
||||
import HttpError from '../errors/http';
|
||||
import EarlyExitError from '../errors/early-exit';
|
||||
import AlreadyProcessedError from '../errors/already-processed';
|
||||
|
||||
type ProcessActionOptions = {
|
||||
flowId: string;
|
||||
@@ -44,13 +46,19 @@ export const processAction = async (options: ProcessActionOptions) => {
|
||||
try {
|
||||
await actionCommand.run($);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
$.actionOutput.error = error.details;
|
||||
} else {
|
||||
try {
|
||||
$.actionOutput.error = JSON.parse(error.message);
|
||||
} catch {
|
||||
$.actionOutput.error = { error: error.message };
|
||||
const shouldEarlyExit = error instanceof EarlyExitError;
|
||||
const shouldNotProcess = error instanceof AlreadyProcessedError;
|
||||
const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess;
|
||||
|
||||
if (!shouldNotConsiderAsError) {
|
||||
if (error instanceof HttpError) {
|
||||
$.actionOutput.error = error.details;
|
||||
} else {
|
||||
try {
|
||||
$.actionOutput.error = JSON.parse(error.message);
|
||||
} catch {
|
||||
$.actionOutput.error = { error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import Flow from '../models/flow';
|
||||
import globalVariable from '../helpers/global-variable';
|
||||
import EarlyExitError from '../errors/early-exit';
|
||||
import AlreadyProcessedError from '../errors/already-processed';
|
||||
import HttpError from '../errors/http';
|
||||
|
||||
type ProcessFlowOptions = {
|
||||
@@ -29,7 +30,11 @@ export const processFlow = async (options: ProcessFlowOptions) => {
|
||||
await triggerCommand.run($);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof EarlyExitError === false) {
|
||||
const shouldEarlyExit = error instanceof EarlyExitError;
|
||||
const shouldNotProcess = error instanceof AlreadyProcessedError;
|
||||
const shouldNotConsiderAsError = shouldEarlyExit || shouldNotProcess;
|
||||
|
||||
if (!shouldNotConsiderAsError) {
|
||||
if (error instanceof HttpError) {
|
||||
$.triggerOutput.error = error.details;
|
||||
} else {
|
||||
|
@@ -1,16 +1,23 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Title</title>
|
||||
<title>Reset password instructions</title>
|
||||
</head>
|
||||
<body>
|
||||
Hello {{ email }}
|
||||
<p>
|
||||
Hello {{ fullName }},
|
||||
</p>
|
||||
|
||||
Someone has requested a link to change your password, and you can do this through the link below.
|
||||
<p>
|
||||
Someone has requested a link to change your password, and you can do this through the link below.
|
||||
</p>
|
||||
|
||||
<a href="/reset-password">Change my password</a>
|
||||
<p>
|
||||
<a href="{{ webAppUrl }}/reset-password?token={{ token }}">Change my password</a>
|
||||
</p>
|
||||
|
||||
If you didn't request this, please ignore this email.
|
||||
Your password won't change until you access the link above and create a new one.
|
||||
<p>
|
||||
If you didn't request this, please ignore this email. Your password won't change until you access the link above and create a new one.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -4,6 +4,7 @@ import './workers/flow';
|
||||
import './workers/trigger';
|
||||
import './workers/action';
|
||||
import './workers/email';
|
||||
import './workers/delete-user.ee';
|
||||
import telemetry from './helpers/telemetry';
|
||||
|
||||
telemetry.setServiceType('worker');
|
||||
|
@@ -21,7 +21,7 @@ const DEFAULT_DELAY_DURATION = 0;
|
||||
export const worker = new Worker(
|
||||
'action',
|
||||
async (job) => {
|
||||
const { stepId, flowId, executionId, computedParameters } = await processAction(
|
||||
const { stepId, flowId, executionId, computedParameters, executionStep } = await processAction(
|
||||
job.data as JobData
|
||||
);
|
||||
|
||||
@@ -48,6 +48,10 @@ export const worker = new Worker(
|
||||
jobOptions.delay = delayAsMilliseconds(step.key, computedParameters);
|
||||
}
|
||||
|
||||
if (step.appKey === 'filter' && !executionStep.dataOut) {
|
||||
return;
|
||||
}
|
||||
|
||||
await actionQueue.add(jobName, jobPayload, jobOptions);
|
||||
},
|
||||
{ connection: redisConfig }
|
||||
|
44
packages/backend/src/workers/delete-user.ee.ts
Normal file
44
packages/backend/src/workers/delete-user.ee.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Worker } from 'bullmq';
|
||||
import redisConfig from '../config/redis';
|
||||
import logger from '../helpers/logger';
|
||||
import User from '../models/user';
|
||||
import Execution from '../models/execution';
|
||||
import ExecutionStep from '../models/execution-step';
|
||||
|
||||
export const worker = new Worker(
|
||||
'delete-user',
|
||||
async (job) => {
|
||||
const { id } = job.data;
|
||||
|
||||
const user = await User.query().findById(id).throwIfNotFound();
|
||||
|
||||
const executionIds = (
|
||||
await user.$relatedQuery('executions').select('executions.id')
|
||||
).map((execution: Execution) => execution.id);
|
||||
|
||||
await ExecutionStep.query().hardDelete().whereIn('execution_id', executionIds);
|
||||
await user.$relatedQuery('executions').hardDelete();
|
||||
await user.$relatedQuery('steps').hardDelete();
|
||||
await user.$relatedQuery('flows').hardDelete();
|
||||
await user.$relatedQuery('connections').hardDelete();
|
||||
|
||||
await user.$query().hardDelete();
|
||||
},
|
||||
{ connection: redisConfig }
|
||||
);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
logger.info(
|
||||
`JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has been deleted!`
|
||||
);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
logger.info(
|
||||
`JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has failed to be deleted! ${err.message}`
|
||||
);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await worker.close();
|
||||
});
|
@@ -8,13 +8,13 @@ import appConfig from '../config/app';
|
||||
export const worker = new Worker(
|
||||
'email',
|
||||
async (job) => {
|
||||
const { email, subject, templateName, params } = job.data;
|
||||
const { email, subject, template, params } = job.data;
|
||||
|
||||
await mailer.sendMail({
|
||||
to: email,
|
||||
from: appConfig.fromEmail,
|
||||
subject: subject,
|
||||
html: compileEmail(templateName, params),
|
||||
html: compileEmail(template, params),
|
||||
});
|
||||
},
|
||||
{ connection: redisConfig }
|
||||
|
16
packages/types/index.d.ts
vendored
16
packages/types/index.d.ts
vendored
@@ -82,6 +82,7 @@ export interface IFlow {
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
connections: IConnection[];
|
||||
@@ -104,6 +105,7 @@ export interface IFieldDropdown {
|
||||
dependsOn?: string[];
|
||||
options?: IFieldDropdownOption[];
|
||||
source?: IFieldDropdownSource;
|
||||
additionalFields?: IFieldDropdownAdditionalFields;
|
||||
}
|
||||
|
||||
export interface IFieldDropdownSource {
|
||||
@@ -114,6 +116,14 @@ export interface IFieldDropdownSource {
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
export interface IFieldDropdownAdditionalFields {
|
||||
type: string;
|
||||
name: string;
|
||||
arguments: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface IFieldDropdownOption {
|
||||
label: string;
|
||||
@@ -167,6 +177,7 @@ export interface IApp {
|
||||
flowCount?: number;
|
||||
beforeRequest?: TBeforeRequest[];
|
||||
dynamicData?: IDynamicData;
|
||||
dynamicFields?: IDynamicFields;
|
||||
triggers?: ITrigger[];
|
||||
actions?: IAction[];
|
||||
connections?: IConnection[];
|
||||
@@ -180,6 +191,10 @@ export interface IDynamicData {
|
||||
[index: string]: any;
|
||||
}
|
||||
|
||||
export interface IDynamicFields {
|
||||
[index: string]: any;
|
||||
}
|
||||
|
||||
export interface IAuth {
|
||||
generateAuthUrl?($: IGlobalVariable): Promise<void>;
|
||||
verifyCredentials?($: IGlobalVariable): Promise<void>;
|
||||
@@ -296,6 +311,7 @@ export type IGlobalVariable = {
|
||||
execution?: {
|
||||
id: string;
|
||||
testRun: boolean;
|
||||
exit: () => void;
|
||||
};
|
||||
lastExecutionStep?: IExecutionStep;
|
||||
webhookUrl?: string;
|
||||
|
@@ -9,9 +9,9 @@
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@hookform/resolvers": "^2.8.8",
|
||||
"@mui/icons-material": "^5.0.1",
|
||||
"@mui/lab": "^5.0.0-alpha.60",
|
||||
"@mui/material": "^5.0.2",
|
||||
"@mui/icons-material": "^5.11.9",
|
||||
"@mui/lab": "^5.0.0-alpha.120",
|
||||
"@mui/material": "^5.11.10",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
@@ -21,6 +21,7 @@
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"compare-versions": "^4.1.3",
|
||||
"graphql": "^15.6.0",
|
||||
@@ -38,6 +39,7 @@
|
||||
"slate-history": "^0.66.0",
|
||||
"slate-react": "^0.72.9",
|
||||
"typescript": "^4.6.3",
|
||||
"uuid": "^9.0.0",
|
||||
"web-vitals": "^1.0.1",
|
||||
"yup": "^0.32.11"
|
||||
},
|
||||
|
@@ -27,7 +27,7 @@ function ControlledAutocomplete(
|
||||
required = false,
|
||||
name,
|
||||
defaultValue,
|
||||
shouldUnregister,
|
||||
shouldUnregister = true,
|
||||
onBlur,
|
||||
onChange,
|
||||
description,
|
||||
@@ -69,7 +69,7 @@ function ControlledAutocomplete(
|
||||
},
|
||||
fieldState,
|
||||
}) => (
|
||||
<div>
|
||||
<div style={{ width:'100%' }}>
|
||||
{/* encapsulated with an element such as div to vertical spacing delegated from parent */}
|
||||
<Autocomplete
|
||||
{...autocompleteProps}
|
||||
|
56
packages/web/src/components/DeleteAccountDialog/index.ee.tsx
Normal file
56
packages/web/src/components/DeleteAccountDialog/index.ee.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
|
||||
import * as URLS from 'config/urls';
|
||||
import apolloClient from 'graphql/client';
|
||||
import { DELETE_USER } from 'graphql/mutations/delete-user.ee';
|
||||
import useAuthentication from 'hooks/useAuthentication';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useCurrentUser from 'hooks/useCurrentUser';
|
||||
|
||||
type DeleteAccountDialogProps = {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteAccountDialog(props: DeleteAccountDialogProps) {
|
||||
const [deleteUser] = useMutation(DELETE_USER);
|
||||
const formatMessage = useFormatMessage();
|
||||
const currentUser = useCurrentUser();
|
||||
const authentication = useAuthentication();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleConfirm = React.useCallback(async () => {
|
||||
await deleteUser();
|
||||
|
||||
authentication.updateToken('');
|
||||
await apolloClient.clearStore();
|
||||
|
||||
navigate(URLS.LOGIN);
|
||||
}, [deleteUser, currentUser]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={props.onClose}>
|
||||
<DialogTitle >
|
||||
{formatMessage('deleteAccountDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
{formatMessage('deleteAccountDialog.description')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose}>{formatMessage('deleteAccountDialog.cancel')}</Button>
|
||||
<Button onClick={handleConfirm} color="error">
|
||||
{formatMessage('deleteAccountDialog.confirm')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@@ -85,7 +85,6 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement {
|
||||
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="open context menu"
|
||||
ref={contextButtonRef}
|
||||
|
@@ -30,11 +30,11 @@ export const Title = styled(MuiStack)(() => ({
|
||||
gridArea: 'title',
|
||||
}));
|
||||
|
||||
export const ContextMenu = styled(MuiBox)(() => ({
|
||||
export const ContextMenu = styled(MuiBox)(({ theme }) => ({
|
||||
flexDirection: 'row',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
gap: theme.spacing(0.625),
|
||||
gridArea: 'menu',
|
||||
}));
|
||||
export const Typography = styled(MuiTypography)(() => ({
|
||||
|
@@ -0,0 +1,213 @@
|
||||
import * as React from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import type { IField, IFieldText, IFieldDropdown } from '@automatisch/types';
|
||||
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import InputCreator from 'components/InputCreator';
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
|
||||
type TGroupItem = {
|
||||
key: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type TGroup = Record<'and', TGroupItem[]>;
|
||||
|
||||
const createGroupItem = (): TGroupItem => ({
|
||||
key: '',
|
||||
operator: operators[0].value,
|
||||
value: '',
|
||||
id: uuidv4(),
|
||||
});
|
||||
|
||||
const createGroup = (): TGroup => ({
|
||||
and: [createGroupItem()]
|
||||
});
|
||||
|
||||
const operators = [
|
||||
{
|
||||
label: 'Equal',
|
||||
value: 'equal',
|
||||
},
|
||||
{
|
||||
label: 'Not Equal',
|
||||
value: 'not_equal',
|
||||
},
|
||||
{
|
||||
label: 'Greater Than',
|
||||
value: 'greater_than',
|
||||
},
|
||||
{
|
||||
label: 'Less Than',
|
||||
value: 'less_than',
|
||||
},
|
||||
{
|
||||
label: 'Greater Than Or Equal',
|
||||
value: 'greater_than_or_equal',
|
||||
},
|
||||
{
|
||||
label: 'Less Than Or Equal',
|
||||
value: 'less_than_or_equal',
|
||||
},
|
||||
{
|
||||
label: 'Contains',
|
||||
value: 'contains',
|
||||
},
|
||||
{
|
||||
label: 'Not Contains',
|
||||
value: 'not_contains',
|
||||
},
|
||||
];
|
||||
|
||||
const createStringArgument = (argumentOptions: Omit<IFieldText, 'type' | 'required' | 'variables'>): IField => {
|
||||
return {
|
||||
...argumentOptions,
|
||||
type: 'string',
|
||||
required: true,
|
||||
variables: true,
|
||||
};
|
||||
};
|
||||
|
||||
const createDropdownArgument = (argumentOptions: Omit<IFieldDropdown, 'type' | 'required'>): IField => {
|
||||
return {
|
||||
...argumentOptions,
|
||||
required: true,
|
||||
type: 'dropdown',
|
||||
};
|
||||
};
|
||||
|
||||
type FilterConditionsProps = {
|
||||
stepId: string;
|
||||
};
|
||||
|
||||
function FilterConditions(props: FilterConditionsProps): React.ReactElement {
|
||||
const {
|
||||
stepId
|
||||
} = props;
|
||||
const formatMessage = useFormatMessage();
|
||||
const { control, setValue, getValues } = useFormContext();
|
||||
const groups = useWatch({ control, name: 'parameters.or' });
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
|
||||
React.useEffect(function addInitialGroupWhenEmpty() {
|
||||
const groups = getValues('parameters.or');
|
||||
|
||||
if (!groups) {
|
||||
setValue('parameters.or', [createGroup()]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const appendGroup = React.useCallback(() => {
|
||||
const values = getValues('parameters.or');
|
||||
|
||||
setValue('parameters.or', values.concat(createGroup()))
|
||||
}, []);
|
||||
|
||||
const appendGroupItem = React.useCallback((index) => {
|
||||
const group = getValues(`parameters.or.${index}.and`);
|
||||
setValue(`parameters.or.${index}.and`, group.concat(createGroupItem()));
|
||||
}, []);
|
||||
|
||||
const removeGroupItem = React.useCallback((groupIndex, groupItemIndex) => {
|
||||
const group: TGroupItem[] = getValues(`parameters.or.${groupIndex}.and`);
|
||||
|
||||
if (group.length === 1) {
|
||||
const groups: TGroup[] = getValues('parameters.or');
|
||||
|
||||
setValue('parameters.or', groups.filter((group, index) => index !== groupIndex));
|
||||
} else {
|
||||
setValue(`parameters.or.${groupIndex}.and`, group.filter((groupItem, index) => index !== groupItemIndex));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Stack sx={{ width: "100%" }} direction="column" spacing={2} mt={2}>
|
||||
{groups?.map((group: TGroup, groupIndex: number) => (
|
||||
<>
|
||||
{groupIndex !== 0 && <Divider />}
|
||||
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{groupIndex === 0 && formatMessage('filterConditions.onlyContinueIf')}
|
||||
{groupIndex !== 0 && formatMessage('filterConditions.orContinueIf')}
|
||||
</Typography>
|
||||
|
||||
{group?.and?.map((groupItem: TGroupItem, groupItemIndex: number) => (
|
||||
<Stack direction="row" spacing={2} key={`item-${groupItem.id}`}>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 2}} sx={{ display: 'flex', flex: 1 }}>
|
||||
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
|
||||
<InputCreator
|
||||
schema={createStringArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.key`, label: 'Choose field' })}
|
||||
namePrefix="parameters"
|
||||
stepId={stepId}
|
||||
disabled={editorContext.readOnly}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
|
||||
<InputCreator
|
||||
schema={createDropdownArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.operator`, options: operators, label: 'Choose condition' })}
|
||||
namePrefix="parameters"
|
||||
stepId={stepId}
|
||||
disabled={editorContext.readOnly}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flex: '1 0 0px', maxWidth: ['100%', '33%'] }}>
|
||||
<InputCreator
|
||||
schema={createStringArgument({ key: `or.${groupIndex}.and.${groupItemIndex}.value`, label: 'Enter text' })}
|
||||
namePrefix="parameters"
|
||||
stepId={stepId}
|
||||
disabled={editorContext.readOnly}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
edge="start"
|
||||
onClick={() => removeGroupItem(groupIndex, groupItemIndex)}
|
||||
sx={{ width: 61, height: 61 }}
|
||||
>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
))}
|
||||
|
||||
<Stack spacing={1} direction="row">
|
||||
<IconButton
|
||||
size="small"
|
||||
edge="start"
|
||||
sx={{ width: 61, height: 61 }}
|
||||
onClick={() => appendGroupItem(groupIndex)}
|
||||
>
|
||||
<AddIcon /> And
|
||||
</IconButton>
|
||||
|
||||
{(groups.length - 1) === groupIndex && <IconButton
|
||||
size="small"
|
||||
edge="start"
|
||||
onClick={appendGroup}
|
||||
sx={{ width: 61, height: 61 }}
|
||||
>
|
||||
<AddIcon /> Or
|
||||
</IconButton>}
|
||||
</Stack>
|
||||
</>
|
||||
))}
|
||||
</Stack>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterConditions;
|
@@ -4,11 +4,12 @@ import Collapse from '@mui/material/Collapse';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import type { IField, IStep, ISubstep } from '@automatisch/types';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import FlowSubstepTitle from 'components/FlowSubstepTitle';
|
||||
import InputCreator from 'components/InputCreator';
|
||||
import type { IField, IStep, ISubstep } from '@automatisch/types';
|
||||
import FilterConditions from './FilterConditions';
|
||||
|
||||
type FlowSubstepProps = {
|
||||
substep: ISubstep;
|
||||
@@ -84,20 +85,25 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
|
||||
pb: 3,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
<Stack width="100%" spacing={2}>
|
||||
{args?.map((argument) => (
|
||||
<InputCreator
|
||||
key={argument.key}
|
||||
schema={argument}
|
||||
namePrefix="parameters"
|
||||
stepId={step.id}
|
||||
disabled={editorContext.readOnly}
|
||||
showOptionValue={true}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{!!args?.length && (
|
||||
<Stack width="100%" spacing={2}>
|
||||
{args.map((argument) => (
|
||||
<InputCreator
|
||||
key={argument.key}
|
||||
schema={argument}
|
||||
namePrefix="parameters"
|
||||
stepId={step.id}
|
||||
disabled={editorContext.readOnly}
|
||||
showOptionValue={true}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step.appKey === 'filter' && <FilterConditions stepId={step.id} />}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
|
68
packages/web/src/components/ForgotPasswordForm/index.ee.tsx
Normal file
68
packages/web/src/components/ForgotPasswordForm/index.ee.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
|
||||
import { FORGOT_PASSWORD } from 'graphql/mutations/forgot-password.ee';
|
||||
import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
export default function ForgotPasswordForm() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const [forgotPassword, { data, loading }] = useMutation(FORGOT_PASSWORD);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
await forgotPassword({
|
||||
variables: {
|
||||
input: values,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ px: 2, py: 4 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
align="center"
|
||||
sx={{
|
||||
borderBottom: '1px solid',
|
||||
borderColor: (theme) => theme.palette.text.disabled,
|
||||
pb: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
{formatMessage('forgotPasswordForm.title')}
|
||||
</Typography>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
label={formatMessage('forgotPasswordForm.emailFieldLabel')}
|
||||
name="email"
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2, my: 3 }}
|
||||
loading={loading}
|
||||
disabled={data}
|
||||
fullWidth
|
||||
>
|
||||
{formatMessage('forgotPasswordForm.submit')}
|
||||
</LoadingButton>
|
||||
|
||||
{data && <Typography variant="body1" sx={{ color: (theme) => theme.palette.success.main }}>
|
||||
{formatMessage('forgotPasswordForm.instructionsSent')}
|
||||
</Typography>}
|
||||
</Form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@@ -1,7 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import type { IField, IFieldDropdownOption } from '@automatisch/types';
|
||||
|
||||
import useDynamicFields from 'hooks/useDynamicFields';
|
||||
import useDynamicData from 'hooks/useDynamicData';
|
||||
import PowerInput from 'components/PowerInput';
|
||||
import TextField from 'components/TextField';
|
||||
@@ -52,58 +54,111 @@ export default function InputCreator(
|
||||
} = schema;
|
||||
|
||||
const { data, loading } = useDynamicData(stepId, schema);
|
||||
const {
|
||||
data: additionalFields,
|
||||
loading: additionalFieldsLoading
|
||||
} = useDynamicFields(stepId, schema);
|
||||
const computedName = namePrefix ? `${namePrefix}.${name}` : name;
|
||||
|
||||
if (type === 'dropdown') {
|
||||
const preparedOptions = schema.options || optionGenerator(data);
|
||||
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
name={computedName}
|
||||
dependsOn={dependsOn}
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={required}
|
||||
options={preparedOptions}
|
||||
renderInput={(params) => <MuiTextField {...params} label={label} />}
|
||||
defaultValue={value as string}
|
||||
onChange={console.log}
|
||||
description={description}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
showOptionValue={showOptionValue}
|
||||
/>
|
||||
<React.Fragment>
|
||||
<ControlledAutocomplete
|
||||
name={computedName}
|
||||
dependsOn={dependsOn}
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={required}
|
||||
options={preparedOptions}
|
||||
renderInput={(params) => <MuiTextField {...params} label={label} />}
|
||||
defaultValue={value as string}
|
||||
description={description}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
showOptionValue={showOptionValue}
|
||||
/>
|
||||
|
||||
{(additionalFieldsLoading && !additionalFields?.length) && <div>
|
||||
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||
</div>}
|
||||
|
||||
{additionalFields?.map((field) => (
|
||||
<InputCreator
|
||||
key={field.key}
|
||||
schema={field}
|
||||
namePrefix="parameters"
|
||||
stepId={stepId}
|
||||
disabled={disabled}
|
||||
showOptionValue={true}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'string') {
|
||||
if (variables) {
|
||||
return (
|
||||
<PowerInput
|
||||
label={label}
|
||||
description={description}
|
||||
name={computedName}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<React.Fragment>
|
||||
<PowerInput
|
||||
label={label}
|
||||
description={description}
|
||||
name={computedName}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{(additionalFieldsLoading && !additionalFields?.length) && <div>
|
||||
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||
</div>}
|
||||
|
||||
{additionalFields?.map((field) => (
|
||||
<InputCreator
|
||||
key={field.key}
|
||||
schema={field}
|
||||
namePrefix="parameters"
|
||||
stepId={stepId}
|
||||
disabled={disabled}
|
||||
showOptionValue={true}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
placeholder=""
|
||||
readOnly={readOnly || disabled}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
name={computedName}
|
||||
size="small"
|
||||
label={label}
|
||||
fullWidth
|
||||
helperText={description}
|
||||
clickToCopy={clickToCopy}
|
||||
/>
|
||||
<React.Fragment>
|
||||
<TextField
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
placeholder=""
|
||||
readOnly={readOnly || disabled}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
name={computedName}
|
||||
label={label}
|
||||
fullWidth
|
||||
helperText={description}
|
||||
clickToCopy={clickToCopy}
|
||||
/>
|
||||
|
||||
{(additionalFieldsLoading && !additionalFields?.length) && <div>
|
||||
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||
</div>}
|
||||
|
||||
{additionalFields?.map((field) => (
|
||||
<InputCreator
|
||||
key={field.key}
|
||||
schema={field}
|
||||
namePrefix="parameters"
|
||||
stepId={stepId}
|
||||
disabled={disabled}
|
||||
showOptionValue={true}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
41
packages/web/src/components/LiveChat/Chatwoot.ee.tsx
Normal file
41
packages/web/src/components/LiveChat/Chatwoot.ee.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import appConfig from 'config/app';
|
||||
import useCurrentUser from 'hooks/useCurrentUser';
|
||||
|
||||
type ChatwootProps = {
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
const Chatwoot = ({ ready }: ChatwootProps) => {
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
React.useEffect(function initiateChatwoot() {
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: 'EFyq5MTsvS7XwUrwSH36VskT',
|
||||
baseUrl: appConfig.chatwootBaseUrl,
|
||||
});
|
||||
|
||||
return function removeChatwoot() {
|
||||
window.$chatwoot.reset();
|
||||
window.$chatwoot.toggleBubbleVisibility('hide');
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(function initiateUser() {
|
||||
if (!currentUser?.id || !ready) return;
|
||||
|
||||
window.$chatwoot.setUser(currentUser.id, {
|
||||
email: currentUser.email,
|
||||
name: currentUser.fullName,
|
||||
});
|
||||
|
||||
window.$chatwoot.toggleBubbleVisibility("show");
|
||||
}, [currentUser, ready]);
|
||||
|
||||
return (
|
||||
<React.Fragment />
|
||||
);
|
||||
};
|
||||
|
||||
export default Chatwoot;
|
62
packages/web/src/components/LiveChat/index.ee.tsx
Normal file
62
packages/web/src/components/LiveChat/index.ee.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import appConfig from 'config/app';
|
||||
import useAuthentication from 'hooks/useAuthentication';
|
||||
import useCloud from 'hooks/useCloud';
|
||||
import Chatwoot from './Chatwoot.ee';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chatwootSDK: any;
|
||||
$chatwoot: any;
|
||||
chatwootSettings: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
const LiveChat = () => {
|
||||
const isCloud = useCloud();
|
||||
const { isAuthenticated } = useAuthentication();
|
||||
const [isLoaded, setLoaded] = React.useState(false);
|
||||
const [isReady, setReady] = React.useState(false);
|
||||
|
||||
const shouldShow = isCloud && isAuthenticated;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
window.addEventListener("chatwoot:ready", function () {
|
||||
setReady(true);
|
||||
}, { once: true });
|
||||
}, []);
|
||||
|
||||
React.useLayoutEffect(function addChatwootScript() {
|
||||
if (!shouldShow) return;
|
||||
|
||||
window.chatwootSettings = {
|
||||
hideMessageBubble: true,
|
||||
position: 'right',
|
||||
type: 'standard',
|
||||
launcherTitle: 'Give us feedback'
|
||||
};
|
||||
|
||||
const g = document.createElement('script')
|
||||
const s = document.getElementsByTagName('script')[0];
|
||||
g.src = appConfig.chatwootBaseUrl + '/packs/js/sdk.js';
|
||||
g.defer = true;
|
||||
g.async = true;
|
||||
|
||||
if (s.parentNode) {
|
||||
s.parentNode.insertBefore(g, s);
|
||||
}
|
||||
|
||||
g.onload = function () {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [shouldShow]);
|
||||
|
||||
if (!shouldShow || !isLoaded) return (<React.Fragment />);
|
||||
|
||||
return (
|
||||
<Chatwoot ready={isReady} />
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveChat;
|
@@ -1,7 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Link from '@mui/material/Link';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
|
||||
@@ -10,52 +11,11 @@ import * as URLS from 'config/urls';
|
||||
import { LOGIN } from 'graphql/mutations/login';
|
||||
import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
|
||||
function renderFields(props: { loading: boolean }) {
|
||||
const { loading = false } = props;
|
||||
|
||||
return () => {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
label="Email"
|
||||
name="email"
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
data-test="email-text-field"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="current-password"
|
||||
data-test="password-text-field"
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2, mt: 3 }}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
data-test="login-button"
|
||||
>
|
||||
Login
|
||||
</LoadingButton>
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
function LoginForm() {
|
||||
const navigate = useNavigate();
|
||||
const formatMessage = useFormatMessage();
|
||||
const authentication = useAuthentication();
|
||||
const [login, { loading }] = useMutation(LOGIN);
|
||||
|
||||
@@ -77,8 +37,6 @@ function LoginForm() {
|
||||
authentication.updateToken(token);
|
||||
};
|
||||
|
||||
const render = React.useMemo(() => renderFields({ loading }), [loading]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ px: 2, py: 4 }}>
|
||||
<Typography
|
||||
@@ -92,10 +50,60 @@ function LoginForm() {
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
Login
|
||||
{formatMessage('loginForm.title')}
|
||||
</Typography>
|
||||
|
||||
<Form onSubmit={handleSubmit} render={render} />
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
label={formatMessage('loginForm.emailFieldLabel')}
|
||||
name="email"
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
data-test="email-text-field"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('loginForm.passwordFieldLabel')}
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="current-password"
|
||||
data-test="password-text-field"
|
||||
sx={{ mb: 1 }}
|
||||
/>
|
||||
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={URLS.FORGOT_PASSWORD}
|
||||
underline="none"
|
||||
>
|
||||
{formatMessage('loginForm.forgotPasswordText')}
|
||||
</Link>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2, mt: 3 }}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
data-test="login-button"
|
||||
>
|
||||
{formatMessage('loginForm.submit')}
|
||||
</LoadingButton>
|
||||
|
||||
<Typography variant="body1" align="center" mt={3}>
|
||||
{formatMessage('loginForm.noAccount')}
|
||||
|
||||
<Link component={RouterLink} to={URLS.SIGNUP} underline="none">
|
||||
{formatMessage('loginForm.signUp')}
|
||||
</Link>
|
||||
</Typography>
|
||||
</Form>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
28
packages/web/src/components/PaymentInformation/index.ee.tsx
Normal file
28
packages/web/src/components/PaymentInformation/index.ee.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import { generateExternalLink } from 'helpers/translation-values';
|
||||
import usePaymentPortalUrl from 'hooks/usePaymentPortalUrl.ee';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
export default function PaymentInformation() {
|
||||
const paymentPortal = usePaymentPortalUrl();
|
||||
const formatMessage = useFormatMessage();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<PageTitle
|
||||
gutterBottom
|
||||
>
|
||||
{formatMessage('billingAndUsageSettings.paymentInformation')}
|
||||
</PageTitle>
|
||||
|
||||
<Typography>
|
||||
{formatMessage(
|
||||
'billingAndUsageSettings.paymentPortalInformation',
|
||||
{ link: generateExternalLink(paymentPortal.url) })}
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
@@ -81,7 +81,7 @@ const PowerInput = (props: PowerInputProps) => {
|
||||
name={name}
|
||||
control={control}
|
||||
defaultValue={defaultValue}
|
||||
shouldUnregister={false}
|
||||
shouldUnregister={true}
|
||||
render={({
|
||||
field: {
|
||||
value,
|
||||
@@ -130,7 +130,7 @@ const PowerInput = (props: PowerInputProps) => {
|
||||
/>
|
||||
</FakeInput>
|
||||
{/* ghost placer for the variables popover */}
|
||||
<div ref={editorRef} style={{ width: '100%' }} />
|
||||
<div ref={editorRef} style={{ position: 'absolute', right: 16, left: 16 }} />
|
||||
|
||||
<FormHelperText variant="outlined">{description}</FormHelperText>
|
||||
|
||||
|
122
packages/web/src/components/ResetPasswordForm/index.ee.tsx
Normal file
122
packages/web/src/components/ResetPasswordForm/index.ee.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import * as React from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
import * as URLS from 'config/urls';
|
||||
import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import { RESET_PASSWORD } from 'graphql/mutations/reset-password.ee';
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
password: yup.string().required('resetPasswordForm.mandatoryInput'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required('resetPasswordForm.mandatoryInput')
|
||||
.oneOf([yup.ref('password')], 'resetPasswordForm.passwordsMustMatch'),
|
||||
});
|
||||
|
||||
export default function ResetPasswordForm() {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const formatMessage = useFormatMessage();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [resetPassword, { data, loading }] = useMutation(RESET_PASSWORD);
|
||||
|
||||
const token = searchParams.get('token');
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
await resetPassword({
|
||||
variables: {
|
||||
input: {
|
||||
password: values.password,
|
||||
token,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
enqueueSnackbar(formatMessage('resetPasswordForm.passwordUpdated'), { variant: 'success' });
|
||||
|
||||
navigate(URLS.LOGIN);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ px: 2, py: 4 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
align="center"
|
||||
sx={{
|
||||
borderBottom: '1px solid',
|
||||
borderColor: (theme) => theme.palette.text.disabled,
|
||||
pb: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
{formatMessage('resetPasswordForm.title')}
|
||||
</Typography>
|
||||
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
resolver={yupResolver(validationSchema)}
|
||||
mode="onChange"
|
||||
render={({ formState: { errors, touchedFields } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label={formatMessage('resetPasswordForm.passwordFieldLabel')}
|
||||
name="password"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
error={touchedFields.password && !!errors?.password}
|
||||
helperText={
|
||||
touchedFields.password && errors?.password?.message
|
||||
? formatMessage(errors?.password?.message, {
|
||||
inputName: formatMessage('resetPasswordForm.passwordFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('resetPasswordForm.confirmPasswordFieldLabel')}
|
||||
name="confirmPassword"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
error={touchedFields.confirmPassword && !!errors?.confirmPassword}
|
||||
helperText={
|
||||
touchedFields.confirmPassword &&
|
||||
errors?.confirmPassword?.message
|
||||
? formatMessage(errors?.confirmPassword?.message, {
|
||||
inputName: formatMessage(
|
||||
'resetPasswordForm.confirmPasswordFieldLabel'
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2, my: 3 }}
|
||||
loading={loading}
|
||||
disabled={data || !token}
|
||||
fullWidth
|
||||
>
|
||||
{formatMessage('resetPasswordForm.submit')}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
@@ -5,8 +5,10 @@ import { useTheme } from '@mui/material/styles';
|
||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
||||
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||
import PaymentIcon from '@mui/icons-material/Payment';
|
||||
|
||||
import * as URLS from 'config/urls';
|
||||
import useAutomatischInfo from 'hooks/useAutomatischInfo';
|
||||
import AppBar from 'components/AppBar';
|
||||
import Drawer from 'components/Drawer';
|
||||
|
||||
@@ -14,13 +16,25 @@ type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const drawerLinks = [
|
||||
{
|
||||
Icon: AccountCircleIcon,
|
||||
primary: 'settingsDrawer.myProfile',
|
||||
to: URLS.SETTINGS_PROFILE,
|
||||
},
|
||||
];
|
||||
function createDrawerLinks({ isCloud }: { isCloud: boolean }) {
|
||||
const items = [
|
||||
{
|
||||
Icon: AccountCircleIcon,
|
||||
primary: 'settingsDrawer.myProfile',
|
||||
to: URLS.SETTINGS_PROFILE,
|
||||
}
|
||||
]
|
||||
|
||||
if (isCloud) {
|
||||
items.push({
|
||||
Icon: PaymentIcon,
|
||||
primary: 'settingsDrawer.billingAndUsage',
|
||||
to: URLS.SETTINGS_BILLING_AND_USAGE,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const drawerBottomLinks = [
|
||||
{
|
||||
@@ -33,6 +47,7 @@ const drawerBottomLinks = [
|
||||
export default function SettingsLayout({
|
||||
children,
|
||||
}: SettingsLayoutProps): React.ReactElement {
|
||||
const { isCloud } = useAutomatischInfo();
|
||||
const theme = useTheme();
|
||||
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'), {
|
||||
noSsr: true,
|
||||
@@ -41,6 +56,7 @@ export default function SettingsLayout({
|
||||
|
||||
const openDrawer = () => setDrawerOpen(true);
|
||||
const closeDrawer = () => setDrawerOpen(false);
|
||||
const drawerLinks = createDrawerLinks({ isCloud });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
181
packages/web/src/components/SignUpForm/index.ee.tsx
Normal file
181
packages/web/src/components/SignUpForm/index.ee.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation } from '@apollo/client';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
import useAuthentication from 'hooks/useAuthentication';
|
||||
import * as URLS from 'config/urls';
|
||||
import { CREATE_USER } from 'graphql/mutations/create-user.ee';
|
||||
import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
import { LOGIN } from 'graphql/mutations/login';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
fullName: yup.string().trim().required('signupForm.mandatoryInput'),
|
||||
email: yup
|
||||
.string()
|
||||
.trim()
|
||||
.email('signupForm.validateEmail')
|
||||
.required('signupForm.mandatoryInput'),
|
||||
password: yup.string().required('signupForm.mandatoryInput'),
|
||||
confirmPassword: yup
|
||||
.string()
|
||||
.required('signupForm.mandatoryInput')
|
||||
.oneOf([yup.ref('password')], 'signupForm.passwordsMustMatch'),
|
||||
});
|
||||
|
||||
const initialValues = {
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
};
|
||||
|
||||
function SignUpForm() {
|
||||
const navigate = useNavigate();
|
||||
const authentication = useAuthentication();
|
||||
const formatMessage = useFormatMessage();
|
||||
const [createUser] = useMutation(CREATE_USER);
|
||||
const [login, { loading }] = useMutation(LOGIN);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authentication.isAuthenticated) {
|
||||
navigate(URLS.DASHBOARD);
|
||||
}
|
||||
}, [authentication.isAuthenticated]);
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const { fullName, email, password } = values;
|
||||
await createUser({
|
||||
variables: {
|
||||
input: { fullName, email, password },
|
||||
},
|
||||
});
|
||||
|
||||
const { data } = await login({
|
||||
variables: {
|
||||
input: { email, password },
|
||||
},
|
||||
});
|
||||
|
||||
const { token } = data.login;
|
||||
|
||||
authentication.updateToken(token);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ px: 2, py: 4 }}>
|
||||
<Typography
|
||||
variant="h3"
|
||||
align="center"
|
||||
sx={{
|
||||
borderBottom: '1px solid',
|
||||
borderColor: (theme) => theme.palette.text.disabled,
|
||||
pb: 2,
|
||||
mb: 2,
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
{formatMessage('signupForm.title')}
|
||||
</Typography>
|
||||
|
||||
<Form
|
||||
defaultValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
resolver={yupResolver(validationSchema)}
|
||||
mode="onChange"
|
||||
render={({ formState: { errors, touchedFields } }) => (
|
||||
<>
|
||||
<TextField
|
||||
label={formatMessage('signupForm.fullNameFieldLabel')}
|
||||
name="fullName"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="fullName"
|
||||
data-test="fullName-text-field"
|
||||
error={touchedFields.fullName && !!errors?.fullName}
|
||||
helperText={
|
||||
touchedFields.fullName && errors?.fullName?.message
|
||||
? formatMessage(errors?.fullName?.message, {
|
||||
inputName: formatMessage('signupForm.fullNameFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('signupForm.emailFieldLabel')}
|
||||
name="email"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
autoComplete="email"
|
||||
data-test="email-text-field"
|
||||
error={touchedFields.email && !!errors?.email}
|
||||
helperText={
|
||||
touchedFields.email && errors?.email?.message
|
||||
? formatMessage(errors?.email?.message, {
|
||||
inputName: formatMessage('signupForm.emailFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('signupForm.passwordFieldLabel')}
|
||||
name="password"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
error={touchedFields.password && !!errors?.password}
|
||||
helperText={
|
||||
touchedFields.password && errors?.password?.message
|
||||
? formatMessage(errors?.password?.message, {
|
||||
inputName: formatMessage('signupForm.passwordFieldLabel'),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage('signupForm.confirmPasswordFieldLabel')}
|
||||
name="confirmPassword"
|
||||
fullWidth
|
||||
margin="dense"
|
||||
type="password"
|
||||
error={touchedFields.confirmPassword && !!errors?.confirmPassword}
|
||||
helperText={
|
||||
touchedFields.confirmPassword &&
|
||||
errors?.confirmPassword?.message
|
||||
? formatMessage(errors?.confirmPassword?.message, {
|
||||
inputName: formatMessage(
|
||||
'signupForm.confirmPasswordFieldLabel'
|
||||
),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2, mt: 3 }}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
data-test="signUp-button"
|
||||
>
|
||||
{formatMessage('signupForm.submit')}
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpForm;
|
@@ -38,9 +38,10 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
|
||||
required,
|
||||
name,
|
||||
defaultValue,
|
||||
shouldUnregister,
|
||||
clickToCopy,
|
||||
readOnly,
|
||||
shouldUnregister = true,
|
||||
clickToCopy = false,
|
||||
readOnly = false,
|
||||
disabled = false,
|
||||
onBlur,
|
||||
onChange,
|
||||
...textFieldProps
|
||||
@@ -64,6 +65,7 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
|
||||
<MuiTextField
|
||||
{...textFieldProps}
|
||||
{...field}
|
||||
disabled={disabled}
|
||||
onChange={(...args) => {
|
||||
controllerOnChange(...args);
|
||||
onChange?.(...args);
|
||||
@@ -85,10 +87,3 @@ export default function TextField(props: TextFieldProps): React.ReactElement {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
TextField.defaultProps = {
|
||||
readOnly: false,
|
||||
disabled: false,
|
||||
clickToCopy: false,
|
||||
shouldUnregister: false,
|
||||
};
|
||||
|
@@ -0,0 +1,57 @@
|
||||
import * as React from 'react';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Table from '@mui/material/Table';
|
||||
import TableBody from '@mui/material/TableBody';
|
||||
import TableCell from '@mui/material/TableCell';
|
||||
import TableContainer from '@mui/material/TableContainer';
|
||||
import TableRow from '@mui/material/TableRow';
|
||||
|
||||
import useUsageData from 'hooks/useUsageData.ee';
|
||||
|
||||
export default function UsageDataInformation() {
|
||||
const usageData = useUsageData();
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell component="td" scope="row">
|
||||
Current plan
|
||||
</TableCell>
|
||||
|
||||
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.name}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell component="td" scope="row">
|
||||
Total allowed task count
|
||||
</TableCell>
|
||||
|
||||
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.allowedTaskCount}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell component="td" scope="row">
|
||||
Consumed task count
|
||||
</TableCell>
|
||||
|
||||
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.consumedTaskCount}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
<TableRow sx={{ 'td': { border: 0 } }}>
|
||||
<TableCell component="td" scope="row">
|
||||
Next billing date
|
||||
</TableCell>
|
||||
|
||||
<TableCell align="right" sx={{ fontWeight: 500 }}>{usageData.nextResetAt?.toLocaleString(DateTime.DATE_FULL)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
@@ -6,6 +6,7 @@ const config: Config = {
|
||||
baseUrl: process.env.REACT_APP_BASE_URL as string,
|
||||
graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string,
|
||||
notificationsUrl: process.env.REACT_APP_NOTIFICATIONS_URL as string,
|
||||
chatwootBaseUrl: 'https://app.chatwoot.com',
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
@@ -5,6 +5,9 @@ export const EXECUTION = (executionId: string): string =>
|
||||
`/executions/${executionId}`;
|
||||
|
||||
export const LOGIN = '/login';
|
||||
export const SIGNUP = '/sign-up';
|
||||
export const FORGOT_PASSWORD = '/forgot-password';
|
||||
export const RESET_PASSWORD = '/reset-password';
|
||||
|
||||
export const APPS = '/apps';
|
||||
export const NEW_APP_CONNECTION = '/apps/new';
|
||||
@@ -61,8 +64,10 @@ export const FLOW_PATTERN = '/flows/:flowId';
|
||||
export const SETTINGS = '/settings';
|
||||
export const SETTINGS_DASHBOARD = SETTINGS;
|
||||
export const PROFILE = 'profile';
|
||||
export const BILLING_AND_USAGE = 'billing';
|
||||
export const UPDATES = '/updates';
|
||||
export const SETTINGS_PROFILE = `${SETTINGS}/${PROFILE}`;
|
||||
export const SETTINGS_BILLING_AND_USAGE = `${SETTINGS}/${BILLING_AND_USAGE}`;
|
||||
|
||||
export const DASHBOARD = FLOWS;
|
||||
|
||||
|
39
packages/web/src/contexts/AutomatischInfo.tsx
Normal file
39
packages/web/src/contexts/AutomatischInfo.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import { useQuery } from '@apollo/client';
|
||||
|
||||
import { GET_AUTOMATISCH_INFO } from 'graphql/queries/get-automatisch-info';
|
||||
|
||||
export type AutomatischInfoContextParams = {
|
||||
isCloud: boolean;
|
||||
};
|
||||
|
||||
export const AutomatischInfoContext =
|
||||
React.createContext<AutomatischInfoContextParams>({
|
||||
isCloud: false,
|
||||
});
|
||||
|
||||
type AutomatischInfoProviderProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const AutomatischInfoProvider = (
|
||||
props: AutomatischInfoProviderProps
|
||||
): React.ReactElement => {
|
||||
const { children } = props;
|
||||
const { data, loading } = useQuery(GET_AUTOMATISCH_INFO);
|
||||
|
||||
const isCloud = data?.getAutomatischInfo?.isCloud;
|
||||
|
||||
const value = React.useMemo(() => {
|
||||
return {
|
||||
isCloud,
|
||||
loading
|
||||
};
|
||||
}, [isCloud, loading]);
|
||||
|
||||
return (
|
||||
<AutomatischInfoContext.Provider value={value}>
|
||||
{children}
|
||||
</AutomatischInfoContext.Provider>
|
||||
);
|
||||
};
|
@@ -33,13 +33,16 @@ const createErrorLink = (callback: CreateLinkOptions['onError']): ApolloLink =>
|
||||
callback?.(message);
|
||||
}
|
||||
|
||||
console.log(
|
||||
console.error(
|
||||
`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
|
||||
);
|
||||
|
||||
if (message === NOT_AUTHORISED) {
|
||||
setItem('token', '');
|
||||
window.location.href = URLS.LOGIN;
|
||||
|
||||
if (window.location.pathname !== URLS.LOGIN) {
|
||||
window.location.href = URLS.LOGIN;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -47,12 +50,13 @@ const createErrorLink = (callback: CreateLinkOptions['onError']): ApolloLink =>
|
||||
if (autoSnackbar) {
|
||||
callback?.(networkError.toString());
|
||||
}
|
||||
console.log(`[Network error]: ${networkError}`);
|
||||
|
||||
console.error(`[Network error]: ${networkError}`);
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = () => {};
|
||||
const noop = () => { };
|
||||
|
||||
const createLink = (options: CreateLinkOptions): ApolloLink => {
|
||||
const { uri, onError = noop, token } = options;
|
||||
|
10
packages/web/src/graphql/mutations/create-user.ee.ts
Normal file
10
packages/web/src/graphql/mutations/create-user.ee.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_USER = gql`
|
||||
mutation CreateUser($input: CreateUserInput) {
|
||||
createUser(input: $input) {
|
||||
email
|
||||
fullName
|
||||
}
|
||||
}
|
||||
`;
|
7
packages/web/src/graphql/mutations/delete-user.ee.ts
Normal file
7
packages/web/src/graphql/mutations/delete-user.ee.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const DELETE_USER = gql`
|
||||
mutation DeleteUser {
|
||||
deleteUser
|
||||
}
|
||||
`;
|
7
packages/web/src/graphql/mutations/forgot-password.ee.ts
Normal file
7
packages/web/src/graphql/mutations/forgot-password.ee.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const FORGOT_PASSWORD = gql`
|
||||
mutation ForgotPassword($input: ForgotPasswordInput) {
|
||||
forgotPassword(input: $input)
|
||||
}
|
||||
`;
|
7
packages/web/src/graphql/mutations/reset-password.ee.ts
Normal file
7
packages/web/src/graphql/mutations/reset-password.ee.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const RESET_PASSWORD = gql`
|
||||
mutation ResetPassword($input: ResetPasswordInput) {
|
||||
resetPassword(input: $input)
|
||||
}
|
||||
`;
|
@@ -122,6 +122,14 @@ export const GET_APPS = gql`
|
||||
value
|
||||
}
|
||||
}
|
||||
additionalFields {
|
||||
type
|
||||
name
|
||||
arguments {
|
||||
name
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
10
packages/web/src/graphql/queries/get-automatisch-info.ts
Normal file
10
packages/web/src/graphql/queries/get-automatisch-info.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_AUTOMATISCH_INFO = gql`
|
||||
query GetAutomatischInfo {
|
||||
getAutomatischInfo {
|
||||
isCloud
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@@ -4,6 +4,7 @@ export const GET_CURRENT_USER = gql`
|
||||
query GetCurrentUser {
|
||||
getCurrentUser {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
createdAt
|
||||
updatedAt
|
||||
|
21
packages/web/src/graphql/queries/get-dynamic-fields.ts
Normal file
21
packages/web/src/graphql/queries/get-dynamic-fields.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_DYNAMIC_FIELDS = gql`
|
||||
query GetDynamicFields(
|
||||
$stepId: String!
|
||||
$key: String!
|
||||
$parameters: JSONObject
|
||||
) {
|
||||
getDynamicFields(stepId: $stepId, key: $key, parameters: $parameters) {
|
||||
label
|
||||
key
|
||||
type
|
||||
required
|
||||
description
|
||||
options {
|
||||
label
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
@@ -0,0 +1,10 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_PAYMENT_PORTAL_URL = gql`
|
||||
query GetPaymentPortalUrl {
|
||||
getPaymentPortalUrl {
|
||||
url
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
14
packages/web/src/graphql/queries/get-usage-data.ee.ts
Normal file
14
packages/web/src/graphql/queries/get-usage-data.ee.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_USAGE_DATA = gql`
|
||||
query GetUsageData {
|
||||
getUsageData {
|
||||
name
|
||||
allowedTaskCount
|
||||
consumedTaskCount
|
||||
remainingTaskCount
|
||||
nextResetAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import Link from '@mui/material/Link';
|
||||
|
||||
export const generateExternalLink = (link: string) => (str: string) =>
|
||||
(
|
||||
<a href={link} target="_blank">
|
||||
<Link href={link} target="_blank">
|
||||
{str}
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
|
14
packages/web/src/hooks/useAutomatischInfo.ts
Normal file
14
packages/web/src/hooks/useAutomatischInfo.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { AutomatischInfoContext } from 'contexts/AutomatischInfo';
|
||||
|
||||
type UseAutomatischInfoReturn = {
|
||||
isCloud: boolean;
|
||||
};
|
||||
|
||||
export default function useAutomatischInfo(): UseAutomatischInfoReturn {
|
||||
const automatischInfoContext = React.useContext(AutomatischInfoContext);
|
||||
|
||||
return {
|
||||
isCloud: automatischInfoContext.isCloud,
|
||||
};
|
||||
}
|
7
packages/web/src/hooks/useCloud.ts
Normal file
7
packages/web/src/hooks/useCloud.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import useAutomatischInfo from './useAutomatischInfo';
|
||||
|
||||
export default function useCloud(): boolean {
|
||||
const { isCloud } = useAutomatischInfo();
|
||||
|
||||
return isCloud;
|
||||
}
|
112
packages/web/src/hooks/useDynamicFields.ts
Normal file
112
packages/web/src/hooks/useDynamicFields.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import { useLazyQuery } from '@apollo/client';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import set from 'lodash/set';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import type {
|
||||
IField,
|
||||
IFieldDropdownAdditionalFields,
|
||||
IJSONObject,
|
||||
} from '@automatisch/types';
|
||||
|
||||
import { GET_DYNAMIC_FIELDS } from 'graphql/queries/get-dynamic-fields';
|
||||
|
||||
const variableRegExp = /({.*?})/g;
|
||||
|
||||
// TODO: extract this function to a separate file
|
||||
function computeArguments(
|
||||
args: IFieldDropdownAdditionalFields['arguments'],
|
||||
getValues: UseFormReturn['getValues']
|
||||
): IJSONObject {
|
||||
const initialValue = {};
|
||||
return args.reduce((result, { name, value }) => {
|
||||
const isVariable = variableRegExp.test(value);
|
||||
|
||||
if (isVariable) {
|
||||
const sanitizedFieldPath = value.replace(/{|}/g, '');
|
||||
const computedValue = getValues(sanitizedFieldPath);
|
||||
|
||||
if (computedValue === undefined)
|
||||
throw new Error(`The ${sanitizedFieldPath} field is required.`);
|
||||
|
||||
set(result, name, computedValue);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
set(result, name, value);
|
||||
|
||||
return result;
|
||||
}, initialValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the dynamic fields for the given step.
|
||||
* This hook must be within a react-hook-form context.
|
||||
*
|
||||
* @param stepId - the id of the step
|
||||
* @param schema - the field schema that needs the dynamic fields
|
||||
*/
|
||||
function useDynamicFields(stepId: string | undefined, schema: IField) {
|
||||
const lastComputedVariables = React.useRef({});
|
||||
const [getDynamicFields, { called, data, loading }] =
|
||||
useLazyQuery(GET_DYNAMIC_FIELDS);
|
||||
const { getValues } = useFormContext();
|
||||
const formValues = getValues();
|
||||
|
||||
/**
|
||||
* Return `null` when even a field is missing value.
|
||||
*
|
||||
* This must return the same reference if no computed variable is changed.
|
||||
* Otherwise, it causes redundant network request!
|
||||
*/
|
||||
const computedVariables = React.useMemo(() => {
|
||||
if (schema.type === 'dropdown' && schema.additionalFields) {
|
||||
try {
|
||||
const variables = computeArguments(schema.additionalFields.arguments, getValues);
|
||||
|
||||
// if computed variables are the same, return the last computed variables.
|
||||
if (isEqual(variables, lastComputedVariables.current)) {
|
||||
return lastComputedVariables.current;
|
||||
}
|
||||
|
||||
lastComputedVariables.current = variables;
|
||||
|
||||
return variables;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
/**
|
||||
* `formValues` is to trigger recomputation when form is updated.
|
||||
* `getValues` is for convenience as it supports paths for fields like `getValues('foo.bar.baz')`.
|
||||
*/
|
||||
}, [schema, formValues, getValues]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
schema.type === 'dropdown' &&
|
||||
stepId &&
|
||||
schema.additionalFields &&
|
||||
computedVariables
|
||||
) {
|
||||
getDynamicFields({
|
||||
variables: {
|
||||
stepId,
|
||||
...computedVariables,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [getDynamicFields, stepId, schema, computedVariables]);
|
||||
|
||||
return {
|
||||
called,
|
||||
data: data?.getDynamicFields as IField[] | undefined,
|
||||
loading,
|
||||
};
|
||||
}
|
||||
|
||||
export default useDynamicFields;
|
16
packages/web/src/hooks/usePaymentPortalUrl.ee.ts
Normal file
16
packages/web/src/hooks/usePaymentPortalUrl.ee.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { GET_PAYMENT_PORTAL_URL } from 'graphql/queries/get-payment-portal-url.ee';
|
||||
|
||||
type UsePaymentPortalUrlReturn = {
|
||||
url: string;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export default function usePaymentPortalUrl(): UsePaymentPortalUrlReturn {
|
||||
const { data, loading } = useQuery(GET_PAYMENT_PORTAL_URL);
|
||||
|
||||
return {
|
||||
url: data?.getPaymentPortalUrl?.url,
|
||||
loading
|
||||
};
|
||||
}
|
30
packages/web/src/hooks/useUsageData.ee.ts
Normal file
30
packages/web/src/hooks/useUsageData.ee.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@apollo/client';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { GET_USAGE_DATA } from 'graphql/queries/get-usage-data.ee';
|
||||
|
||||
type UseUsageDataReturn = {
|
||||
name: string;
|
||||
allowedTaskCount: number;
|
||||
consumedTaskCount: number;
|
||||
remainingTaskCount: number;
|
||||
nextResetAt: DateTime;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
export default function useUsageData(): UseUsageDataReturn {
|
||||
const { data, loading } = useQuery(GET_USAGE_DATA);
|
||||
|
||||
const usageData = data?.getUsageData;
|
||||
const nextResetAt = usageData?.nextResetAt;
|
||||
const nextResetAtDateTimeObject = nextResetAt && DateTime.fromMillis(Number(nextResetAt));
|
||||
|
||||
return {
|
||||
name: usageData?.name,
|
||||
allowedTaskCount: usageData?.allowedTaskCount,
|
||||
consumedTaskCount: usageData?.consumedTaskCount,
|
||||
remainingTaskCount: usageData?.remainingTaskCount,
|
||||
nextResetAt: nextResetAtDateTimeObject,
|
||||
loading
|
||||
};
|
||||
}
|
@@ -5,7 +5,9 @@ import IntlProvider from 'components/IntlProvider';
|
||||
import ApolloProvider from 'components/ApolloProvider';
|
||||
import SnackbarProvider from 'components/SnackbarProvider';
|
||||
import { AuthenticationProvider } from 'contexts/Authentication';
|
||||
import { AutomatischInfoProvider } from 'contexts/AutomatischInfo';
|
||||
import Router from 'components/Router';
|
||||
import LiveChat from 'components/LiveChat/index.ee';
|
||||
import routes from 'routes';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
@@ -13,11 +15,15 @@ ReactDOM.render(
|
||||
<SnackbarProvider>
|
||||
<AuthenticationProvider>
|
||||
<ApolloProvider>
|
||||
<IntlProvider>
|
||||
<ThemeProvider>
|
||||
<Router>{routes}</Router>
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
<AutomatischInfoProvider>
|
||||
<IntlProvider>
|
||||
<ThemeProvider>
|
||||
<Router>{routes}</Router>
|
||||
|
||||
<LiveChat />
|
||||
</ThemeProvider>
|
||||
</IntlProvider>
|
||||
</AutomatischInfoProvider>
|
||||
</ApolloProvider>
|
||||
</AuthenticationProvider>
|
||||
</SnackbarProvider>,
|
||||
|
@@ -11,6 +11,7 @@
|
||||
"settingsDrawer.myProfile": "My Profile",
|
||||
"settingsDrawer.goBack": "Go to the dashboard",
|
||||
"settingsDrawer.notifications": "Notifications",
|
||||
"settingsDrawer.billingAndUsage": "Billing and usage",
|
||||
"app.connectionCount": "{count} connections",
|
||||
"app.flowCount": "{count} flows",
|
||||
"app.addConnection": "Add connection",
|
||||
@@ -58,6 +59,8 @@
|
||||
"flowEditor.triggerEvent": "Trigger event",
|
||||
"flowEditor.actionEvent": "Action event",
|
||||
"flowEditor.instantTriggerType": "Instant",
|
||||
"filterConditions.onlyContinueIf": "Only continue if…",
|
||||
"filterConditions.orContinueIf": "OR continue if…",
|
||||
"chooseConnectionSubstep.continue": "Continue",
|
||||
"chooseConnectionSubstep.addNewConnection": "Add new connection",
|
||||
"chooseConnectionSubstep.chooseConnection": "Choose connection",
|
||||
@@ -87,10 +90,51 @@
|
||||
"profileSettings.updatedEmail": "Your email has been updated.",
|
||||
"profileSettings.updatedPassword": "Your password has been updated.",
|
||||
"profileSettings.updatePassword": "Update password",
|
||||
"profileSettings.deleteMyAccount": "Delete my account",
|
||||
"profileSettings.deleteAccount": "Delete account",
|
||||
"profileSettings.deleteAccountSubtitle": "This will permanently delete...",
|
||||
"profileSettings.deleteAccountResult1": "Your account",
|
||||
"profileSettings.deleteAccountResult2": "All your flows",
|
||||
"profileSettings.deleteAccountResult3": "All your connections",
|
||||
"profileSettings.deleteAccountResult4": "All execution history",
|
||||
"billingAndUsageSettings.title": "Billing and usage",
|
||||
"billingAndUsageSettings.paymentInformation": "Payment information",
|
||||
"billingAndUsageSettings.paymentPortalInformation": "To manage your subscription, click <link>here</link> to go to the payment portal.",
|
||||
"deleteAccountDialog.title": "Delete account?",
|
||||
"deleteAccountDialog.description": "This will permanently delete your account and all the associated data with it.",
|
||||
"deleteAccountDialog.cancel": "Cancel?",
|
||||
"deleteAccountDialog.confirm": "Yes, delete it",
|
||||
"notifications.title": "Notifications",
|
||||
"notification.releasedAt": "Released {relativeDate}",
|
||||
"webhookUrlInfo.title": "Your webhook URL",
|
||||
"webhookUrlInfo.description": "You'll need to configure your application with this webhook URL.",
|
||||
"webhookUrlInfo.helperText": "We've generated a custom webhook URL for you to send requests to. <link>Learn more about webhooks</link>.",
|
||||
"webhookUrlInfo.copy": "Copy"
|
||||
"webhookUrlInfo.copy": "Copy",
|
||||
"signupForm.title": "Sign up",
|
||||
"signupForm.fullNameFieldLabel": "Full name",
|
||||
"signupForm.emailFieldLabel": "Email",
|
||||
"signupForm.passwordFieldLabel": "Password",
|
||||
"signupForm.confirmPasswordFieldLabel": "Confirm password",
|
||||
"signupForm.submit": "Sign up",
|
||||
"signupForm.validateEmail": "Email must be valid.",
|
||||
"signupForm.passwordsMustMatch": "Passwords must match.",
|
||||
"signupForm.mandatoryInput": "{inputName} is required.",
|
||||
"loginForm.title": "Login",
|
||||
"loginForm.emailFieldLabel": "Email",
|
||||
"loginForm.passwordFieldLabel": "Password",
|
||||
"loginForm.forgotPasswordText": "Forgot password?",
|
||||
"loginForm.submit": "Login",
|
||||
"loginForm.noAccount": "Don't have an Automatisch account yet?",
|
||||
"loginForm.signUp": "Sign up",
|
||||
"forgotPasswordForm.title": "Forgot password",
|
||||
"forgotPasswordForm.submit": "Send reset instructions",
|
||||
"forgotPasswordForm.instructionsSent": "The instructions have been sent!",
|
||||
"forgotPasswordForm.emailFieldLabel": "Email",
|
||||
"resetPasswordForm.passwordsMustMatch": "Passwords must match.",
|
||||
"resetPasswordForm.mandatoryInput": "{inputName} is required.",
|
||||
"resetPasswordForm.title": "Reset password",
|
||||
"resetPasswordForm.submit": "Reset password",
|
||||
"resetPasswordForm.passwordFieldLabel": "Password",
|
||||
"resetPasswordForm.confirmPasswordFieldLabel": "Confirm password",
|
||||
"resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login."
|
||||
}
|
45
packages/web/src/pages/BillingAndUsageSettings/index.ee.tsx
Normal file
45
packages/web/src/pages/BillingAndUsageSettings/index.ee.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import Grid from '@mui/material/Grid';
|
||||
|
||||
import * as URLS from 'config/urls'
|
||||
import PaymentInformation from 'components/PaymentInformation/index.ee';
|
||||
import UsageDataInformation from 'components/UsageDataInformation/index.ee';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import Container from 'components/Container';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useCloud from 'hooks/useCloud';
|
||||
|
||||
function BillingAndUsageSettings() {
|
||||
const isCloud = useCloud();
|
||||
const formatMessage = useFormatMessage();
|
||||
|
||||
// redirect to the initial settings page
|
||||
if (isCloud === false) {
|
||||
return (<Navigate to={URLS.SETTINGS} replace={true} />)
|
||||
}
|
||||
|
||||
// render nothing until we know if it's cloud or not
|
||||
// here, `isCloud` is not `false`, but `undefined`
|
||||
if (!isCloud) return <React.Fragment />
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
<Grid container item xs={12} sm={9} md={8} lg={6}>
|
||||
<Grid item xs={12} sx={{ mb: [2, 5] }}>
|
||||
<PageTitle>{formatMessage('billingAndUsageSettings.title')}</PageTitle>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sx={{ mb: 6 }}>
|
||||
<UsageDataInformation />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<PaymentInformation />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillingAndUsageSettings;
|
14
packages/web/src/pages/ForgotPassword/index.ee.tsx
Normal file
14
packages/web/src/pages/ForgotPassword/index.ee.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Container from 'components/Container';
|
||||
import ForgotPasswordForm from 'components/ForgotPasswordForm/index.ee';
|
||||
|
||||
export default function ForgotPassword(): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flex: 1, alignItems: 'center' }}>
|
||||
<Container maxWidth="sm">
|
||||
<ForgotPasswordForm />
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user