Merge branch 'automatisch:main' into feature/signalwire-integration

This commit is contained in:
Sebastian Schumann
2023-03-09 01:51:13 +01:00
committed by GitHub
108 changed files with 2754 additions and 11222 deletions

View File

@@ -12,6 +12,8 @@ export async function createUser(
const userParams = {
email,
password,
fullName: 'Initial admin',
role: 'admin',
};
try {

View File

@@ -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": [

View 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,
}
});
},
});

View File

@@ -0,0 +1,3 @@
import continueIfMatches from './continue';
export default [continueIfMatches];

View 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

View File

View 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,
});

View File

@@ -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) {

View File

@@ -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}',
},
],
},
},
],

View File

@@ -0,0 +1,3 @@
import listFieldsAfterSendAsBot from './send-as-bot';
export default [listFieldsAfterSendAsBot];

View File

@@ -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,
},
];
}
},
};

View File

@@ -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,
});

View File

@@ -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,
};

View 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);
}
};

View File

@@ -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');
});
}

View File

@@ -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');
}

View File

@@ -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');
}

View File

@@ -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();
});
}

View File

@@ -0,0 +1,3 @@
import BaseError from './base';
export default class AlreadyProcessedError extends BaseError { }

View File

@@ -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,

View File

@@ -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;
};

View 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;

View File

@@ -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;

View File

@@ -24,7 +24,7 @@ const resetPassword = async (_parent: unknown, params: Params) => {
await user.resetPassword(password);
return;
return true;
};
export default resetPassword;

View File

@@ -0,0 +1,9 @@
import appConfig from '../../config/app';
const getAutomatischInfo = async () => {
return {
isCloud: appConfig.isCloud,
};
};
export default getAutomatischInfo;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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,
},
},
{

View 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;

View 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;

View File

@@ -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,

View File

@@ -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,
});

View File

@@ -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);

View File

@@ -28,7 +28,7 @@ const graphQLInstance = graphqlHTTP({
delete (error.originalError as HttpError).response;
}
return error.originalError;
return error;
},
});

View File

@@ -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

View File

@@ -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();
}
}
}
}

View File

@@ -10,6 +10,7 @@ class Execution extends Base {
testRun: boolean;
internalId: string;
executionSteps: ExecutionStep[];
flow?: Flow;
static tableName = 'executions';

View File

@@ -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() {

View 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;

View File

@@ -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;

View 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;

View File

@@ -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) {

View 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;

View File

@@ -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;

View 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;

View File

@@ -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 };
}
}
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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');

View File

@@ -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 }

View 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();
});

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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"
},

View File

@@ -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}

View 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>
);
}

View File

@@ -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}

View File

@@ -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)(() => ({

View File

@@ -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;

View File

@@ -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

View 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>
);
}

View File

@@ -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>
);
}

View 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;

View 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;

View File

@@ -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')}
&nbsp;
<Link component={RouterLink} to={URLS.SIGNUP} underline="none">
{formatMessage('loginForm.signUp')}
</Link>
</Typography>
</Form>
</Paper>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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 (
<>

View 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;

View File

@@ -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,
};

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View 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>
);
};

View File

@@ -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;

View File

@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput) {
createUser(input: $input) {
email
fullName
}
}
`;

View File

@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const DELETE_USER = gql`
mutation DeleteUser {
deleteUser
}
`;

View File

@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const FORGOT_PASSWORD = gql`
mutation ForgotPassword($input: ForgotPasswordInput) {
forgotPassword(input: $input)
}
`;

View File

@@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const RESET_PASSWORD = gql`
mutation ResetPassword($input: ResetPasswordInput) {
resetPassword(input: $input)
}
`;

View File

@@ -122,6 +122,14 @@ export const GET_APPS = gql`
value
}
}
additionalFields {
type
name
arguments {
name
value
}
}
}
}
}

View File

@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const GET_AUTOMATISCH_INFO = gql`
query GetAutomatischInfo {
getAutomatischInfo {
isCloud
}
}
`;

View File

@@ -4,6 +4,7 @@ export const GET_CURRENT_USER = gql`
query GetCurrentUser {
getCurrentUser {
id
fullName
email
createdAt
updatedAt

View 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
}
}
}
`;

View File

@@ -0,0 +1,10 @@
import { gql } from '@apollo/client';
export const GET_PAYMENT_PORTAL_URL = gql`
query GetPaymentPortalUrl {
getPaymentPortalUrl {
url
}
}
`;

View File

@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_USAGE_DATA = gql`
query GetUsageData {
getUsageData {
name
allowedTaskCount
consumedTaskCount
remainingTaskCount
nextResetAt
}
}
`;

View File

@@ -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>
);

View 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,
};
}

View File

@@ -0,0 +1,7 @@
import useAutomatischInfo from './useAutomatischInfo';
export default function useCloud(): boolean {
const { isCloud } = useAutomatischInfo();
return isCloud;
}

View 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;

View 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
};
}

View 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
};
}

View File

@@ -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>,

View File

@@ -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."
}

View 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;

View 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