Compare commits

..

2 Commits

Author SHA1 Message Date
Ali BARIN
96544df7d5 refactor(role): remove transactions and tidy up logic in model (#2141)
* refactor(role): remove returning this in model methods

* refactor(role): assert altering admin in model before update and delete

* refactor(role): rename overridePermissions with updatePermissions in model

* refactor(role): remove transactions in model

* refactor(role): remove transactions in model

* refactor(role): return with permissions upon update in model

* fix(role): assert admin check on old instance in model

* refactor(role): fetch and use current role in preventAlteringAdmin
2024-10-28 14:57:33 +01:00
Ali BARIN
036db63a33 test(role): write model tests 2024-10-28 08:22:07 +00:00
69 changed files with 17497 additions and 21146 deletions

View File

@@ -5,11 +5,8 @@ BACKEND_PORT=3000
WEB_PORT=3001 WEB_PORT=3001
echo "Configuring backend environment variables..." echo "Configuring backend environment variables..."
cd packages/backend cd packages/backend
rm -rf .env rm -rf .env
echo " echo "
PORT=$BACKEND_PORT PORT=$BACKEND_PORT
WEB_APP_URL=http://localhost:$WEB_PORT WEB_APP_URL=http://localhost:$WEB_PORT
@@ -24,35 +21,24 @@ WEBHOOK_SECRET_KEY=sample_webhook_secret_key
APP_SECRET_KEY=sample_app_secret_key APP_SECRET_KEY=sample_app_secret_key
REDIS_HOST=redis REDIS_HOST=redis
SERVE_WEB_APP_SEPARATELY=true" >> .env SERVE_WEB_APP_SEPARATELY=true" >> .env
echo "Installing backend dependencies..."
yarn
cd $CURRENT_DIR cd $CURRENT_DIR
echo "Configuring web environment variables..." echo "Configuring web environment variables..."
cd packages/web cd packages/web
rm -rf .env rm -rf .env
echo " echo "
PORT=$WEB_PORT PORT=$WEB_PORT
REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT
" >> .env " >> .env
echo "Installing web dependencies..."
yarn
cd $CURRENT_DIR cd $CURRENT_DIR
echo "Installing and linking dependencies..."
yarn
yarn lerna bootstrap
echo "Migrating database..." echo "Migrating database..."
cd packages/backend cd packages/backend
yarn db:migrate yarn db:migrate
yarn db:seed:user yarn db:seed:user
echo "Done!" echo "Done!"

View File

@@ -41,11 +41,8 @@ jobs:
with: with:
node-version: 18 node-version: 18
- name: Install dependencies - name: Install dependencies
run: yarn run: cd packages/backend && yarn
working-directory: packages/backend
- name: Copy .env-example.test file to .env.test - name: Copy .env-example.test file to .env.test
run: cp .env-example.test .env.test run: cd packages/backend && cp .env-example.test .env.test
working-directory: packages/backend
- name: Run tests - name: Run tests
run: yarn test:coverage run: cd packages/backend && yarn test
working-directory: packages/backend

View File

@@ -18,13 +18,11 @@ jobs:
with: with:
node-version: '18' node-version: '18'
cache: 'yarn' cache: 'yarn'
cache-dependency-path: packages/backend/yarn.lock cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
working-directory: packages/backend - run: cd packages/backend && yarn lint
- run: yarn lint
working-directory: packages/backend
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."
start-backend-server: start-backend-server:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -37,13 +35,11 @@ jobs:
with: with:
node-version: '18' node-version: '18'
cache: 'yarn' cache: 'yarn'
cache-dependency-path: packages/backend/yarn.lock cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile && yarn lerna bootstrap
working-directory: packages/backend - run: cd packages/backend && yarn start
- run: yarn start
working-directory: packages/backend
env: env:
ENCRYPTION_KEY: sample_encryption_key ENCRYPTION_KEY: sample_encryption_key
WEBHOOK_SECRET_KEY: sample_webhook_secret_key WEBHOOK_SECRET_KEY: sample_webhook_secret_key
@@ -59,13 +55,11 @@ jobs:
with: with:
node-version: '18' node-version: '18'
cache: 'yarn' cache: 'yarn'
cache-dependency-path: packages/backend/yarn.lock cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile && yarn lerna bootstrap
working-directory: packages/backend - run: cd packages/backend && yarn start:worker
- run: yarn start:worker
working-directory: packages/backend
env: env:
ENCRYPTION_KEY: sample_encryption_key ENCRYPTION_KEY: sample_encryption_key
WEBHOOK_SECRET_KEY: sample_webhook_secret_key WEBHOOK_SECRET_KEY: sample_webhook_secret_key
@@ -81,13 +75,11 @@ jobs:
with: with:
node-version: '18' node-version: '18'
cache: 'yarn' cache: 'yarn'
cache-dependency-path: packages/web/yarn.lock cache-dependency-path: yarn.lock
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile && yarn lerna bootstrap
working-directory: packages/web - run: cd packages/web && yarn build
- run: yarn build
working-directory: packages/web
env: env:
CI: false CI: false
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."

View File

@@ -58,21 +58,13 @@ jobs:
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 18 node-version: 18
- name: Install web dependencies - name: Install dependencies
run: yarn run: yarn && yarn lerna bootstrap
working-directory: ./packages/web
- name: Install backend dependencies
run: yarn
working-directory: ./packages/backend
- name: Install e2e-tests dependencies
run: yarn
working-directory: ./packages/e2e-tests
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
working-directory: ./packages/e2e-tests
- name: Build Automatisch web - name: Build Automatisch web
run: yarn build
working-directory: ./packages/web working-directory: ./packages/web
run: yarn build
env: env:
# Keep this until we clean up warnings in build processes # Keep this until we clean up warnings in build processes
CI: false CI: false

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ logs
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)

View File

@@ -11,12 +11,10 @@ WORKDIR /automatisch
# copy the app, note .dockerignore # copy the app, note .dockerignore
COPY . /automatisch COPY . /automatisch
RUN cd packages/web && yarn RUN yarn
RUN cd packages/web && yarn build RUN cd packages/web && yarn build
RUN cd packages/backend && yarn --production
RUN \ RUN \
rm -rf /usr/local/share/.cache/ && \ rm -rf /usr/local/share/.cache/ && \
apk del build-dependencies apk del build-dependencies

13
lerna.json Normal file
View File

