Compare commits

..

1 Commits

Author SHA1 Message Date
kasia.oczkowska
b48b2592d5 feat: show api error message when logging in fails 2024-11-13 14:46:21 +00:00
42 changed files with 17328 additions and 18751 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,9 +1,8 @@
import { vi, describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import SamlAuthProvider from '../models/saml-auth-provider.ee'; import SamlAuthProvider from '../models/saml-auth-provider.ee';
import SamlAuthProvidersRoleMapping from '../models/saml-auth-providers-role-mapping.ee'; import SamlAuthProvidersRoleMapping from '../models/saml-auth-providers-role-mapping.ee';
import Identity from './identity.ee'; import Identity from './identity.ee';
import Base from './base'; import Base from './base';
import appConfig from '../config/app';
describe('SamlAuthProvider model', () => { describe('SamlAuthProvider model', () => {
it('tableName should return correct name', () => { it('tableName should return correct name', () => {
@@ -46,39 +45,4 @@ describe('SamlAuthProvider model', () => {
expect(virtualAttributes).toStrictEqual(expectedAttributes); 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

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

View File

@@ -1,5 +1,4 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { DateTime, Duration } from 'luxon';
import appConfig from '../config/app.js'; import appConfig from '../config/app.js';
import Base from './base.js'; import Base from './base.js';
import AccessToken from './access-token.js'; import AccessToken from './access-token.js';
@@ -13,12 +12,6 @@ import Step from './step.js';
import Subscription from './subscription.ee.js'; import Subscription from './subscription.ee.js';
import UsageData from './usage-data.ee.js'; import UsageData from './usage-data.ee.js';
import User from './user.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 { createUser } from '../../test/factories/user.js';
import { createConnection } from '../../test/factories/connection.js'; import { createConnection } from '../../test/factories/connection.js';
import { createRole } from '../../test/factories/role.js'; import { createRole } from '../../test/factories/role.js';
@@ -587,292 +580,4 @@ describe('User model', () => {
expect(refetchedUser.invitationTokenSentAt).toBe(null); expect(refetchedUser.invitationTokenSentAt).toBe(null);
expect(refetchedUser.status).toBe('active'); 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

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

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

@@ -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,23 @@ function LoginForm() {
}); });
const { token } = data; const { token } = data;
authentication.updateToken(token); authentication.updateToken(token);
} catch {} } catch (error) {
}; const errors = error?.response?.data?.errors
? Object.values(error.response.data.errors)
: [];
const renderError = () => { if (errors.length) {
const errors = error?.response?.data?.errors?.general || [ for (const [error] of errors) {
formatMessage('loginForm.error'), enqueueSnackbar(error, {
]; variant: 'error',
});
return errors.map((error) => ( }
<Alert severity="error" sx={{ mt: 2 }}> } else {
{error} enqueueSnackbar(error?.message || formatMessage('loginForm.error'), {
</Alert> variant: 'error',
)); });
}
}
}; };
return ( return (
@@ -105,8 +106,6 @@ function LoginForm() {
</Link> </Link>
)} )}
{isError && renderError()}
<LoadingButton <LoadingButton
type="submit" type="submit"
variant="contained" variant="contained"

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

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

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

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

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