@@ -0,0 +1,13 @@
{
"packages": [
"packages/*"
],
"version": "0.10.0",
"npmClient": "yarn",
"useWorkspaces": true,
"command": {
"add": {
"exact": true
}
}
}

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "@automatisch/root",
"license": "See LICENSE file",
"private": true,
"scripts": {
"start": "lerna run --stream --parallel --scope=@*/{web,backend} dev",
"start:web": "lerna run --stream --scope=@*/web dev",
"start:backend": "lerna run --stream --scope=@*/backend dev",
"build:docs": "cd ./packages/docs && yarn install && yarn build"
},
"workspaces": {
"packages": [
"packages/*"
],
"nohoist": [
"**/babel-loader",
"**/webpack",
"**/@automatisch/web",
"**/ajv"
]
},
"devDependencies": {
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"lerna": "^4.0.0",
"prettier": "^2.5.1"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -12,7 +12,6 @@
"pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js", "pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js",
"test": "APP_ENV=test vitest run", "test": "APP_ENV=test vitest run",
"test:watch": "APP_ENV=test vitest watch", "test:watch": "APP_ENV=test vitest watch",
"test:coverage": "yarn test --coverage",
"lint": "eslint .", "lint": "eslint .",
"db:create": "node ./bin/database/create.js", "db:create": "node ./bin/database/create.js",
"db:seed:user": "node ./bin/database/seed-user.js", "db:seed:user": "node ./bin/database/seed-user.js",
@@ -24,7 +23,6 @@
"dependencies": { "dependencies": {
"@bull-board/express": "^3.10.1", "@bull-board/express": "^3.10.1",
"@casl/ability": "^6.5.0", "@casl/ability": "^6.5.0",
"@faker-js/faker": "^9.2.0",
"@node-saml/passport-saml": "^4.0.4", "@node-saml/passport-saml": "^4.0.4",
"@rudderstack/rudder-sdk-node": "^1.1.2", "@rudderstack/rudder-sdk-node": "^1.1.2",
"@sentry/node": "^7.42.0", "@sentry/node": "^7.42.0",
@@ -38,9 +36,6 @@
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"debug": "~2.6.9", "debug": "~2.6.9",
"dotenv": "^10.0.0", "dotenv": "^10.0.0",
"eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"express": "~4.18.2", "express": "~4.18.2",
"express-async-errors": "^3.1.1", "express-async-errors": "^3.1.1",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
@@ -66,7 +61,6 @@
"pg": "^8.7.1", "pg": "^8.7.1",
"php-serialize": "^4.0.2", "php-serialize": "^4.0.2",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"prettier": "^2.5.1",
"raw-body": "^2.5.2", "raw-body": "^2.5.2",
"showdown": "^2.1.0", "showdown": "^2.1.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
@@ -98,11 +92,10 @@
"url": "https://github.com/automatisch/automatisch/issues" "url": "https://github.com/automatisch/automatisch/issues"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2.1.5",
"node-gyp": "^10.1.0", "node-gyp": "^10.1.0",
"nodemon": "^2.0.13", "nodemon": "^2.0.13",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"vitest": "^2.1.5" "vitest": "^1.1.3"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"

View File

@@ -8,7 +8,7 @@ export default {
key: 'instanceUrl', key: 'instanceUrl',
label: 'WordPress instance URL', label: 'WordPress instance URL',
type: 'string', type: 'string',
required: true, required: false,
readOnly: false, readOnly: false,
value: null, value: null,
placeholder: null, placeholder: null,

View File

@@ -52,7 +52,7 @@ const appConfig = {
isDev: appEnv === 'development', isDev: appEnv === 'development',
isTest: appEnv === 'test', isTest: appEnv === 'test',
isProd: appEnv === 'production', isProd: appEnv === 'production',
version: '0.14.0', version: '0.13.1',
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development', postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
postgresSchema: process.env.POSTGRES_SCHEMA || 'public', postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'), postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),

View File

@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
const expectedPayload = { const expectedPayload = {
data: { data: {
version: '0.14.0', version: '0.13.1',
}, },
meta: { meta: {
count: 1, count: 1,

View File

@@ -1,11 +1,11 @@
import { renderObject } from '../../../../helpers/renderer.js'; import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => { export default async (request, response) => {
const flow = await request.currentUser.$relatedQuery('flows').insertAndFetch({ let flow = await request.currentUser.$relatedQuery('flows').insert({
name: 'Name your flow', name: 'Name your flow',
}); });
await flow.createInitialSteps(); flow = await flow.createInitialSteps();
renderObject(response, flow, { status: 201 }); renderObject(response, flow, { status: 201 });
}; };

View File

@@ -6,7 +6,7 @@ export default async (request, response) => {
.findById(request.params.flowId) .findById(request.params.flowId)
.throwIfNotFound(); .throwIfNotFound();
const createdActionStep = await flow.createStepAfter( const createdActionStep = await flow.createActionStep(
request.body.previousStepId request.body.previousStepId
); );

View File

@@ -1,42 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Flow model > jsonSchema should have correct validations 1`] = `
{
"properties": {
"active": {
"type": "boolean",
},
"createdAt": {
"type": "string",
},
"deletedAt": {
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"name": {
"minLength": 1,
"type": "string",
},
"publishedAt": {
"type": "string",
},
"remoteWebhookId": {
"type": "string",
},
"updatedAt": {
"type": "string",
},
"userId": {
"format": "uuid",
"type": "string",
},
},
"required": [
"name",
],
"type": "object",
}
`;

View File

@@ -1,72 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SamlAuthProvider model > jsonSchema should have the correct schema 1`] = `
{
"properties": {
"active": {
"type": "boolean",
},
"certificate": {
"minLength": 1,
"type": "string",
},
"defaultRoleId": {
"format": "uuid",
"type": "string",
},
"emailAttributeName": {
"minLength": 1,
"type": "string",
},
"entryPoint": {
"minLength": 1,
"type": "string",
},
"firstnameAttributeName": {
"minLength": 1,
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"issuer": {
"minLength": 1,
"type": "string",
},
"name": {
"minLength": 1,
"type": "string",
},
"roleAttributeName": {
"minLength": 1,
"type": "string",
},
"signatureAlgorithm": {
"enum": [
"sha1",
"sha256",
"sha512",
],
"type": "string",
},
"surnameAttributeName": {
"minLength": 1,
"type": "string",
},
},
"required": [
"name",
"certificate",
"signatureAlgorithm",
"entryPoint",
"issuer",
"firstnameAttributeName",
"surnameAttributeName",
"emailAttributeName",
"roleAttributeName",
"defaultRoleId",
],
"type": "object",
}
`;

View File

@@ -28,3 +28,14 @@ exports[`SamlAuthProvidersRoleMapping model > jsonSchema should have the correct
"type": "object", "type": "object",
} }
`; `;
exports[`SamlAuthProvidersRoleMapping model > relationMappings should have samlAuthProvider relation 1`] = `
{
"join": {
"from": "saml_auth_providers_role_mappings.saml_auth_provider_id",
"to": "saml_auth_providers.id",
},
"modelClass": [Function],
"relation": [Function],
}
`;

View File

@@ -1,77 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Step model > jsonSchema should have correct validations 1`] = `
{
"properties": {
"appKey": {
"maxLength": 255,
"minLength": 1,
"type": [
"string",
"null",
],
},
"connectionId": {
"format": "uuid",
"type": [
"string",
"null",
],
},
"createdAt": {
"type": "string",
},
"deletedAt": {
"type": "string",
},
"flowId": {
"format": "uuid",
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"key": {
"type": [
"string",
"null",
],
},
"parameters": {
"type": "object",
},
"position": {
"type": "integer",
},
"status": {
"default": "incomplete",
"enum": [
"incomplete",
"completed",
],
"type": "string",
},
"type": {
"enum": [
"action",
"trigger",
],
"type": "string",
},
"updatedAt": {
"type": "string",
},
"webhookPath": {
"type": [
"string",
"null",
],
},
},
"required": [
"type",
],
"type": "object",
}
`;

View File

@@ -1,81 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`User model > jsonSchema should have correct validations 1`] = `
{
"properties": {
"createdAt": {
"type": "string",
},
"deletedAt": {
"type": "string",
},
"email": {
"format": "email",
"maxLength": 255,
"minLength": 1,
"type": "string",
},
"fullName": {
"minLength": 1,
"type": "string",
},
"id": {
"format": "uuid",
"type": "string",
},
"invitationToken": {
"type": [
"string",
"null",
],
},
"invitationTokenSentAt": {
"format": "date-time",
"type": [
"string",
"null",
],
},
"password": {
"minLength": 6,
"type": "string",
},
"resetPasswordToken": {
"type": [
"string",
"null",
],
},
"resetPasswordTokenSentAt": {
"format": "date-time",
"type": [
"string",
"null",
],
},
"roleId": {
"format": "uuid",
"type": "string",
},
"status": {
"default": "active",
"enum": [
"active",
"invited",
],
"type": "string",
},
"trialExpiryDate": {
"type": "string",
},
"updatedAt": {
"type": "string",
},
},
"required": [
"fullName",
"email",
],
"type": "object",
}
`;

View File

@@ -88,13 +88,15 @@ class Flow extends Base {
}, },
}); });
static async populateStatusProperty(flows) { static async afterFind(args) {
const referenceFlow = flows[0]; const { result } = args;
const referenceFlow = result[0];
if (referenceFlow) { if (referenceFlow) {
const shouldBePaused = await referenceFlow.isPaused(); const shouldBePaused = await referenceFlow.isPaused();
for (const flow of flows) { for (const flow of result) {
if (!flow.active) { if (!flow.active) {
flow.status = 'draft'; flow.status = 'draft';
} else if (flow.active && shouldBePaused) { } else if (flow.active && shouldBePaused) {
@@ -106,10 +108,6 @@ class Flow extends Base {
} }
} }
static async afterFind(args) {
await this.populateStatusProperty(args.result);
}
async lastInternalId() { async lastInternalId() {
const lastExecution = await this.$relatedQuery('lastExecution'); const lastExecution = await this.$relatedQuery('lastExecution');
@@ -125,14 +123,13 @@ class Flow extends Base {
return lastExecutions.map((execution) => execution.internalId); return lastExecutions.map((execution) => execution.internalId);
} }
static get IncompleteStepsError() { get IncompleteStepsError() {
return new ValidationError({ return new ValidationError({
data: { data: {
flow: [ flow: [
{ {
message: message: 'All steps should be completed before updating flow status!'
'All steps should be completed before updating flow status!', }
},
], ],
}, },
type: 'incompleteStepsError', type: 'incompleteStepsError',
@@ -151,48 +148,36 @@ class Flow extends Base {
type: 'action', type: 'action',
position: 2, position: 2,
}); });
return this.$query().withGraphFetched('steps');
} }
async getStepById(stepId) { async createActionStep(previousStepId) {
return await this.$relatedQuery('steps').findById(stepId).throwIfNotFound(); const previousStep = await this.$relatedQuery('steps')
} .findById(previousStepId)
.throwIfNotFound();
async insertActionStepAtPosition(position) { const createdStep = await this.$relatedQuery('steps').insertAndFetch({
return await this.$relatedQuery('steps').insertAndFetch({
type: 'action', type: 'action',
position, position: previousStep.position + 1,
}); });
}
async getStepsAfterPosition(position) { const nextSteps = await this.$relatedQuery('steps')
return await this.$relatedQuery('steps').where('position', '>', position); .where('position', '>=', createdStep.position)
} .whereNot('id', createdStep.id);
async updateStepPositionsFrom(startPosition, steps) { const nextStepQueries = nextSteps.map(async (nextStep, index) => {
const stepPositionUpdates = steps.map(async (step, index) => { return await nextStep.$query().patchAndFetch({
return await step.$query().patch({ position: createdStep.position + index + 1,
position: startPosition + index,
}); });
}); });
return await Promise.all(stepPositionUpdates); await Promise.all(nextStepQueries);
}
async createStepAfter(previousStepId) {
const previousStep = await this.getStepById(previousStepId);
const nextSteps = await this.getStepsAfterPosition(previousStep.position);
const createdStep = await this.insertActionStepAtPosition(
previousStep.position + 1
);
await this.updateStepPositionsFrom(createdStep.position + 1, nextSteps);
return createdStep; return createdStep;
} }
async unregisterWebhook() { async delete() {
const triggerStep = await this.getTriggerStep(); const triggerStep = await this.getTriggerStep();
const trigger = await triggerStep?.getTriggerCommand(); const trigger = await triggerStep?.getTriggerCommand();
@@ -213,33 +198,15 @@ class Flow extends Base {
); );
} }
} }
}
async deleteExecutionSteps() {
const executionIds = ( const executionIds = (
await this.$relatedQuery('executions').select('executions.id') await this.$relatedQuery('executions').select('executions.id')
).map((execution) => execution.id); ).map((execution) => execution.id);
return await ExecutionStep.query() await ExecutionStep.query().delete().whereIn('execution_id', executionIds);
.delete()
.whereIn('execution_id', executionIds);
}
async deleteExecutions() {
return await this.$relatedQuery('executions').delete();
}
async deleteSteps() {
return await this.$relatedQuery('steps').delete();
}
async delete() {
await this.unregisterWebhook();
await this.deleteExecutionSteps();
await this.deleteExecutions();
await this.deleteSteps();
await this.$relatedQuery('executions').delete();
await this.$relatedQuery('steps').delete();
await this.$query().delete(); await this.$query().delete();
} }
@@ -324,18 +291,6 @@ class Flow extends Base {
return duplicatedFlowWithSteps; return duplicatedFlowWithSteps;
} }
async getTriggerStep() {
return await this.$relatedQuery('steps').findOne({
type: 'trigger',
});
}
async isPaused() {
const user = await this.$relatedQuery('user').withSoftDeleted();
const allowedToRunFlows = await user.isAllowedToRunFlows();
return allowedToRunFlows ? false : true;
}
async updateStatus(newActiveValue) { async updateStatus(newActiveValue) {
if (this.active === newActiveValue) { if (this.active === newActiveValue) {
return this; return this;
@@ -344,7 +299,7 @@ class Flow extends Base {
const triggerStep = await this.getTriggerStep(); const triggerStep = await this.getTriggerStep();
if (triggerStep.status === 'incomplete') { if (triggerStep.status === 'incomplete') {
throw Flow.IncompleteStepsError; throw this.IncompleteStepsError;
} }
const trigger = await triggerStep.getTriggerCommand(); const trigger = await triggerStep.getTriggerCommand();
@@ -398,55 +353,60 @@ class Flow extends Base {
}); });
} }
async throwIfHavingIncompleteSteps() { async $beforeUpdate(opt, queryContext) {
const incompleteStep = await this.$relatedQuery('steps').findOne({ await super.$beforeUpdate(opt, queryContext);
if (!this.active) return;
const oldFlow = opt.old;
const incompleteStep = await oldFlow.$relatedQuery('steps').findOne({
status: 'incomplete', status: 'incomplete',
}); });
if (incompleteStep) { if (incompleteStep) {
throw Flow.IncompleteStepsError; throw this.IncompleteStepsError;
} }
}
async throwIfHavingLessThanTwoSteps() { const allSteps = await oldFlow.$relatedQuery('steps');
const allSteps = await this.$relatedQuery('steps');
if (allSteps.length < 2) { if (allSteps.length < 2) {
throw new ValidationError({ throw new ValidationError({
data: { data: {
flow: [ flow: [
{ {
message: message: 'There should be at least one trigger and one action steps in the flow!'
'There should be at least one trigger and one action steps in the flow!', }
},
], ],
}, },
type: 'insufficientStepsError', type: 'insufficientStepsError',
}); });
} }
}
async $beforeUpdate(opt, queryContext) { return;
await super.$beforeUpdate(opt, queryContext);
if (this.active) {
await opt.old.throwIfHavingIncompleteSteps();
await opt.old.throwIfHavingLessThanTwoSteps();
}
} }
async $afterInsert(queryContext) { async $afterInsert(queryContext) {
await super.$afterInsert(queryContext); await super.$afterInsert(queryContext);
Telemetry.flowCreated(this); Telemetry.flowCreated(this);
} }
async $afterUpdate(opt, queryContext) { async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext); await super.$afterUpdate(opt, queryContext);
Telemetry.flowUpdated(this); Telemetry.flowUpdated(this);
} }
async getTriggerStep() {
return await this.$relatedQuery('steps').findOne({
type: 'trigger',
});
}
async isPaused() {
const user = await this.$relatedQuery('user').withSoftDeleted();
const allowedToRunFlows = await user.isAllowedToRunFlows();
return allowedToRunFlows ? false : true;
}
} }
export default Flow; export default Flow;

View File

@@ -1,616 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import Flow from './flow.js';
import User from './user.js';
import Base from './base.js';
import Step from './step.js';
import Execution from './execution.js';
import Telemetry from '../helpers/telemetry/index.js';
import * as globalVariableModule from '../helpers/global-variable.js';
import { createFlow } from '../../test/factories/flow.js';
import { createStep } from '../../test/factories/step.js';
import { createExecution } from '../../test/factories/execution.js';
import { createExecutionStep } from '../../test/factories/execution-step.js';
describe('Flow model', () => {
it('tableName should return correct name', () => {
expect(Flow.tableName).toBe('flows');
});
it('jsonSchema should have correct validations', () => {
expect(Flow.jsonSchema).toMatchSnapshot();
});
describe('relationMappings', () => {
it('should return correct associations', () => {
const relationMappings = Flow.relationMappings();
const expectedRelations = {
steps: {
relation: Base.HasManyRelation,
modelClass: Step,
join: {
from: 'flows.id',
to: 'steps.flow_id',
},
filter: expect.any(Function),
},
triggerStep: {
relation: Base.HasOneRelation,
modelClass: Step,
join: {
from: 'flows.id',
to: 'steps.flow_id',
},
filter: expect.any(Function),
},
executions: {
relation: Base.HasManyRelation,
modelClass: Execution,
join: {
from: 'flows.id',
to: 'executions.flow_id',
},
},
lastExecution: {
relation: Base.HasOneRelation,
modelClass: Execution,
join: {
from: 'flows.id',
to: 'executions.flow_id',
},
filter: expect.any(Function),
},
user: {
relation: Base.HasOneRelation,
modelClass: User,
join: {
from: 'flows.user_id',
to: 'users.id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('steps should return the steps', () => {
const relations = Flow.relationMappings();
const orderBySpy = vi.fn();
relations.steps.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('position', 'asc');
});
it('triggerStep should return the trigger step', () => {
const relations = Flow.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const whereSpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.triggerStep.filter({ where: whereSpy });
expect(whereSpy).toHaveBeenCalledWith('type', 'trigger');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
it('lastExecution should return the last execution', () => {
const relations = Flow.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.lastExecution.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
});
describe('populateStatusProperty', () => {
it('should assign "draft" to status property when a flow is not active', async () => {
const referenceFlow = await createFlow({ active: false });
const flows = [referenceFlow];
vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue();
await Flow.populateStatusProperty(flows);
expect(referenceFlow.status).toBe('draft');
});
it('should assign "paused" to status property when a flow is active, but should be paused', async () => {
const referenceFlow = await createFlow({ active: true });
const flows = [referenceFlow];
vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(true);
await Flow.populateStatusProperty(flows);
expect(referenceFlow.status).toBe('paused');
});
it('should assign "published" to status property when a flow is active', async () => {
const referenceFlow = await createFlow({ active: true });
const flows = [referenceFlow];
vi.spyOn(referenceFlow, 'isPaused').mockResolvedValue(false);
await Flow.populateStatusProperty(flows);
expect(referenceFlow.status).toBe('published');
});
});
it('afterFind should call Flow.populateStatusProperty', async () => {
const populateStatusPropertySpy = vi
.spyOn(Flow, 'populateStatusProperty')
.mockImplementation(() => {});
await createFlow();
expect(populateStatusPropertySpy).toHaveBeenCalledOnce();
});
describe('lastInternalId', () => {
it('should return internal ID of last execution when exists', async () => {
const flow = await createFlow();
await createExecution({ flowId: flow.id });
await createExecution({ flowId: flow.id });
const lastExecution = await createExecution({ flowId: flow.id });
expect(await flow.lastInternalId()).toBe(lastExecution.internalId);
});
it('should return null when no flow execution exists', async () => {
const flow = await createFlow();
expect(await flow.lastInternalId()).toBe(null);
});
});
describe('lastInternalIds', () => {
it('should return last internal IDs', async () => {
const flow = await createFlow();
const internalIds = [
await createExecution({ flowId: flow.id }),
await createExecution({ flowId: flow.id }),
await createExecution({ flowId: flow.id }),
].map((execution) => execution.internalId);
expect(await flow.lastInternalIds()).toStrictEqual(internalIds);
});
it('should return last 50 internal IDs by default', async () => {
const flow = new Flow();
const limitSpy = vi.fn().mockResolvedValue([]);
vi.spyOn(flow, '$relatedQuery').mockReturnValue({
select: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: limitSpy,
});
await flow.lastInternalIds();
expect(limitSpy).toHaveBeenCalledWith(50);
});
});
it('IncompleteStepsError should return validation error for incomplete steps', () => {
expect(() => {
throw Flow.IncompleteStepsError;
}).toThrowError(
'flow: All steps should be completed before updating flow status!'
);
});
it('createInitialSteps should create one trigger and one action step', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
const steps = await flow.$relatedQuery('steps');
expect(steps.length).toBe(2);
expect(steps[0]).toMatchObject({
flowId: flow.id,
type: 'trigger',
position: 1,
});
expect(steps[1]).toMatchObject({
flowId: flow.id,
type: 'action',
position: 2,
});
});
it('getStepById should return the step with the given ID from the flow', async () => {
const flow = await createFlow();
const step = await createStep({ flowId: flow.id });
expect(await flow.getStepById(step.id)).toStrictEqual(step);
});
it('insertActionStepAtPosition should insert action step at given position', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
const createdStep = await flow.insertActionStepAtPosition(2);
expect(createdStep).toMatchObject({
type: 'action',
position: 2,
});
});
it('getStepsAfterPosition should return steps after the given position', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
await createStep({ flowId: flow.id });
expect(await flow.getStepsAfterPosition(1)).toMatchObject([
{ position: 2 },
{ position: 3 },
]);
});
it('updateStepPositionsFrom', async () => {
const flow = await createFlow();
await createStep({ type: 'trigger', flowId: flow.id, position: 6 });
await createStep({ type: 'action', flowId: flow.id, position: 8 });
await createStep({ type: 'action', flowId: flow.id, position: 10 });
await flow.updateStepPositionsFrom(2, await flow.$relatedQuery('steps'));
expect(await flow.$relatedQuery('steps')).toMatchObject([
{ position: 2, type: 'trigger' },
{ position: 3, type: 'action' },
{ position: 4, type: 'action' },
]);
});
it('createStepAfter should create an action step after given step ID', async () => {
const flow = await createFlow();
const triggerStep = await createStep({ type: 'trigger', flowId: flow.id });
const actionStep = await createStep({ type: 'action', flowId: flow.id });
const createdStep = await flow.createStepAfter(triggerStep.id);
const refetchedActionStep = await actionStep.$query();
expect(createdStep).toMatchObject({ type: 'action', position: 2 });
expect(refetchedActionStep.position).toBe(3);
});
describe('unregisterWebhook', () => {
it('should unregister webhook on remote when supported', async () => {
const flow = await createFlow();
const triggerStep = await createStep({
flowId: flow.id,
appKey: 'typeform',
key: 'new-entry',
type: 'trigger',
});
const unregisterHookSpy = vi.fn().mockResolvedValue();
vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({
type: 'webhook',
unregisterHook: unregisterHookSpy,
});
const globalVariableSpy = vi
.spyOn(globalVariableModule, 'default')
.mockResolvedValue('global-variable');
await flow.unregisterWebhook();
expect(unregisterHookSpy).toHaveBeenCalledWith('global-variable');
expect(globalVariableSpy).toHaveBeenCalledWith({
flow,
step: triggerStep,
connection: undefined,
app: await triggerStep.getApp(),
});
});
it('should silently fail when unregistration fails', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
appKey: 'typeform',
key: 'new-entry',
type: 'trigger',
});
const unregisterHookSpy = vi.fn().mockRejectedValue(new Error());
vi.spyOn(Step.prototype, 'getTriggerCommand').mockResolvedValue({
type: 'webhook',
unregisterHook: unregisterHookSpy,
});
expect(await flow.unregisterWebhook()).toBe(undefined);
expect(unregisterHookSpy).toHaveBeenCalledOnce();
});
it('should do nothing when trigger step is not webhook', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
type: 'trigger',
});
const unregisterHookSpy = vi.fn().mockRejectedValue(new Error());
expect(await flow.unregisterWebhook()).toBe(undefined);
expect(unregisterHookSpy).not.toHaveBeenCalled();
});
});
it('deleteExecutionSteps should delete related execution steps', async () => {
const flow = await createFlow();
const execution = await createExecution({ flowId: flow.id });
const firstExecutionStep = await createExecutionStep({
executionId: execution.id,
});
const secondExecutionStep = await createExecutionStep({
executionId: execution.id,
});
await flow.deleteExecutionSteps();
expect(await firstExecutionStep.$query()).toBe(undefined);
expect(await secondExecutionStep.$query()).toBe(undefined);
});
it('deleteExecutions should delete related executions', async () => {
const flow = await createFlow();
const firstExecution = await createExecution({ flowId: flow.id });
const secondExecution = await createExecution({ flowId: flow.id });
await flow.deleteExecutions();
expect(await firstExecution.$query()).toBe(undefined);
expect(await secondExecution.$query()).toBe(undefined);
});
it('deleteSteps should delete related steps', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
await flow.deleteSteps();
expect(await flow.$relatedQuery('steps')).toStrictEqual([]);
});
it('delete should delete the flow with its relations', async () => {
const flow = await createFlow();
const unregisterWebhookSpy = vi
.spyOn(flow, 'unregisterWebhook')
.mockResolvedValue();
const deleteExecutionStepsSpy = vi
.spyOn(flow, 'deleteExecutionSteps')
.mockResolvedValue();
const deleteExecutionsSpy = vi
.spyOn(flow, 'deleteExecutions')
.mockResolvedValue();
const deleteStepsSpy = vi.spyOn(flow, 'deleteSteps').mockResolvedValue();
await flow.delete();
expect(unregisterWebhookSpy).toHaveBeenCalledOnce();
expect(deleteExecutionStepsSpy).toHaveBeenCalledOnce();
expect(deleteExecutionsSpy).toHaveBeenCalledOnce();
expect(deleteStepsSpy).toHaveBeenCalledOnce();
expect(await flow.$query()).toBe(undefined);
});
it.todo('duplicateFor');
it('getTriggerStep', async () => {
const flow = await createFlow();
const triggerStep = await createStep({ flowId: flow.id, type: 'trigger' });
await createStep({ flowId: flow.id, type: 'action' });
expect(await flow.getTriggerStep()).toStrictEqual(triggerStep);
});
describe('isPaused', () => {
it('should return true when user.isAllowedToRunFlows returns false', async () => {
const flow = await createFlow();
const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(false);
vi.spyOn(flow, '$relatedQuery').mockReturnValue({
withSoftDeleted: vi.fn().mockReturnThis(),
isAllowedToRunFlows: isAllowedToRunFlowsSpy,
});
expect(await flow.isPaused()).toBe(true);
expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce();
});
it('should return false when user.isAllowedToRunFlows returns true', async () => {
const flow = await createFlow();
const isAllowedToRunFlowsSpy = vi.fn().mockResolvedValue(true);
vi.spyOn(flow, '$relatedQuery').mockReturnValue({
withSoftDeleted: vi.fn().mockReturnThis(),
isAllowedToRunFlows: isAllowedToRunFlowsSpy,
});
expect(await flow.isPaused()).toBe(false);
expect(isAllowedToRunFlowsSpy).toHaveBeenCalledOnce();
});
});
describe('throwIfHavingIncompleteSteps', () => {
it('should throw validation error with incomplete steps', async () => {
const flow = await createFlow();
await flow.createInitialSteps();
await expect(() =>
flow.throwIfHavingIncompleteSteps()
).rejects.toThrowError(
'flow: All steps should be completed before updating flow status!'
);
});
it('should return undefined when all steps are completed', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
status: 'completed',
type: 'trigger',
});
await createStep({
flowId: flow.id,
status: 'completed',
type: 'action',
});
expect(await flow.throwIfHavingIncompleteSteps()).toBe(undefined);
});
});
describe('throwIfHavingLessThanTwoSteps', () => {
it('should throw validation error with less than two steps', async () => {
const flow = await createFlow();
await expect(() =>
flow.throwIfHavingLessThanTwoSteps()
).rejects.toThrowError(
'flow: There should be at least one trigger and one action steps in the flow!'
);
});
it('should return undefined when there are at least two steps', async () => {
const flow = await createFlow();
await createStep({
flowId: flow.id,
type: 'trigger',
});
await createStep({
flowId: flow.id,
type: 'action',
});
expect(await flow.throwIfHavingLessThanTwoSteps()).toBe(undefined);
});
});
describe('$beforeUpdate', () => {
it('should invoke throwIfHavingIncompleteSteps when flow is becoming active', async () => {
const flow = await createFlow({ active: false });
const throwIfHavingIncompleteStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps')
.mockImplementation(() => {});
const throwIfHavingLessThanTwoStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps')
.mockImplementation(() => {});
await flow.$query().patch({ active: true });
expect(throwIfHavingIncompleteStepsSpy).toHaveBeenCalledOnce();
expect(throwIfHavingLessThanTwoStepsSpy).toHaveBeenCalledOnce();
});
it('should invoke throwIfHavingIncompleteSteps when flow is not becoming active', async () => {
const flow = await createFlow({ active: true });
const throwIfHavingIncompleteStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingIncompleteSteps')
.mockImplementation(() => {});
const throwIfHavingLessThanTwoStepsSpy = vi
.spyOn(Flow.prototype, 'throwIfHavingLessThanTwoSteps')
.mockImplementation(() => {});
await flow.$query().patch({});
expect(throwIfHavingIncompleteStepsSpy).not.toHaveBeenCalledOnce();
expect(throwIfHavingLessThanTwoStepsSpy).not.toHaveBeenCalledOnce();
});
});
describe('$afterInsert', () => {
it('should call super.$afterInsert', async () => {
const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert');
await createFlow();
expect(superAfterInsertSpy).toHaveBeenCalled();
});
it('should call Telemetry.flowCreated', async () => {
const telemetryFlowCreatedSpy = vi
.spyOn(Telemetry, 'flowCreated')
.mockImplementation(() => {});
const flow = await createFlow();
expect(telemetryFlowCreatedSpy).toHaveBeenCalledWith(flow);
});
});
describe('$afterUpdate', () => {
it('should call super.$afterUpdate', async () => {
const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate');
const flow = await createFlow();
await flow.$query().patch({ active: false });
expect(superAfterUpdateSpy).toHaveBeenCalledOnce();
});
it('$afterUpdate should call Telemetry.flowUpdated', async () => {
const telemetryFlowUpdatedSpy = vi
.spyOn(Telemetry, 'flowUpdated')
.mockImplementation(() => {});
const flow = await createFlow();
await flow.$query().patch({ active: false });
expect(telemetryFlowUpdatedSpy).toHaveBeenCalled({});
});
});
});

View File

@@ -1,84 +0,0 @@
import { vi, describe, it, expect } from 'vitest';
import SamlAuthProvider from '../models/saml-auth-provider.ee';
import SamlAuthProvidersRoleMapping from '../models/saml-auth-providers-role-mapping.ee';
import Identity from './identity.ee';
import Base from './base';
import appConfig from '../config/app';
describe('SamlAuthProvider model', () => {
it('tableName should return correct name', () => {
expect(SamlAuthProvider.tableName).toBe('saml_auth_providers');
});
it('jsonSchema should have the correct schema', () => {
expect(SamlAuthProvider.jsonSchema).toMatchSnapshot();
});
it('relationMappings should return correct associations', () => {
const relationMappings = SamlAuthProvider.relationMappings();
const expectedRelations = {
identities: {
relation: Base.HasOneRelation,
modelClass: Identity,
join: {
from: 'identities.provider_id',
to: 'saml_auth_providers.id',
},
},
samlAuthProvidersRoleMappings: {
relation: Base.HasManyRelation,
modelClass: SamlAuthProvidersRoleMapping,
join: {
from: 'saml_auth_providers.id',
to: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('virtualAttributes should return correct attributes', () => {
const virtualAttributes = SamlAuthProvider.virtualAttributes;
const expectedAttributes = ['loginUrl', 'remoteLogoutUrl'];
expect(virtualAttributes).toStrictEqual(expectedAttributes);
});
it('loginUrl should return the URL of login', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.issuer = 'sample-issuer';
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(samlAuthProvider.loginUrl).toStrictEqual(
'https://automatisch.io/login/saml/sample-issuer'
);
});
it('loginCallbackUrl should return the URL of login callback', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.issuer = 'sample-issuer';
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(samlAuthProvider.loginCallBackUrl).toStrictEqual(
'https://automatisch.io/login/saml/sample-issuer/callback'
);
});
it('remoteLogoutUrl should return the URL from entrypoint', () => {
const samlAuthProvider = new SamlAuthProvider();
samlAuthProvider.entryPoint = 'https://example.com/saml/logout';
expect(samlAuthProvider.remoteLogoutUrl).toStrictEqual(
'https://example.com/saml/logout'
);
});
});

View File

@@ -93,14 +93,6 @@ class Step extends Base {
return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`; return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`;
} }
get isTrigger() {
return this.type === 'trigger';
}
get isAction() {
return this.type === 'action';
}
async computeWebhookPath() { async computeWebhookPath() {
if (this.type === 'action') return null; if (this.type === 'action') return null;
@@ -143,6 +135,24 @@ class Step extends Base {
return webhookUrl; return webhookUrl;
} }
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
Telemetry.stepCreated(this);
}
async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext);
Telemetry.stepUpdated(this);
}
get isTrigger() {
return this.type === 'trigger';
}
get isAction() {
return this.type === 'action';
}
async getApp() { async getApp() {
if (!this.appKey) return null; if (!this.appKey) return null;
@@ -160,7 +170,12 @@ class Step extends Base {
} }
async getLastExecutionStep() { async getLastExecutionStep() {
return await this.$relatedQuery('lastExecutionStep'); const lastExecutionStep = await this.$relatedQuery('executionSteps')
.orderBy('created_at', 'desc')
.limit(1)
.first();
return lastExecutionStep;
} }
async getNextStep() { async getNextStep() {
@@ -192,18 +207,19 @@ class Step extends Base {
} }
async getSetupFields() { async getSetupFields() {
let substeps; let setupSupsteps;
if (this.isTrigger) { if (this.isTrigger) {
substeps = (await this.getTriggerCommand()).substeps; setupSupsteps = (await this.getTriggerCommand()).substeps;
} else { } else {
substeps = (await this.getActionCommand()).substeps; setupSupsteps = (await this.getActionCommand()).substeps;
} }
const setupSubstep = substeps.find( const existingArguments = setupSupsteps.find(
(substep) => substep.key === 'chooseTrigger' (substep) => substep.key === 'chooseTrigger'
); ).arguments;
return setupSubstep.arguments;
return existingArguments;
} }
async getSetupAndDynamicFields() { async getSetupAndDynamicFields() {
@@ -310,17 +326,23 @@ class Step extends Base {
.$relatedQuery('steps') .$relatedQuery('steps')
.where('position', '>', this.position); .where('position', '>', this.position);
await flow.updateStepPositionsFrom(this.position, nextSteps); const nextStepQueries = nextSteps.map(async (nextStep) => {
await nextStep.$query().patch({
position: nextStep.position - 1,
});
});
await Promise.all(nextStepQueries);
} }
async updateFor(user, newStepData) { async updateFor(user, newStepData) {
const { appKey = this.appKey, connectionId, key, parameters } = newStepData; const { connectionId, appKey, key, parameters } = newStepData;
if (connectionId && appKey) { if (connectionId && (appKey || this.appKey)) {
await user.authorizedConnections await user.authorizedConnections
.findOne({ .findOne({
id: connectionId, id: connectionId,
key: appKey, key: appKey || this.appKey,
}) })
.throwIfNotFound(); .throwIfNotFound();
} }
@@ -334,8 +356,8 @@ class Step extends Base {
} }
const updatedStep = await this.$query().patchAndFetch({ const updatedStep = await this.$query().patchAndFetch({
key, key: key,
appKey, appKey: appKey,
connectionId: connectionId, connectionId: connectionId,
parameters: parameters, parameters: parameters,
status: 'incomplete', status: 'incomplete',
@@ -345,16 +367,6 @@ class Step extends Base {
return updatedStep; return updatedStep;
} }
async $afterInsert(queryContext) {
await super.$afterInsert(queryContext);
Telemetry.stepCreated(this);
}
async $afterUpdate(opt, queryContext) {
await super.$afterUpdate(opt, queryContext);
Telemetry.stepUpdated(this);
}
} }
export default Step; export default Step;

View File

@@ -1,504 +0,0 @@
import { beforeEach, describe, it, expect, vi } from 'vitest';
import appConfig from '../config/app.js';
import App from './app.js';
import Base from './base.js';
import Step from './step.js';
import Flow from './flow.js';
import Connection from './connection.js';
import ExecutionStep from './execution-step.js';
import Telemetry from '../helpers/telemetry/index.js';
import * as testRunModule from '../services/test-run.js';
import { createFlow } from '../../test/factories/flow.js';
import { createUser } from '../../test/factories/user.js';
import { createRole } from '../../test/factories/role.js';
import { createPermission } from '../../test/factories/permission.js';
import { createConnection } from '../../test/factories/connection.js';
import { createStep } from '../../test/factories/step.js';
import { createExecutionStep } from '../../test/factories/execution-step.js';
describe('Step model', () => {
it('tableName should return correct name', () => {
expect(Step.tableName).toBe('steps');
});
it('jsonSchema should have correct validations', () => {
expect(Step.jsonSchema).toMatchSnapshot();
});
it('virtualAttributes should return correct attributes', () => {
const virtualAttributes = Step.virtualAttributes;
const expectedAttributes = ['iconUrl', 'webhookUrl'];
expect(virtualAttributes).toStrictEqual(expectedAttributes);
});
describe('relationMappings', () => {
it('should return correct associations', () => {
const relationMappings = Step.relationMappings();
const expectedRelations = {
flow: {
relation: Base.BelongsToOneRelation,
modelClass: Flow,
join: {
from: 'steps.flow_id',
to: 'flows.id',
},
},
connection: {
relation: Base.HasOneRelation,
modelClass: Connection,
join: {
from: 'steps.connection_id',
to: 'connections.id',
},
},
lastExecutionStep: {
relation: Base.HasOneRelation,
modelClass: ExecutionStep,
join: {
from: 'steps.id',
to: 'execution_steps.step_id',
},
filter: expect.any(Function),
},
executionSteps: {
relation: Base.HasManyRelation,
modelClass: ExecutionStep,
join: {
from: 'steps.id',
to: 'execution_steps.step_id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('lastExecutionStep should return the trigger step', () => {
const relations = Step.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.lastExecutionStep.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
});
describe('webhookUrl', () => {
it('should return it along with appConfig.webhookUrl when exists', () => {
vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const step = new Step();
step.webhookPath = '/webhook-path';
expect(step.webhookUrl).toBe('https://automatisch.io/webhook-path');
});
it('should return null when webhookUrl does not exist', () => {
const step = new Step();
expect(step.webhookUrl).toBe(null);
});
});
describe('iconUrl', () => {
it('should return step app icon absolute URL when app is set', () => {
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
const step = new Step();
step.appKey = 'gitlab';
expect(step.iconUrl).toBe(
'https://automatisch.io/apps/gitlab/assets/favicon.svg'
);
});
it('should return null when appKey is not set', () => {
const step = new Step();
expect(step.iconUrl).toBe(null);
});
});
it('isTrigger should return true when step type is trigger', () => {
const step = new Step();
step.type = 'trigger';
expect(step.isTrigger).toBe(true);
});
it('isAction should return true when step type is action', () => {
const step = new Step();
step.type = 'action';
expect(step.isAction).toBe(true);
});
describe.todo('computeWebhookPath');
describe('getWebhookUrl', () => {
it('should return absolute webhook URL when step type is trigger', async () => {
const step = new Step();
step.type = 'trigger';
vi.spyOn(step, 'computeWebhookPath').mockResolvedValue('/webhook-path');
vi.spyOn(appConfig, 'webhookUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(await step.getWebhookUrl()).toBe(
'https://automatisch.io/webhook-path'
);
});
it('should return undefined when step type is action', async () => {
const step = new Step();
step.type = 'action';
expect(await step.getWebhookUrl()).toBe(undefined);
});
});
describe('getApp', () => {
it('should return app with the given appKey', async () => {
const step = new Step();
step.appKey = 'gitlab';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue();
await step.getApp();
expect(findOneByKeySpy).toHaveBeenCalledWith('gitlab');
});
it('should return null with no appKey', async () => {
const step = new Step();
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey').mockResolvedValue();
expect(await step.getApp()).toBe(null);
expect(findOneByKeySpy).not.toHaveBeenCalled();
});
});
it('test should execute the flow and mark the step as completed', async () => {
const step = await createStep({ status: 'incomplete' });
const testRunSpy = vi.spyOn(testRunModule, 'default').mockResolvedValue();
const updatedStep = await step.test();
expect(testRunSpy).toHaveBeenCalledWith({ stepId: step.id });
expect(updatedStep.status).toBe('completed');
});
it('getLastExecutionStep should return last execution step', async () => {
const step = await createStep();
await createExecutionStep({ stepId: step.id });
const secondExecutionStep = await createExecutionStep({ stepId: step.id });
expect(await step.getLastExecutionStep()).toStrictEqual(
secondExecutionStep
);
});
it('getNextStep should return the next step', async () => {
const firstStep = await createStep();
const secondStep = await createStep({ flowId: firstStep.flowId });
const thirdStep = await createStep({ flowId: firstStep.flowId });
expect(await secondStep.getNextStep()).toStrictEqual(thirdStep);
});
describe('getTriggerCommand', () => {
it('should return trigger command when app key and key are defined in trigger step', async () => {
const step = new Step();
step.type = 'trigger';
step.appKey = 'webhook';
step.key = 'catchRawWebhook';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey');
const triggerCommand = await step.getTriggerCommand();
expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey);
expect(triggerCommand.key).toBe(step.key);
});
it('should return null when key is not defined', async () => {
const step = new Step();
step.type = 'trigger';
step.appKey = 'webhook';
expect(await step.getTriggerCommand()).toBe(null);
});
});
describe('getActionCommand', () => {
it('should return action comamand when app key and key are defined in action step', async () => {
const step = new Step();
step.type = 'action';
step.appKey = 'ntfy';
step.key = 'sendMessage';
const findOneByKeySpy = vi.spyOn(App, 'findOneByKey');
const actionCommand = await step.getActionCommand();
expect(findOneByKeySpy).toHaveBeenCalledWith(step.appKey);
expect(actionCommand.key).toBe(step.key);
});
it('should return null when key is not defined', async () => {
const step = new Step();
step.type = 'action';
step.appKey = 'ntfy';
expect(await step.getActionCommand()).toBe(null);
});
});
describe('getSetupFields', () => {
it('should return trigger setup substep fields in trigger step', async () => {
const step = new Step();
step.appKey = 'webhook';
step.key = 'catchRawWebhook';
step.type = 'trigger';
expect(await step.getSetupFields()).toStrictEqual([
{
label: 'Wait until flow is done',
key: 'workSynchronously',
type: 'dropdown',
required: true,
options: [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
],
},
]);
});
it('should return action setup substep fields in action step', async () => {
const step = new Step();
step.appKey = 'datastore';
step.key = 'getValue';
step.type = 'action';
expect(await step.getSetupFields()).toStrictEqual([
{
label: 'Key',
key: 'key',
type: 'string',
required: true,
description: 'The key of your value to get.',
variables: true,
},
]);
});
});
it.todo('getSetupAndDynamicFields');
it.todo('createDynamicFields');
it.todo('createDynamicData');
it.todo('updateWebhookUrl');
describe('delete', () => {
it('should delete the step and align the positions', async () => {
const flow = await createFlow();
await createStep({ flowId: flow.id, position: 1, type: 'trigger' });
await createStep({ flowId: flow.id, position: 2 });
const stepToDelete = await createStep({ flowId: flow.id, position: 3 });
await createStep({ flowId: flow.id, position: 4 });
await stepToDelete.delete();
const steps = await flow.$relatedQuery('steps');
const stepIds = steps.map((step) => step.id);
expect(stepIds).not.toContain(stepToDelete.id);
});
it('should align the positions of remaining steps', async () => {
const flow = await createFlow();
await createStep({ flowId: flow.id, position: 1, type: 'trigger' });
await createStep({ flowId: flow.id, position: 2 });
const stepToDelete = await createStep({ flowId: flow.id, position: 3 });
await createStep({ flowId: flow.id, position: 4 });
await stepToDelete.delete();
const steps = await flow.$relatedQuery('steps');
const stepPositions = steps.map((step) => step.position);
expect(stepPositions).toMatchObject([1, 2, 3]);
});
it('should delete related execution steps', async () => {
const step = await createStep();
const executionStep = await createExecutionStep({ stepId: step.id });
await step.delete();
expect(await executionStep.$query()).toBe(undefined);
});
});
describe('updateFor', async () => {
let step,
userRole,
user,
userConnection,
anotherUser,
anotherUserConnection;
beforeEach(async () => {
userRole = await createRole({ name: 'User' });
anotherUser = await createUser({ roleId: userRole.id });
user = await createUser({ roleId: userRole.id });
userConnection = await createConnection({
key: 'deepl',
userId: user.id,
});
anotherUserConnection = await createConnection({
key: 'deepl',
userId: anotherUser.id,
});
await createPermission({
roleId: userRole.id,
action: 'read',
subject: 'Connection',
conditions: ['isCreator'],
});
step = await createStep();
});
it('should update step with the given payload and mark it as incomplete', async () => {
const stepData = {
appKey: 'deepl',
key: 'translateText',
connectionId: anotherUserConnection.id,
parameters: {
key: 'value',
},
};
const anotherUserWithRoleAndPermissions = await anotherUser
.$query()
.withGraphFetched({ permissions: true, role: true });
const updatedStep = await step.updateFor(
anotherUserWithRoleAndPermissions,
stepData
);
expect(updatedStep).toMatchObject({
...stepData,
status: 'incomplete',
});
});
it('should invoke updateWebhookUrl', async () => {
const updateWebhookUrlSpy = vi
.spyOn(Step.prototype, 'updateWebhookUrl')
.mockResolvedValue();
const stepData = {
appKey: 'deepl',
key: 'translateText',
};
await step.updateFor(user, stepData);
expect(updateWebhookUrlSpy).toHaveBeenCalledOnce();
});
it('should not update step when inaccessible connection is given', async () => {
const stepData = {
appKey: 'deepl',
key: 'translateText',
connectionId: userConnection.id,
};
const anotherUserWithRoleAndPermissions = await anotherUser
.$query()
.withGraphFetched({ permissions: true, role: true });
await expect(() =>
step.updateFor(anotherUserWithRoleAndPermissions, stepData)
).rejects.toThrowError('NotFoundError');
});
it('should not update step when given app key and key do not exist', async () => {
const stepData = {
appKey: 'deepl',
key: 'not-existing-key',
};
await expect(() => step.updateFor(user, stepData)).rejects.toThrowError(
'DeepL does not have an action with the "not-existing-key" key!'
);
});
});
describe('$afterInsert', () => {
it('should call super.$afterInsert', async () => {
const superAfterInsertSpy = vi.spyOn(Base.prototype, '$afterInsert');
await createStep();
expect(superAfterInsertSpy).toHaveBeenCalled();
});
it('should call Telemetry.stepCreated', async () => {
const telemetryStepCreatedSpy = vi
.spyOn(Telemetry, 'stepCreated')
.mockImplementation(() => {});
const step = await createStep();
expect(telemetryStepCreatedSpy).toHaveBeenCalledWith(step);
});
});
describe('$afterUpdate', () => {
it('should call super.$afterUpdate', async () => {
const superAfterUpdateSpy = vi.spyOn(Base.prototype, '$afterUpdate');
const step = await createStep();
await step.$query().patch({ position: 2 });
expect(superAfterUpdateSpy).toHaveBeenCalledOnce();
});
it('$afterUpdate should call Telemetry.stepUpdated', async () => {
const telemetryStepUpdatedSpy = vi
.spyOn(Telemetry, 'stepUpdated')
.mockImplementation(() => {});
const step = await createStep();
await step.$query().patch({ position: 2 });
expect(telemetryStepUpdatedSpy).toHaveBeenCalled({});
});
});
});

View File

@@ -223,8 +223,8 @@ class User extends Base {
} }
} }
async login(password) { login(password) {
return await bcrypt.compare(password, this.password); return bcrypt.compare(password, this.password);
} }
async generateResetPasswordToken() { async generateResetPasswordToken() {
@@ -366,18 +366,6 @@ class User extends Base {
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds; return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
} }
toTestTestCoverage() {
if (!this.resetPasswordTokenSentAt) {
return false;
}
const sentAt = new Date(this.resetPasswordTokenSentAt);
const now = new Date();
const fourHoursInMilliseconds = 1000 * 60 * 60 * 4;
return now.getTime() - sentAt.getTime() < fourHoursInMilliseconds;
}
async sendInvitationEmail() { async sendInvitationEmail() {
await this.generateInvitationToken(); await this.generateInvitationToken();
@@ -419,7 +407,7 @@ class User extends Base {
} }
} }
startTrialPeriod() { async startTrialPeriod() {
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate();
} }
@@ -602,7 +590,7 @@ class User extends Base {
await this.generateHash(); await this.generateHash();
if (appConfig.isCloud) { if (appConfig.isCloud) {
this.startTrialPeriod(); await this.startTrialPeriod();
} }
} }
@@ -654,7 +642,7 @@ class User extends Base {
can(action, subject) { can(action, subject) {
const can = this.ability.can(action, subject); const can = this.ability.can(action, subject);
if (!can) throw new NotAuthorizedError('The user is not authorized!'); if (!can) throw new NotAuthorizedError();
const relevantRule = this.ability.relevantRuleFor(action, subject); const relevantRule = this.ability.relevantRuleFor(action, subject);

View File

@@ -1,878 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { DateTime, Duration } from 'luxon';
import appConfig from '../config/app.js';
import Base from './base.js';
import AccessToken from './access-token.js';
import Connection from './connection.js';
import Execution from './execution.js';
import Flow from './flow.js';
import Identity from './identity.ee.js';
import Permission from './permission.js';
import Role from './role.js';
import Step from './step.js';
import Subscription from './subscription.ee.js';
import UsageData from './usage-data.ee.js';
import User from './user.js';
import deleteUserQueue from '../queues/delete-user.ee.js';
import emailQueue from '../queues/email.js';
import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS,
REMOVE_AFTER_7_DAYS_OR_50_JOBS,
} from '../helpers/remove-job-configuration.js';
import { createUser } from '../../test/factories/user.js';
import { createConnection } from '../../test/factories/connection.js';
import { createRole } from '../../test/factories/role.js';
import { createPermission } from '../../test/factories/permission.js';
import { createFlow } from '../../test/factories/flow.js';
import { createStep } from '../../test/factories/step.js';
import { createExecution } from '../../test/factories/execution.js';
describe('User model', () => {
it('tableName should return correct name', () => {
expect(User.tableName).toBe('users');
});
it('jsonSchema should have correct validations', () => {
expect(User.jsonSchema).toMatchSnapshot();
});
describe('relationMappings', () => {
it('should return correct associations', () => {
const relationMappings = User.relationMappings();
const expectedRelations = {
accessTokens: {
relation: Base.HasManyRelation,
modelClass: AccessToken,
join: {
from: 'users.id',
to: 'access_tokens.user_id',
},
},
connections: {
relation: Base.HasManyRelation,
modelClass: Connection,
join: {
from: 'users.id',
to: 'connections.user_id',
},
},
flows: {
relation: Base.HasManyRelation,
modelClass: Flow,
join: {
from: 'users.id',
to: 'flows.user_id',
},
},
steps: {
relation: Base.ManyToManyRelation,
modelClass: Step,
join: {
from: 'users.id',
through: {
from: 'flows.user_id',
to: 'flows.id',
},
to: 'steps.flow_id',
},
},
executions: {
relation: Base.ManyToManyRelation,
modelClass: Execution,
join: {
from: 'users.id',
through: {
from: 'flows.user_id',
to: 'flows.id',
},
to: 'executions.flow_id',
},
},
usageData: {
relation: Base.HasManyRelation,
modelClass: UsageData,
join: {
from: 'usage_data.user_id',
to: 'users.id',
},
},
currentUsageData: {
relation: Base.HasOneRelation,
modelClass: UsageData,
join: {
from: 'usage_data.user_id',
to: 'users.id',
},
filter: expect.any(Function),
},
subscriptions: {
relation: Base.HasManyRelation,
modelClass: Subscription,
join: {
from: 'subscriptions.user_id',
to: 'users.id',
},
},
currentSubscription: {
relation: Base.HasOneRelation,
modelClass: Subscription,
join: {
from: 'subscriptions.user_id',
to: 'users.id',
},
filter: expect.any(Function),
},
role: {
relation: Base.HasOneRelation,
modelClass: Role,
join: {
from: 'roles.id',
to: 'users.role_id',
},
},
permissions: {
relation: Base.HasManyRelation,
modelClass: Permission,
join: {
from: 'users.role_id',
to: 'permissions.role_id',
},
},
identities: {
relation: Base.HasManyRelation,
modelClass: Identity,
join: {
from: 'identities.user_id',
to: 'users.id',
},
},
};
expect(relationMappings).toStrictEqual(expectedRelations);
});
it('currentUsageData should return the current usage data', () => {
const relations = User.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.currentUsageData.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
it('currentSubscription should return the current subscription', () => {
const relations = User.relationMappings();
const firstSpy = vi.fn();
const limitSpy = vi.fn().mockImplementation(() => ({
first: firstSpy,
}));
const orderBySpy = vi.fn().mockImplementation(() => ({
limit: limitSpy,
}));
relations.currentSubscription.filter({ orderBy: orderBySpy });
expect(orderBySpy).toHaveBeenCalledWith('created_at', 'desc');
expect(limitSpy).toHaveBeenCalledWith(1);
expect(firstSpy).toHaveBeenCalledOnce();
});
});
it('virtualAttributes should return correct attributes', () => {
const virtualAttributes = User.virtualAttributes;
const expectedAttributes = ['acceptInvitationUrl'];
expect(virtualAttributes).toStrictEqual(expectedAttributes);
});
it('acceptInvitationUrl should return accept invitation page URL with invitation token', async () => {
const user = new User();
user.invitationToken = 'invitation-token';
vi.spyOn(appConfig, 'webAppUrl', 'get').mockReturnValue(
'https://automatisch.io'
);
expect(user.acceptInvitationUrl).toBe(
'https://automatisch.io/accept-invitation?token=invitation-token'
);
});
describe('authenticate', () => {
it('should create and return the token for correct email and password', async () => {
const user = await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate(
'test-user@automatisch.io',
'sample-password'
);
const persistedToken = await AccessToken.query().findOne({
userId: user.id,
});
expect(token).toBe(persistedToken.token);
});
it('should return undefined for existing email and incorrect password', async () => {
await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate(
'test-user@automatisch.io',
'wrong-password'
);
expect(token).toBe(undefined);
});
it('should return undefined for non-existing email', async () => {
await createUser({
email: 'test-user@automatisch.io',
password: 'sample-password',
});
const token = await User.authenticate('non-existing-user@automatisch.io');
expect(token).toBe(undefined);
});
});
describe('authorizedFlows', () => {
it('should return user flows with isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: ['isCreator'],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userFlow = await createFlow({ userId: user.id });
await createFlow();
expect(await userWithRoleAndPermissions.authorizedFlows).toStrictEqual([
userFlow,
]);
});
it('should return all flows without isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: [],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userFlow = await createFlow({ userId: user.id });
const anotherUserFlow = await createFlow();
expect(await userWithRoleAndPermissions.authorizedFlows).toStrictEqual([
userFlow,
anotherUserFlow,
]);
});
it('should throw an authorization error without Flow read permission', async () => {
const user = new User();
expect(() => user.authorizedFlows).toThrowError(
'The user is not authorized!'
);
});
});
describe('authorizedSteps', () => {
it('should return user steps with isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: ['isCreator'],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userFlow = await createFlow({ userId: user.id });
const userFlowStep = await createStep({ flowId: userFlow.id });
const anotherUserFlow = await createFlow();
await createStep({ flowId: anotherUserFlow.id });
expect(await userWithRoleAndPermissions.authorizedSteps).toStrictEqual([
userFlowStep,
]);
});
it('should return all steps without isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Flow',
action: 'read',
conditions: [],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userFlow = await createFlow({ userId: user.id });
const userFlowStep = await createStep({ flowId: userFlow.id });
const anotherUserFlow = await createFlow();
const anotherUserFlowStep = await createStep({
flowId: anotherUserFlow.id,
});
expect(await userWithRoleAndPermissions.authorizedSteps).toStrictEqual([
userFlowStep,
anotherUserFlowStep,
]);
});
it('should throw an authorization error without Flow read permission', async () => {
const user = new User();
expect(() => user.authorizedSteps).toThrowError(
'The user is not authorized!'
);
});
});
describe('authorizedConnections', () => {
it('should return user connections with isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Connection',
action: 'read',
conditions: ['isCreator'],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userConnection = await createConnection({ userId: user.id });
await createConnection();
expect(
await userWithRoleAndPermissions.authorizedConnections
).toStrictEqual([userConnection]);
});
it('should return all connections without isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Connection',
action: 'read',
conditions: [],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userConnection = await createConnection({ userId: user.id });
const anotherUserConnection = await createConnection();
expect(
await userWithRoleAndPermissions.authorizedConnections
).toStrictEqual([userConnection, anotherUserConnection]);
});
it('should throw an authorization error without Connection read permission', async () => {
const user = new User();
expect(() => user.authorizedConnections).toThrowError(
'The user is not authorized!'
);
});
});
describe('authorizedExecutions', () => {
it('should return user executions with isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Execution',
action: 'read',
conditions: ['isCreator'],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userFlow = await createFlow({ userId: user.id });
const userFlowExecution = await createExecution({ flowId: userFlow.id });
await createExecution();
expect(
await userWithRoleAndPermissions.authorizedExecutions
).toStrictEqual([userFlowExecution]);
});
it('should return all executions without isCreator condition', async () => {
const userRole = await createRole({ name: 'User' });
await createPermission({
roleId: userRole.id,
subject: 'Execution',
action: 'read',
conditions: [],
});
const user = await createUser({ roleId: userRole.id });
const userWithRoleAndPermissions = await user
.$query()
.withGraphFetched({ role: true, permissions: true });
const userFlow = await createFlow({ userId: user.id });
const userFlowExecution = await createExecution({ flowId: userFlow.id });
const anotherUserFlowExecution = await createExecution();
expect(
await userWithRoleAndPermissions.authorizedExecutions
).toStrictEqual([userFlowExecution, anotherUserFlowExecution]);
});
it('should throw an authorization error without Execution read permission', async () => {
const user = new User();
expect(() => user.authorizedExecutions).toThrowError(
'The user is not authorized!'
);
});
});
describe('login', () => {
it('should return true when the given password matches with the user password', async () => {
const user = await createUser({ password: 'sample-password' });
expect(await user.login('sample-password')).toBe(true);
});
it('should return false when the given password does not match with the user password', async () => {
const user = await createUser({ password: 'sample-password' });
expect(await user.login('wrong-password')).toBe(false);
});
});
it('generateResetPasswordToken should persist a random reset password token with the current date', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 11, 15, 17, 0, 0);
vi.setSystemTime(date);
const user = await createUser({
resetPasswordToken: null,
resetPasswordTokenSentAt: null,
});
await user.generateResetPasswordToken();
const refetchedUser = await user.$query();
expect(refetchedUser.resetPasswordToken.length).toBe(128);
expect(refetchedUser.resetPasswordTokenSentAt).toStrictEqual(date);
vi.useRealTimers();
});
it('generateInvitationToken should persist a random invitation token with the current date', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 11, 15, 26, 0, 0);
vi.setSystemTime(date);
const user = await createUser({
invitationToken: null,
invitationTokenSentAt: null,
});
await user.generateInvitationToken();
const refetchedUser = await user.$query();
expect(refetchedUser.invitationToken.length).toBe(128);
expect(refetchedUser.invitationTokenSentAt).toStrictEqual(date);
vi.useRealTimers();
});
it('resetPassword should persist given password and remove reset password token', async () => {
const user = await createUser({
resetPasswordToken: 'reset-password-token',
resetPasswordTokenSentAt: '2024-11-11T12:26:00.000Z',
});
await user.resetPassword('new-password');
const refetchedUser = await user.$query();
expect(refetchedUser.resetPasswordToken).toBe(null);
expect(refetchedUser.resetPasswordTokenSentAt).toBe(null);
expect(await refetchedUser.login('new-password')).toBe(true);
});
it('acceptInvitation should persist given password, set user active and remove invitation token', async () => {
const user = await createUser({
invitationToken: 'invitation-token',
invitationTokenSentAt: '2024-11-11T12:26:00.000Z',
status: 'invited',
});
await user.acceptInvitation('new-password');
const refetchedUser = await user.$query();
expect(refetchedUser.invitationToken).toBe(null);
expect(refetchedUser.invitationTokenSentAt).toBe(null);
expect(refetchedUser.status).toBe('active');
});
describe('updatePassword', () => {
it('should update password when the given current password matches with the user password', async () => {
const user = await createUser({ password: 'sample-password' });
const updatedUser = await user.updatePassword({
currentPassword: 'sample-password',
password: 'new-password',
});
expect(await updatedUser.login('new-password')).toBe(true);
});
it('should throw validation error when the given current password does not match with the user password', async () => {
const user = await createUser({ password: 'sample-password' });
await expect(
user.updatePassword({
currentPassword: 'wrong-password',
password: 'new-password',
})
).rejects.toThrowError('currentPassword: is incorrect.');
});
});
it('softRemove should soft remove the user, its associations and queue it for hard deletion in 30 days', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 12, 12, 50, 0, 0);
vi.setSystemTime(date);
const user = await createUser();
const softRemoveAssociationsSpy = vi
.spyOn(user, 'softRemoveAssociations')
.mockReturnValue();
const deleteUserQueueAddSpy = vi
.spyOn(deleteUserQueue, 'add')
.mockResolvedValue();
await user.softRemove();
const refetchedSoftDeletedUser = await user.$query().withSoftDeleted();
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
const jobName = `Delete user - ${user.id}`;
const jobPayload = { id: user.id };
const jobOptions = {
delay: millisecondsFor30Days,
};
expect(softRemoveAssociationsSpy).toHaveBeenCalledOnce();
expect(refetchedSoftDeletedUser.deletedAt).toStrictEqual(date);
expect(deleteUserQueueAddSpy).toHaveBeenCalledWith(
jobName,
jobPayload,
jobOptions
);
vi.useRealTimers();
});
it.todo('softRemoveAssociations');
it('sendResetPasswordEmail should generate reset password token and queue to send reset password email', async () => {
vi.useFakeTimers();
const date = new Date(2024, 10, 12, 14, 33, 0, 0);
vi.setSystemTime(date);
const user = await createUser();
const generateResetPasswordTokenSpy = vi
.spyOn(user, 'generateResetPasswordToken')
.mockReturnValue();
const emailQueueAddSpy = vi.spyOn(emailQueue, 'add').mockResolvedValue();
await user.sendResetPasswordEmail();
const refetchedUser = await user.$query();
const jobName = `Reset Password Email - ${user.id}`;
const jobPayload = {
email: refetchedUser.email,
subject: 'Reset Password',
template: 'reset-password-instructions.ee',
params: {
token: refetchedUser.resetPasswordToken,
webAppUrl: appConfig.webAppUrl,
fullName: refetchedUser.fullName,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
expect(generateResetPasswordTokenSpy).toHaveBeenCalledOnce();
expect(emailQueueAddSpy).toHaveBeenCalledWith(
jobName,
jobPayload,
jobOptions
);
vi.useRealTimers();
});
describe('isResetPasswordTokenValid', () => {
it('should return true when resetPasswordTokenSentAt is within the next four hours', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 16, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.resetPasswordTokenSentAt = '2024-11-12T13:31:00.000Z';
expect(user.isResetPasswordTokenValid()).toBe(true);
vi.useRealTimers();
});
it('should return false when there is no resetPasswordTokenSentAt', async () => {
const user = new User();
expect(user.isResetPasswordTokenValid()).toBe(false);
});
it('should return false when resetPasswordTokenSentAt is older than four hours', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 16, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.resetPasswordTokenSentAt = '2024-11-12T12:29:00.000Z';
expect(user.isResetPasswordTokenValid()).toBe(false);
vi.useRealTimers();
});
});
it('sendInvitationEmail should generate invitation token and queue to send invitation email', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 12, hour: 17, minute: 10 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = await createUser();
const generateInvitationTokenSpy = vi
.spyOn(user, 'generateInvitationToken')
.mockReturnValue();
const emailQueueAddSpy = vi.spyOn(emailQueue, 'add').mockResolvedValue();
await user.sendInvitationEmail();
const refetchedUser = await user.$query();
const jobName = `Invitation Email - ${refetchedUser.id}`;
const jobPayload = {
email: refetchedUser.email,
subject: 'You are invited!',
template: 'invitation-instructions',
params: {
fullName: refetchedUser.fullName,
acceptInvitationUrl: refetchedUser.acceptInvitationUrl,
},
};
const jobOptions = {
removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS,
removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS,
};
expect(generateInvitationTokenSpy).toHaveBeenCalledOnce();
expect(emailQueueAddSpy).toHaveBeenCalledWith(
jobName,
jobPayload,
jobOptions
);
vi.useRealTimers();
});
describe('isInvitationTokenValid', () => {
it('should return truen when invitationTokenSentAt is within the next four hours', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 14, hour: 14, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.invitationTokenSentAt = '2024-11-14T13:31:00.000Z';
expect(user.isInvitationTokenValid()).toBe(true);
vi.useRealTimers();
});
it('should return false when there is no invitationTokenSentAt', async () => {
const user = new User();
expect(user.isInvitationTokenValid()).toBe(false);
});
it('should return false when invitationTokenSentAt is older than seventy two hours', async () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 14, hour: 14, minute: 30 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.invitationTokenSentAt = '2024-11-11T14:20:00.000Z';
expect(user.isInvitationTokenValid()).toBe(false);
vi.useRealTimers();
});
});
describe('generateHash', () => {
it('should hash password and re-assign it', async () => {
const user = new User();
user.password = 'sample-password';
await user.generateHash();
expect(user.password).not.toBe('sample-password');
expect(await user.login('sample-password')).toBe(true);
});
it('should do nothing when password does not exist', async () => {
const user = new User();
await user.generateHash();
expect(user.password).toBe(undefined);
});
});
it('startTrialPeriod should assign trialExpiryDate 30 days from now', () => {
vi.useFakeTimers();
const date = DateTime.fromObject(
{ year: 2024, month: 11, day: 14, hour: 16 },
{ zone: 'UTC+0' }
);
vi.setSystemTime(date);
const user = new User();
user.startTrialPeriod();
expect(user.trialExpiryDate).toBe('2024-12-14');
vi.useRealTimers();
});
});

View File

@@ -1,5 +1,5 @@
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import Role from '../../src/models/role.js'; import Role from '../../src/models/role';
export const createRole = async (params = {}) => { export const createRole = async (params = {}) => {
const name = faker.lorem.word(); const name = faker.lorem.word();

View File

@@ -6,6 +6,18 @@ const createFlowMock = async (flow) => {
status: flow.status, status: flow.status,
createdAt: flow.createdAt.getTime(), createdAt: flow.createdAt.getTime(),
updatedAt: flow.updatedAt.getTime(), updatedAt: flow.updatedAt.getTime(),
steps: [
{
position: 1,
status: 'incomplete',
type: 'trigger',
},
{
position: 2,
status: 'incomplete',
type: 'action',
},
],
}; };
return { return {

View File

@@ -2,25 +2,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
root: './',
environment: 'node', environment: 'node',
setupFiles: ['./test/setup/global-hooks.js'], setupFiles: ['./test/setup/global-hooks.js'],
globals: true, globals: true,
reporters: process.env.GITHUB_ACTIONS ? ['dot', 'github-actions'] : ['dot'],
coverage: {
reportOnFailure: true,
provider: 'v8',
reportsDirectory: './coverage',
reporter: ['text', 'lcov'],
all: true,
include: ['**/src/models/**', '**/src/controllers/**'],
thresholds: {
autoUpdate: true,
statements: 93.41,
branches: 93.46,
functions: 95.95,
lines: 93.41,
},
},
}, },
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
pages/.vitepress/cache

View File

@@ -4,7 +4,6 @@
"license": "See LICENSE file", "license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.", "description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "vitepress dev pages --port 3002", "dev": "vitepress dev pages --port 3002",
"build": "vitepress build pages", "build": "vitepress build pages",

View File

@@ -6,19 +6,11 @@ Clone main branch of Automatisch.
git clone git@github.com:automatisch/automatisch.git git clone git@github.com:automatisch/automatisch.git
``` ```
Then, install the dependencies for both backend and web packages separately. Then, install the dependencies.
```bash ```bash
cd automatisch cd automatisch
# Install backend dependencies
cd packages/backend
yarn install yarn install
# Install web dependencies
cd packages/web
yarn install
``` ```
## Backend ## Backend
@@ -61,14 +53,12 @@ yarn db:seed:user
Start the main backend server. Start the main backend server.
```bash ```bash
cd packages/backend
yarn dev yarn dev
``` ```
Start the worker server in another terminal tab. Start the worker server in another terminal tab.
```bash ```bash
cd packages/backend
yarn worker yarn worker
``` ```
@@ -94,7 +84,6 @@ It will automatically open [http://localhost:3001](http://localhost:3001) in you
```bash ```bash
cd packages/docs cd packages/docs
yarn install
yarn dev yarn dev
``` ```

View File

@@ -1,6 +1,6 @@
# Repository Structure # Repository Structure
We manage a monorepo structure with the following packages: We use `lerna` with `yarn workspaces` to manage the mono repository. We have the following packages:
``` ```
. .
@@ -15,5 +15,3 @@ We manage a monorepo structure with the following packages:
- `docs` - The docs package contains the documentation website. - `docs` - The docs package contains the documentation website.
- `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage. - `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage.
- `web` - The web package contains the frontend application of Automatisch. - `web` - The web package contains the frontend application of Automatisch.
Each package is independently managed, and has its own package.json file to manage dependencies. This allows for better isolation and flexibility.

File diff suppressed because it is too large Load Diff

View File

@@ -29,12 +29,10 @@
"@playwright/test": "^1.45.1" "@playwright/test": "^1.45.1"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.13.0", "eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"knex": "^2.4.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"micro": "^10.0.1", "micro": "^10.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",

File diff suppressed because it is too large Load Diff

View File

@@ -18,7 +18,6 @@
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"axios": "^1.6.0",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"compare-versions": "^4.1.3", "compare-versions": "^4.1.3",
"lodash": "^4.17.21", "lodash": "^4.17.21",

View File

@@ -112,7 +112,7 @@ export default function ResetPasswordForm() {
<Alert <Alert
data-test="accept-invitation-form-error" data-test="accept-invitation-form-error"
severity="error" severity="error"
sx={{ mt: 1 }} sx={{ mt: 1, fontWeight: 500 }}
> >
{formatMessage('acceptInvitationForm.invalidToken')} {formatMessage('acceptInvitationForm.invalidToken')}
</Alert> </Alert>

View File

@@ -126,7 +126,7 @@ function AddAppConnection(props) {
</DialogTitle> </DialogTitle>
{authDocUrl && ( {authDocUrl && (
<Alert severity="info"> <Alert severity="info" sx={{ fontWeight: 300 }}>
{formatMessage('addAppConnection.callToDocs', { {formatMessage('addAppConnection.callToDocs', {
appName: name, appName: name,
docsLink: generateExternalLink(authDocUrl), docsLink: generateExternalLink(authDocUrl),
@@ -138,7 +138,7 @@ function AddAppConnection(props) {
<Alert <Alert
data-test="add-connection-error" data-test="add-connection-error"
severity="error" severity="error"
sx={{ mt: 1, wordBreak: 'break-all' }} sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
> >
{!errorDetails && errorMessage} {!errorDetails && errorMessage}
{errorDetails && ( {errorDetails && (

View File

@@ -32,7 +32,10 @@ function AdminApplicationAuthClientDialog(props) {
<Dialog open={true} onClose={onClose}> <Dialog open={true} onClose={onClose}>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
{error && ( {error && (
<Alert severity="error" sx={{ mt: 1, wordBreak: 'break-all' }}> <Alert
severity="error"
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
>
{error.message} {error.message}
</Alert> </Alert>
)} )}

View File

@@ -29,8 +29,6 @@ function ControlledAutocomplete(props) {
options = [], options = [],
dependsOn = [], dependsOn = [],
showOptionValue, showOptionValue,
renderInput,
showHelperText = true,
...autocompleteProps ...autocompleteProps
} = props; } = props;
let dependsOnValues = []; let dependsOnValues = [];
@@ -107,18 +105,16 @@ function ControlledAutocomplete(props) {
)} )}
</li> </li>
)} )}
renderInput={(params) => renderInput(params, fieldState)}
/> />
{showHelperText && (
<FormHelperText <FormHelperText
variant="outlined" variant="outlined"
error={Boolean(fieldState.isTouched && fieldState.error)} error={Boolean(fieldState.isTouched && fieldState.error)}
> >
{fieldState.isTouched {fieldState.isTouched
? fieldState.error?.message || description ? fieldState.error?.message || description
: description} : description}
</FormHelperText> </FormHelperText>
)}
</div> </div>
)} )}
/> />
@@ -136,8 +132,6 @@ ControlledAutocomplete.propTypes = {
onBlur: PropTypes.func, onBlur: PropTypes.func,
onChange: PropTypes.func, onChange: PropTypes.func,
options: PropTypes.array, options: PropTypes.array,
renderInput: PropTypes.func.isRequired,
showHelperText: PropTypes.bool,
}; };
export default ControlledAutocomplete; export default ControlledAutocomplete;

View File

@@ -6,7 +6,7 @@ import FormHelperText from '@mui/material/FormHelperText';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import ClearIcon from '@mui/icons-material/Clear'; import ClearIcon from '@mui/icons-material/Clear';
import { ActionButtonsWrapper } from './style'; import { ActionButtonsWrapper } from './style';
import { ClickAwayListener } from '@mui/base/ClickAwayListener'; import ClickAwayListener from '@mui/base/ClickAwayListener';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import { createEditor } from 'slate'; import { createEditor } from 'slate';
import { Editable, ReactEditor } from 'slate-react'; import { Editable, ReactEditor } from 'slate-react';

View File

@@ -13,14 +13,12 @@ function Form(props) {
resolver, resolver,
render, render,
mode = 'all', mode = 'all',
reValidateMode = 'onBlur',
automaticValidation = true,
...formProps ...formProps
} = props; } = props;
const methods = useForm({ const methods = useForm({
defaultValues, defaultValues,
reValidateMode, reValidateMode: 'onBlur',
resolver, resolver,
mode, mode,
}); });
@@ -32,9 +30,7 @@ function Form(props) {
* For fields having `dependsOn` fields, we need to re-validate the form. * For fields having `dependsOn` fields, we need to re-validate the form.
*/ */
React.useEffect(() => { React.useEffect(() => {
if (automaticValidation) { methods.trigger();
methods.trigger();
}
}, [methods.trigger, form]); }, [methods.trigger, form]);
React.useEffect(() => { React.useEffect(() => {
@@ -60,8 +56,6 @@ Form.propTypes = {
render: PropTypes.func, render: PropTypes.func,
resolver: PropTypes.func, resolver: PropTypes.func,
mode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit', 'onTouched', 'all']), mode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit', 'onTouched', 'all']),
reValidateMode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit']),
automaticValidation: PropTypes.bool,
}; };
export default Form; export default Form;

View File

@@ -188,7 +188,7 @@ function InstallationForm() {
)} )}
/> />
{install.isSuccess && ( {install.isSuccess && (
<Alert data-test="success-alert" severity="success" sx={{ mt: 3 }}> <Alert data-test="success-alert" severity="success" sx={{ mt: 3, fontWeight: 500 }}>
{formatMessage('installationForm.success', { {formatMessage('installationForm.success', {
link: (str) => ( link: (str) => (
<Link <Link

View File

@@ -11,19 +11,16 @@ import Form from 'components/Form';
import TextField from 'components/TextField'; import TextField from 'components/TextField';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useCreateAccessToken from 'hooks/useCreateAccessToken'; import useCreateAccessToken from 'hooks/useCreateAccessToken';
import { Alert } from '@mui/material'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
function LoginForm() { function LoginForm() {
const isCloud = useCloud(); const isCloud = useCloud();
const navigate = useNavigate(); const navigate = useNavigate();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const authentication = useAuthentication(); const authentication = useAuthentication();
const { const { mutateAsync: createAccessToken, isPending: loading } =
mutateAsync: createAccessToken, useCreateAccessToken();
isPending: loading,
error,
isError,
} = useCreateAccessToken();
React.useEffect(() => { React.useEffect(() => {
if (authentication.isAuthenticated) { if (authentication.isAuthenticated) {
@@ -40,19 +37,11 @@ function LoginForm() {
}); });
const { token } = data; const { token } = data;
authentication.updateToken(token); authentication.updateToken(token);
} catch {} } catch (error) {
}; enqueueSnackbar(error?.message || formatMessage('loginForm.error'), {
variant: 'error',
const renderError = () => { });
const errors = error?.response?.data?.errors?.general || [ }
formatMessage('loginForm.error'),
];
return errors.map((error) => (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
));
}; };
return ( return (
@@ -105,8 +94,6 @@ function LoginForm() {
</Link> </Link>
)} )}
{isError && renderError()}
<LoadingButton <LoadingButton
type="submit" type="submit"
variant="contained" variant="contained"

View File

@@ -1,51 +0,0 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import PropTypes from 'prop-types';
import ControlledCheckbox from 'components/ControlledCheckbox';
const ActionField = ({ action, subject, disabled, name, syncIsCreator }) => {
const { formState, resetField } = useFormContext();
const actionDefaultValue =
formState.defaultValues?.[name]?.[subject.key]?.[action.key].value;
const conditionFieldName = `${name}.${subject.key}.${action.key}.conditions.isCreator`;
const conditionFieldTouched =
formState.touchedFields?.[name]?.[subject.key]?.[action.key]?.conditions
?.isCreator === true;
const handleSyncIsCreator = (newValue) => {
if (
syncIsCreator &&
actionDefaultValue === false &&
!conditionFieldTouched
) {
resetField(conditionFieldName, { defaultValue: newValue });
}
};
return (
<ControlledCheckbox
disabled={disabled}
name={`${name}.${subject.key}.${action.key}.value`}
dataTest={`${action.key.toLowerCase()}-checkbox`}
onChange={(e, value) => {
handleSyncIsCreator(value);
}}
/>
);
};
ActionField.propTypes = {
action: PropTypes.shape({
key: PropTypes.string.isRequired,
subjects: PropTypes.arrayOf(PropTypes.string).isRequired,
}),
subject: PropTypes.shape({
key: PropTypes.string.isRequired,
}).isRequired,
disabled: PropTypes.bool,
name: PropTypes.string.isRequired,
syncIsCreator: PropTypes.bool,
};
export default ActionField;

View File

@@ -25,6 +25,7 @@ function PermissionSettings(props) {
subject, subject,
actions, actions,
conditions, conditions,
defaultChecked,
} = props; } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { getValues, resetField } = useFormContext(); const { getValues, resetField } = useFormContext();
@@ -33,7 +34,7 @@ function PermissionSettings(props) {
for (const action of actions) { for (const action of actions) {
for (const condition of conditions) { for (const condition of conditions) {
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`; const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
resetField(fieldName, { keepTouched: true }); resetField(fieldName);
} }
} }
onClose(); onClose();
@@ -44,7 +45,7 @@ function PermissionSettings(props) {
for (const condition of conditions) { for (const condition of conditions) {
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`; const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
const value = getValues(fieldName); const value = getValues(fieldName);
resetField(fieldName, { defaultValue: value, keepTouched: true }); resetField(fieldName, { defaultValue: value });
} }
} }
onClose(); onClose();
@@ -55,7 +56,6 @@ function PermissionSettings(props) {
open={open} open={open}
onClose={cancel} onClose={cancel}
data-test={`${subject}-role-conditions-modal`} data-test={`${subject}-role-conditions-modal`}
keepMounted
> >
<DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle> <DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle>
@@ -65,10 +65,10 @@ function PermissionSettings(props) {
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell component="th" /> <TableCell component="th" />
{actions.map((action) => ( {actions.map((action) => (
<TableCell component="th" key={action.key}> <TableCell component="th" key={action.key}>
<Typography <Typography
component="div"
variant="subtitle1" variant="subtitle1"
align="center" align="center"
sx={{ sx={{
@@ -89,7 +89,7 @@ function PermissionSettings(props) {
sx={{ '&:last-child td': { border: 0 } }} sx={{ '&:last-child td': { border: 0 } }}
> >
<TableCell scope="row"> <TableCell scope="row">
<Typography variant="subtitle2" component="div"> <Typography variant="subtitle2">
{condition.label} {condition.label}
</Typography> </Typography>
</TableCell> </TableCell>
@@ -99,13 +99,14 @@ function PermissionSettings(props) {
key={`${action.key}.${condition.key}`} key={`${action.key}.${condition.key}`}
align="center" align="center"
> >
<Typography variant="subtitle2" component="div"> <Typography variant="subtitle2">
{action.subjects.includes(subject) && ( {action.subjects.includes(subject) && (
<ControlledCheckbox <ControlledCheckbox
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`} name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
dataTest={`${ dataTest={`${
condition.key condition.key
}-${action.key.toLowerCase()}-checkbox`} }-${action.key.toLowerCase()}-checkbox`}
defaultValue={defaultChecked}
disabled={ disabled={
getValues( getValues(
`${fieldPrefix}.${action.key}.value`, `${fieldPrefix}.${action.key}.value`,
@@ -143,6 +144,7 @@ PermissionSettings.propTypes = {
fieldPrefix: PropTypes.string.isRequired, fieldPrefix: PropTypes.string.isRequired,
subject: PropTypes.string.isRequired, subject: PropTypes.string.isRequired,
open: PropTypes.bool, open: PropTypes.bool,
defaultChecked: PropTypes.bool,
actions: PropTypes.arrayOf( actions: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
label: PropTypes.string, label: PropTypes.string,

View File

@@ -12,15 +12,15 @@ import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import * as React from 'react'; import * as React from 'react';
import ControlledCheckbox from 'components/ControlledCheckbox';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee'; import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
import PermissionSettings from './PermissionSettings.ee'; import PermissionSettings from './PermissionSettings.ee';
import PermissionCatalogFieldLoader from './PermissionCatalogFieldLoader'; import PermissionCatalogFieldLoader from './PermissionCatalogFieldLoader';
import ActionField from './ActionField';
const PermissionCatalogField = ({ const PermissionCatalogField = ({
name = 'permissions', name = 'permissions',
disabled = false, disabled = false,
syncIsCreator = false, defaultChecked = false,
}) => { }) => {
const { data, isLoading: isPermissionCatalogLoading } = const { data, isLoading: isPermissionCatalogLoading } =
usePermissionCatalog(); usePermissionCatalog();
@@ -39,7 +39,6 @@ const PermissionCatalogField = ({
{permissionCatalog?.actions.map((action) => ( {permissionCatalog?.actions.map((action) => (
<TableCell component="th" key={action.key}> <TableCell component="th" key={action.key}>
<Typography <Typography
component="div"
variant="subtitle1" variant="subtitle1"
align="center" align="center"
sx={{ sx={{
@@ -63,23 +62,20 @@ const PermissionCatalogField = ({
data-test={`${subject.key}-permission-row`} data-test={`${subject.key}-permission-row`}
> >
<TableCell scope="row"> <TableCell scope="row">
<Typography variant="subtitle2" component="div"> <Typography variant="subtitle2">{subject.label}</Typography>
{subject.label}
</Typography>
</TableCell> </TableCell>
{permissionCatalog?.actions.map((action) => ( {permissionCatalog?.actions.map((action) => (
<TableCell key={`${subject.key}.${action.key}`} align="center"> <TableCell key={`${subject.key}.${action.key}`} align="center">
<Typography variant="subtitle2" component="div"> <Typography variant="subtitle2">
{action.subjects.includes(subject.key) && ( {action.subjects.includes(subject.key) && (
<ActionField <ControlledCheckbox
action={action}
subject={subject}
disabled={disabled} disabled={disabled}
name={name} name={`${name}.${subject.key}.${action.key}.value`}
syncIsCreator={syncIsCreator} dataTest={`${action.key.toLowerCase()}-checkbox`}
/> />
)} )}
{!action.subjects.includes(subject.key) && '-'} {!action.subjects.includes(subject.key) && '-'}
</Typography> </Typography>
</TableCell> </TableCell>
@@ -104,6 +100,7 @@ const PermissionCatalogField = ({
subject={subject.key} subject={subject.key}
actions={permissionCatalog?.actions} actions={permissionCatalog?.actions}
conditions={permissionCatalog?.conditions} conditions={permissionCatalog?.conditions}
defaultChecked={defaultChecked}
/> />
</Stack> </Stack>
</TableCell> </TableCell>
@@ -117,7 +114,7 @@ const PermissionCatalogField = ({
PermissionCatalogField.propTypes = { PermissionCatalogField.propTypes = {
name: PropTypes.string, name: PropTypes.string,
disabled: PropTypes.bool, disabled: PropTypes.bool,
syncIsCreator: PropTypes.bool, defaultChecked: PropTypes.bool,
}; };
export default PermissionCatalogField; export default PermissionCatalogField;

View File

@@ -1,5 +1,5 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ClickAwayListener } from '@mui/base/ClickAwayListener'; import ClickAwayListener from '@mui/base/ClickAwayListener';
import FormHelperText from '@mui/material/FormHelperText'; import FormHelperText from '@mui/material/FormHelperText';
import InputLabel from '@mui/material/InputLabel'; import InputLabel from '@mui/material/InputLabel';
import * as React from 'react'; import * as React from 'react';

View File

@@ -7,7 +7,7 @@ import FormControl from '@mui/material/FormControl';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
export default function SearchInput({ onChange, defaultValue = '' }) { export default function SearchInput({ onChange }) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
return ( return (
<FormControl variant="outlined" fullWidth> <FormControl variant="outlined" fullWidth>
@@ -16,7 +16,6 @@ export default function SearchInput({ onChange, defaultValue = '' }) {
</InputLabel> </InputLabel>
<OutlinedInput <OutlinedInput
defaultValue={defaultValue}
id="search-input" id="search-input"
type="text" type="text"
size="medium" size="medium"
@@ -35,5 +34,4 @@ export default function SearchInput({ onChange, defaultValue = '' }) {
SearchInput.propTypes = { SearchInput.propTypes = {
onChange: PropTypes.func, onChange: PropTypes.func,
defaultValue: PropTypes.string,
}; };

View File

@@ -7,9 +7,9 @@ function Variable({ attributes, children, element, disabled }) {
const focused = useFocused(); const focused = useFocused();
const label = ( const label = (
<> <>
{children}
<span style={{ fontWeight: 500 }}>{element.name}</span>:{' '} <span style={{ fontWeight: 500 }}>{element.name}</span>:{' '}
<span style={{ fontWeight: 300 }}>{element.sampleValue}</span> <span style={{ fontWeight: 300 }}>{element.sampleValue}</span>
{children}
</> </>
); );
return ( return (

View File

@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup'; import ButtonGroup from '@mui/material/ButtonGroup';
import { ClickAwayListener } from '@mui/base/ClickAwayListener'; import ClickAwayListener from '@mui/material/ClickAwayListener';
import Grow from '@mui/material/Grow'; import Grow from '@mui/material/Grow';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList'; import MenuList from '@mui/material/MenuList';

View File

@@ -11,17 +11,14 @@ export default function SubscriptionCancelledAlert() {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const subscription = useSubscription(); const subscription = useSubscription();
const trial = useUserTrial(); const trial = useUserTrial();
if (subscription?.data?.status === 'active' || trial.hasTrial)
return <React.Fragment />;
const cancellationEffectiveDateObject = DateTime.fromISO( const cancellationEffectiveDateObject = DateTime.fromISO(
subscription?.data?.cancellationEffectiveDate, subscription?.data?.cancellationEffectiveDate,
); );
if (
subscription?.data?.status === 'active' ||
trial.hasTrial ||
!cancellationEffectiveDateObject.isValid
)
return <React.Fragment />;
return ( return (
<Alert <Alert
severity="warning" severity="warning"

View File

@@ -84,7 +84,10 @@ function TestSubstep(props) {
}} }}
> >
{hasError && ( {hasError && (
<Alert severity="error" sx={{ mb: 2, width: '100%' }}> <Alert
severity="error"
sx={{ mb: 2, fontWeight: 500, width: '100%' }}
>
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}> <pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(errorDetails, null, 2)} {JSON.stringify(errorDetails, null, 2)}
</pre> </pre>
@@ -101,11 +104,13 @@ function TestSubstep(props) {
severity="warning" severity="warning"
sx={{ mb: 1, width: '100%' }} sx={{ mb: 1, width: '100%' }}
> >
<AlertTitle> <AlertTitle sx={{ fontWeight: 700 }}>
{formatMessage('flowEditor.noTestDataTitle')} {formatMessage('flowEditor.noTestDataTitle')}
</AlertTitle> </AlertTitle>
<Box>{formatMessage('flowEditor.noTestDataMessage')}</Box> <Box sx={{ fontWeight: 400 }}>
{formatMessage('flowEditor.noTestDataMessage')}
</Box>
</Alert> </Alert>
)} )}

View File

@@ -31,7 +31,6 @@ function TextField(props) {
onBlur, onBlur,
onChange, onChange,
'data-test': dataTest, 'data-test': dataTest,
showError = false,
...textFieldProps ...textFieldProps
} = props; } = props;
return ( return (
@@ -48,7 +47,6 @@ function TextField(props) {
onBlur: controllerOnBlur, onBlur: controllerOnBlur,
...field ...field
}, },
fieldState: { error },
}) => ( }) => (
<MuiTextField <MuiTextField
{...textFieldProps} {...textFieldProps}
@@ -74,7 +72,6 @@ function TextField(props) {
inputProps={{ inputProps={{
'data-test': dataTest, 'data-test': dataTest,
}} }}
{...(showError && { helperText: error?.message, error: !!error })}
/> />
)} )}
/> />
@@ -92,7 +89,6 @@ TextField.propTypes = {
disabled: PropTypes.bool, disabled: PropTypes.bool,
onBlur: PropTypes.func, onBlur: PropTypes.func,
onChange: PropTypes.func, onChange: PropTypes.func,
showError: PropTypes.bool,
}; };
export default TextField; export default TextField;

View File

@@ -44,7 +44,7 @@ function BillingCard(props) {
</Typography> </Typography>
<Typography variant="h6" fontWeight="bold"> <Typography variant="h6" fontWeight="bold">
{title || '---'} {title}
</Typography> </Typography>
</CardContent> </CardContent>
@@ -119,12 +119,12 @@ export default function UsageDataInformation() {
text: 'Upgrade plan', text: 'Upgrade plan',
}, },
nextBillAmount: { nextBillAmount: {
title: null, title: '---',
action: null, action: null,
text: null, text: null,
}, },
nextBillDate: { nextBillDate: {
title: null, title: '---',
action: null, action: null,
text: null, text: null,
}, },
@@ -137,9 +137,7 @@ export default function UsageDataInformation() {
text: formatMessage('usageDataInformation.cancelPlan'), text: formatMessage('usageDataInformation.cancelPlan'),
}, },
nextBillAmount: { nextBillAmount: {
title: subscription?.nextBillAmount title: `${subscription?.nextBillAmount}`,
? `${subscription?.nextBillAmount}`
: null,
action: subscription?.updateUrl, action: subscription?.updateUrl,
text: formatMessage('usageDataInformation.updatePaymentMethod'), text: formatMessage('usageDataInformation.updatePaymentMethod'),
}, },

View File

@@ -45,36 +45,3 @@ export function getPermissions(computedPermissions) {
[], [],
); );
} }
export const getComputedPermissionsDefaultValues = (
data,
conditionsInitialValues,
) => {
if (!data) return {};
const conditions = {};
data.conditions.forEach((condition) => {
conditions[condition.key] =
conditionsInitialValues?.[condition.key] || false;
});
const result = {};
data.subjects.forEach((subject) => {
const subjectKey = subject.key;
result[subjectKey] = {};
data.actions.forEach((action) => {
const actionKey = action.key;
if (action.subjects.includes(subjectKey)) {
result[subjectKey][actionKey] = {
value: false,
conditions: { ...conditions },
};
}
});
});
return result;
};

View File

@@ -5,8 +5,6 @@ import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import Form from 'components/Form'; import Form from 'components/Form';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
@@ -25,42 +23,6 @@ function generateFormRoleMappings(roleMappings) {
})); }));
} }
const uniqueRemoteRoleName = (array, context, formatMessage) => {
const seen = new Set();
for (const [index, value] of array.entries()) {
if (seen.has(value.remoteRoleName)) {
const path = `${context.path}[${index}].remoteRoleName`;
return context.createError({
message: `${formatMessage('roleMappingsForm.remoteRoleName')} must be unique`,
path,
});
}
seen.add(value.remoteRoleName);
}
return true;
};
const getValidationSchema = (formatMessage) =>
yup.object({
roleMappings: yup
.array()
.of(
yup.object({
roleId: yup
.string()
.required(`${formatMessage('roleMappingsForm.role')} is required`),
remoteRoleName: yup
.string()
.required(
`${formatMessage('roleMappingsForm.remoteRoleName')} is required`,
),
}),
)
.test('unique-remoteRoleName', '', (value, ctx) => {
return uniqueRemoteRoleName(value, ctx, formatMessage);
}),
});
function RoleMappings({ provider, providerLoading }) { function RoleMappings({ provider, providerLoading }) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
@@ -132,15 +94,7 @@ function RoleMappings({ provider, providerLoading }) {
<Typography variant="h3"> <Typography variant="h3">
{formatMessage('roleMappingsForm.title')} {formatMessage('roleMappingsForm.title')}
</Typography> </Typography>
<Form <Form defaultValues={defaultValues} onSubmit={handleRoleMappingsUpdate}>
defaultValues={defaultValues}
onSubmit={handleRoleMappingsUpdate}
resolver={yupResolver(getValidationSchema(formatMessage))}
mode="onSubmit"
reValidateMode="onChange"
noValidate
automaticValidation={false}
>
<Stack direction="column" spacing={2}> <Stack direction="column" spacing={2}>
<RoleMappingsFieldArray /> <RoleMappingsFieldArray />
<LoadingButton <LoadingButton

View File

@@ -55,7 +55,6 @@ function RoleMappingsFieldArray() {
label={formatMessage('roleMappingsForm.remoteRoleName')} label={formatMessage('roleMappingsForm.remoteRoleName')}
fullWidth fullWidth
required required
showError
/> />
<ControlledAutocomplete <ControlledAutocomplete
name={`roleMappings.${index}.roleId`} name={`roleMappings.${index}.roleId`}
@@ -63,17 +62,14 @@ function RoleMappingsFieldArray() {
disablePortal disablePortal
disableClearable disableClearable
options={generateRoleOptions(roles)} options={generateRoleOptions(roles)}
renderInput={(params, { error }) => ( renderInput={(params) => (
<MuiTextField <MuiTextField
{...params} {...params}
label={formatMessage('roleMappingsForm.role')} label={formatMessage('roleMappingsForm.role')}
required required
error={!!error}
helperText={error?.message}
/> />
)} )}
loading={isRolesLoading} loading={isRolesLoading}
showHelperText={false}
/> />
</Stack> </Stack>
<IconButton <IconButton

View File

@@ -11,13 +11,9 @@ import Form from 'components/Form';
import PageTitle from 'components/PageTitle'; import PageTitle from 'components/PageTitle';
import TextField from 'components/TextField'; import TextField from 'components/TextField';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import { import { getPermissions } from 'helpers/computePermissions.ee';
getComputedPermissionsDefaultValues,
getPermissions,
} from 'helpers/computePermissions.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useAdminCreateRole from 'hooks/useAdminCreateRole'; import useAdminCreateRole from 'hooks/useAdminCreateRole';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
export default function CreateRole() { export default function CreateRole() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -25,21 +21,6 @@ export default function CreateRole() {
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const { mutateAsync: createRole, isPending: isCreateRolePending } = const { mutateAsync: createRole, isPending: isCreateRolePending } =
useAdminCreateRole(); useAdminCreateRole();
const { data: permissionCatalogData } = usePermissionCatalog();
const defaultValues = React.useMemo(
() => ({
name: '',
description: '',
computedPermissions: getComputedPermissionsDefaultValues(
permissionCatalogData?.data,
{
isCreator: true,
},
),
}),
[permissionCatalogData],
);
const handleRoleCreation = async (roleData) => { const handleRoleCreation = async (roleData) => {
try { try {
@@ -83,7 +64,7 @@ export default function CreateRole() {
</Grid> </Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}> <Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
<Form onSubmit={handleRoleCreation} defaultValues={defaultValues}> <Form onSubmit={handleRoleCreation}>
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
<TextField <TextField
required={true} required={true}
@@ -100,7 +81,10 @@ export default function CreateRole() {
data-test="description-input" data-test="description-input"
/> />
<PermissionCatalogField name="computedPermissions" /> <PermissionCatalogField
name="computedPermissions"
defaultChecked={true}
/>
<LoadingButton <LoadingButton
type="submit" type="submit"

View File

@@ -124,6 +124,7 @@ export default function CreateUser() {
<Alert <Alert
severity="info" severity="info"
color="primary" color="primary"
sx={{ fontWeight: '500' }}
data-test="invitation-email-info-alert" data-test="invitation-email-info-alert"
> >
{formatMessage('createUser.invitationEmailInfo', { {formatMessage('createUser.invitationEmailInfo', {

View File

@@ -5,7 +5,6 @@ import Stack from '@mui/material/Stack';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { merge } from 'lodash';
import Container from 'components/Container'; import Container from 'components/Container';
import Form from 'components/Form'; import Form from 'components/Form';
@@ -14,25 +13,21 @@ import PermissionCatalogField from 'components/PermissionCatalogField/index.ee';
import TextField from 'components/TextField'; import TextField from 'components/TextField';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import { import {
getComputedPermissionsDefaultValues,
getPermissions, getPermissions,
getRoleWithComputedPermissions, getRoleWithComputedPermissions,
} from 'helpers/computePermissions.ee'; } from 'helpers/computePermissions.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useAdminUpdateRole from 'hooks/useAdminUpdateRole'; import useAdminUpdateRole from 'hooks/useAdminUpdateRole';
import useRole from 'hooks/useRole.ee'; import useRole from 'hooks/useRole.ee';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
export default function EditRole() { export default function EditRole() {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const navigate = useNavigate(); const navigate = useNavigate();
const { roleId } = useParams(); const { roleId } = useParams();
const { data: roleData, isLoading: isRoleLoading } = useRole({ roleId }); const { data, loading: isRoleLoading } = useRole({ roleId });
const { mutateAsync: updateRole, isPending: isUpdateRolePending } = const { mutateAsync: updateRole, isPending: isUpdateRolePending } =
useAdminUpdateRole(roleId); useAdminUpdateRole(roleId);
const { data: permissionCatalogData } = usePermissionCatalog(); const role = data?.data;
const role = roleData?.data;
const permissionCatalog = permissionCatalogData?.data;
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const handleRoleUpdate = async (roleData) => { const handleRoleUpdate = async (roleData) => {
@@ -57,20 +52,7 @@ export default function EditRole() {
} }
}; };
const defaultValues = React.useMemo(() => { const roleWithComputedPermissions = getRoleWithComputedPermissions(role);
const roleWithComputedPermissions = getRoleWithComputedPermissions(role);
const computedPermissionsDefaultValues =
getComputedPermissionsDefaultValues(permissionCatalog);
return {
...roleWithComputedPermissions,
computedPermissions: merge(
{},
computedPermissionsDefaultValues,
roleWithComputedPermissions.computedPermissions,
),
};
}, [role, permissionCatalog]);
return ( return (
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}> <Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
@@ -82,7 +64,10 @@ export default function EditRole() {
</Grid> </Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}> <Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
<Form defaultValues={defaultValues} onSubmit={handleRoleUpdate}> <Form
defaultValues={roleWithComputedPermissions}
onSubmit={handleRoleUpdate}
>
<Stack direction="column" gap={2}> <Stack direction="column" gap={2}>
{isRoleLoading && ( {isRoleLoading && (
<> <>
@@ -110,11 +95,12 @@ export default function EditRole() {
/> />
</> </>
)} )}
<PermissionCatalogField <PermissionCatalogField
name="computedPermissions" name="computedPermissions"
disabled={role?.isAdmin} disabled={role?.isAdmin}
syncIsCreator
/> />
<LoadingButton <LoadingButton
type="submit" type="submit"
variant="contained" variant="contained"

View File

@@ -42,9 +42,13 @@ export default function Execution() {
<Grid container item sx={{ mt: 2, mb: [2, 5] }} rowGap={3}> <Grid container item sx={{ mt: 2, mb: [2, 5] }} rowGap={3}>
{!isExecutionStepsLoading && !data?.pages?.[0].data.length && ( {!isExecutionStepsLoading && !data?.pages?.[0].data.length && (
<Alert severity="warning" sx={{ flex: 1 }}> <Alert severity="warning" sx={{ flex: 1 }}>
<AlertTitle>{formatMessage('execution.noDataTitle')}</AlertTitle> <AlertTitle sx={{ fontWeight: 700 }}>
{formatMessage('execution.noDataTitle')}
</AlertTitle>
<Box>{formatMessage('execution.noDataMessage')}</Box> <Box sx={{ fontWeight: 400 }}>
{formatMessage('execution.noDataMessage')}
</Box>
</Alert> </Alert>
)} )}

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
@@ -23,18 +23,13 @@ import useLazyFlows from 'hooks/useLazyFlows';
export default function Flows() { export default function Flows() {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const page = parseInt(searchParams.get('page') || '', 10) || 1; const page = parseInt(searchParams.get('page') || '', 10) || 1;
const flowName = searchParams.get('flowName') || ''; const [flowName, setFlowName] = React.useState('');
const [isLoading, setIsLoading] = React.useState(true); const [isLoading, setIsLoading] = React.useState(false);
const currentUserAbility = useCurrentUserAbility(); const currentUserAbility = useCurrentUserAbility();
const { const { data, mutate: fetchFlows } = useLazyFlows(
data,
mutate: fetchFlows,
isSuccess,
} = useLazyFlows(
{ flowName, page }, { flowName, page },
{ {
onSettled: () => { onSettled: () => {
@@ -43,36 +38,6 @@ export default function Flows() {
}, },
); );
const flows = data?.data || [];
const pageInfo = data?.meta;
const hasFlows = flows?.length;
const navigateToLastPage = isSuccess && !hasFlows && page > 1;
const onSearchChange = React.useCallback((event) => {
setSearchParams({ flowName: event.target.value });
}, []);
const getPathWithSearchParams = (page, flowName) => {
const searchParams = new URLSearchParams();
if (page > 1) {
searchParams.set('page', page);
}
if (flowName) {
searchParams.set('flowName', flowName);
}
return { search: searchParams.toString() };
};
const onDuplicateFlow = () => {
if (pageInfo?.currentPage > 1) {
navigate(getPathWithSearchParams(1, flowName));
} else {
fetchFlows();
}
};
const fetchData = React.useMemo( const fetchData = React.useMemo(
() => debounce(fetchFlows, 300), () => debounce(fetchFlows, 300),
[fetchFlows], [fetchFlows],
@@ -89,14 +54,21 @@ export default function Flows() {
}, [fetchData, flowName, page]); }, [fetchData, flowName, page]);
React.useEffect( React.useEffect(
function redirectToLastPage() { function resetPageOnSearch() {
if (navigateToLastPage) { // reset search params which only consists of `page`
navigate(getPathWithSearchParams(pageInfo.totalPages, flowName)); setSearchParams({});
}
}, },
[navigateToLastPage], [flowName],
); );
const flows = data?.data || [];
const pageInfo = data?.meta;
const hasFlows = flows?.length;
const onSearchChange = React.useCallback((event) => {
setFlowName(event.target.value);
}, []);
return ( return (
<Box sx={{ py: 3 }}> <Box sx={{ py: 3 }}>
<Container> <Container>
@@ -106,7 +78,7 @@ export default function Flows() {
</Grid> </Grid>
<Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}> <Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
<SearchInput onChange={onSearchChange} defaultValue={flowName} /> <SearchInput onChange={onSearchChange} />
</Grid> </Grid>
<Grid <Grid
@@ -139,7 +111,7 @@ export default function Flows() {
</Grid> </Grid>
<Divider sx={{ mt: [2, 0], mb: 2 }} /> <Divider sx={{ mt: [2, 0], mb: 2 }} />
{(isLoading || navigateToLastPage) && ( {isLoading && (
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} /> <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
)} )}
{!isLoading && {!isLoading &&
@@ -147,11 +119,11 @@ export default function Flows() {
<FlowRow <FlowRow
key={flow.id} key={flow.id}
flow={flow} flow={flow}
onDuplicateFlow={onDuplicateFlow} onDuplicateFlow={fetchFlows}
onDeleteFlow={fetchFlows} onDeleteFlow={fetchFlows}
/> />
))} ))}
{!isLoading && !navigateToLastPage && !hasFlows && ( {!isLoading && !hasFlows && (
<NoResultFound <NoResultFound
text={formatMessage('flows.noFlows')} text={formatMessage('flows.noFlows')}
{...(currentUserAbility.can('create', 'Flow') && { {...(currentUserAbility.can('create', 'Flow') && {
@@ -159,23 +131,23 @@ export default function Flows() {
})} })}
/> />
)} )}
{!isLoading && {!isLoading && pageInfo && pageInfo.totalPages > 1 && (
!navigateToLastPage && <Pagination
pageInfo && sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}
pageInfo.totalPages > 1 && ( page={pageInfo?.currentPage}
<Pagination count={pageInfo?.totalPages}
sx={{ display: 'flex', justifyContent: 'center', mt: 3 }} onChange={(event, page) =>
page={pageInfo?.currentPage} setSearchParams({ page: page.toString() })
count={pageInfo?.totalPages} }
renderItem={(item) => ( renderItem={(item) => (
<PaginationItem <PaginationItem
component={Link} component={Link}
to={getPathWithSearchParams(item.page, flowName)} to={`${item.page === 1 ? '' : `?page=${item.page}`}`}
{...item} {...item}
/> />
)} )}
/> />
)} )}
</Container> </Container>
</Box> </Box>
); );

View File

@@ -266,8 +266,8 @@ function ProfileSettings() {
</Grid> </Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}> <Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
<Alert variant="outlined" severity="error"> <Alert variant="outlined" severity="error" sx={{ fontWeight: 500 }}>
<AlertTitle> <AlertTitle sx={{ fontWeight: 700 }}>
{formatMessage('profileSettings.deleteMyAccount')} {formatMessage('profileSettings.deleteMyAccount')}
</AlertTitle> </AlertTitle>

View File

@@ -278,20 +278,6 @@ export const defaultTheme = createTheme({
}), }),
}, },
}, },
MuiAlert: {
styleOverrides: {
root: ({ theme }) => ({
fontWeight: theme.typography.fontWeightRegular,
}),
},
},
MuiAlertTitle: {
styleOverrides: {
root: ({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
}),
},
},
}, },
}); });
export const mationTheme = createTheme( export const mationTheme = createTheme(

File diff suppressed because it is too large Load Diff

17164
yarn.lock Normal file

File diff suppressed because it is too large Load Diff