Compare commits
186 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
0012c9fb59 | ||
![]() |
feba2a32f9 | ||
![]() |
5090ece9b6 | ||
![]() |
221b19586e | ||
![]() |
3346c14255 | ||
![]() |
6e97e023c9 | ||
![]() |
b26e2ecf2e | ||
![]() |
d896238f23 | ||
![]() |
d2c8f5a75c | ||
![]() |
ce430d238c | ||
![]() |
ee397441ed | ||
![]() |
ba82d986c1 | ||
![]() |
2361cb521e | ||
![]() |
05f8d95281 | ||
![]() |
6c60b1c263 | ||
![]() |
0c32a0693c | ||
![]() |
807faa3c93 | ||
![]() |
fb53e37f7a | ||
![]() |
4ffdf98e16 | ||
![]() |
b8da721e39 | ||
![]() |
db8b98ca16 | ||
![]() |
01b8c600fe | ||
![]() |
69bd5549a2 | ||
![]() |
bc631e3931 | ||
![]() |
8ca4bc5a33 | ||
![]() |
58a569afb0 | ||
![]() |
db718d6fc3 | ||
![]() |
ca9cb8b07b | ||
![]() |
ef14586412 | ||
![]() |
09335fcd79 | ||
![]() |
15f1fca6fe | ||
![]() |
a570b8eb7a | ||
![]() |
02e2735b7a | ||
![]() |
54fa347142 | ||
![]() |
0c752beace | ||
![]() |
c14f808d29 | ||
![]() |
ad71173671 | ||
![]() |
204325ef44 | ||
![]() |
7ce6117659 | ||
![]() |
823a2c8b73 | ||
![]() |
741866e742 | ||
![]() |
41622678b0 | ||
![]() |
449b953401 | ||
![]() |
551548400f | ||
![]() |
6345ce5195 | ||
![]() |
95651f6163 | ||
![]() |
b02c1545b7 | ||
![]() |
2deaab9b24 | ||
![]() |
f0d4853533 | ||
![]() |
af81ae812f | ||
![]() |
bae76064e5 | ||
![]() |
07d9198cc8 | ||
![]() |
a2e07ea2f7 | ||
![]() |
864c762fe2 | ||
![]() |
167bb4e8a0 | ||
![]() |
4cf64ede74 | ||
![]() |
bb309fea6f | ||
![]() |
90a7b4c1c0 | ||
![]() |
1133362028 | ||
![]() |
eb9226bd4a | ||
![]() |
a9abdcc37e | ||
![]() |
6ace93bdbf | ||
![]() |
b89197939a | ||
![]() |
da788106af | ||
![]() |
49e92e6f1d | ||
![]() |
a6c3276104 | ||
![]() |
6388bfc714 | ||
![]() |
bebc3b181d | ||
![]() |
5a6d561c1a | ||
![]() |
5ba575fdfd | ||
![]() |
dcf8bbd804 | ||
![]() |
ff93ffd0b1 | ||
![]() |
395c09df92 | ||
![]() |
4c903cd08b | ||
![]() |
64cb98717c | ||
![]() |
b0e4ce54fb | ||
![]() |
d67a37002f | ||
![]() |
965ff8bc3f | ||
![]() |
400a495ad2 | ||
![]() |
09d0822a8d | ||
![]() |
7016c20ccc | ||
![]() |
df54895805 | ||
![]() |
62d5e6fe51 | ||
![]() |
4615a0b7ea | ||
![]() |
280d603b14 | ||
![]() |
36271f0749 | ||
![]() |
579638f932 | ||
![]() |
48871c82a6 | ||
![]() |
14056c42ef | ||
![]() |
90fe1576de | ||
![]() |
d61cf13985 | ||
![]() |
dfe6dfd0c6 | ||
![]() |
c138c7d0e9 | ||
![]() |
d542be947e | ||
![]() |
c76366e72e | ||
![]() |
75abfda783 | ||
![]() |
f3d8d7d4ad | ||
![]() |
7255eccb22 | ||
![]() |
a0decb70cc | ||
![]() |
532f562495 | ||
![]() |
27e58ae925 | ||
![]() |
abf30dfc1a | ||
![]() |
218b8ce86e | ||
![]() |
4867ffcb4b | ||
![]() |
e34c3b411d | ||
![]() |
c91b8be1a6 | ||
![]() |
9cb41644a1 | ||
![]() |
8c01cea147 | ||
![]() |
58eb55e90a | ||
![]() |
bb05e82e15 | ||
![]() |
5ab95ea175 | ||
![]() |
a25c4f1d1e | ||
![]() |
15287de8af | ||
![]() |
49b4d6b511 | ||
![]() |
d5b4a5d4ac | ||
![]() |
de480b491c | ||
![]() |
a949fda1fc | ||
![]() |
3e28af670c | ||
![]() |
b5310afb90 | ||
![]() |
da81ecf915 | ||
![]() |
f597066d16 | ||
![]() |
ec30606b24 | ||
![]() |
20dce14f17 | ||
![]() |
821742de85 | ||
![]() |
74dc108f62 | ||
![]() |
a05fe856bb | ||
![]() |
d13f51a32d | ||
![]() |
3dbe599cb3 | ||
![]() |
cf966dd83c | ||
![]() |
4e62f3654f | ||
![]() |
970d926563 | ||
![]() |
ff49c747ba | ||
![]() |
c46b8a5f4f | ||
![]() |
485324e204 | ||
![]() |
4696a03db1 | ||
![]() |
7885de36a9 | ||
![]() |
fac4339207 | ||
![]() |
9c70519021 | ||
![]() |
9ae77ecd5d | ||
![]() |
1c8e6f278d | ||
![]() |
c0a190a9f2 | ||
![]() |
e29e2a62f0 | ||
![]() |
1580640a35 | ||
![]() |
33c84b7fcc | ||
![]() |
9773ce75b0 | ||
![]() |
c310e8d152 | ||
![]() |
af251c7b81 | ||
![]() |
122483de0c | ||
![]() |
42c2131144 | ||
![]() |
71bc7a62c2 | ||
![]() |
87bfff07db | ||
![]() |
1cb5b780d2 | ||
![]() |
2f6acd4d6e | ||
![]() |
c2e2351505 | ||
![]() |
d847b5480b | ||
![]() |
32749ee58e | ||
![]() |
a531b8b5fe | ||
![]() |
148a0c5bb0 | ||
![]() |
39f9a58200 | ||
![]() |
edd113d344 | ||
![]() |
c641e8729b | ||
![]() |
2c4b13e4b5 | ||
![]() |
48fcf4dda7 | ||
![]() |
acfd980d4f | ||
![]() |
db9bfab812 | ||
![]() |
d32820ee09 | ||
![]() |
0f823fd19e | ||
![]() |
4308ed5850 | ||
![]() |
b9cd7c3983 | ||
![]() |
fa607aa961 | ||
![]() |
6900b71841 | ||
![]() |
bb230d67e8 | ||
![]() |
4f076ec3e3 | ||
![]() |
96a6cbfb95 | ||
![]() |
5bdc5aed72 | ||
![]() |
d38b0f088b | ||
![]() |
892710f705 | ||
![]() |
fbf898be64 | ||
![]() |
e3e2ecc1e1 | ||
![]() |
b59807d221 | ||
![]() |
163ad52285 | ||
![]() |
4023a6d1cc | ||
![]() |
ec827e5dc0 | ||
![]() |
a8f4fb7c22 | ||
![]() |
bc195ed452 | ||
![]() |
79050af391 |
@@ -5,8 +5,11 @@ BACKEND_PORT=3000
|
||||
WEB_PORT=3001
|
||||
|
||||
echo "Configuring backend environment variables..."
|
||||
|
||||
cd packages/backend
|
||||
|
||||
rm -rf .env
|
||||
|
||||
echo "
|
||||
PORT=$BACKEND_PORT
|
||||
WEB_APP_URL=http://localhost:$WEB_PORT
|
||||
@@ -21,24 +24,35 @@ WEBHOOK_SECRET_KEY=sample_webhook_secret_key
|
||||
APP_SECRET_KEY=sample_app_secret_key
|
||||
REDIS_HOST=redis
|
||||
SERVE_WEB_APP_SEPARATELY=true" >> .env
|
||||
|
||||
echo "Installing backend dependencies..."
|
||||
|
||||
yarn
|
||||
|
||||
cd $CURRENT_DIR
|
||||
|
||||
echo "Configuring web environment variables..."
|
||||
|
||||
cd packages/web
|
||||
|
||||
rm -rf .env
|
||||
|
||||
echo "
|
||||
PORT=$WEB_PORT
|
||||
REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT
|
||||
" >> .env
|
||||
|
||||
echo "Installing web dependencies..."
|
||||
|
||||
yarn
|
||||
|
||||
cd $CURRENT_DIR
|
||||
|
||||
echo "Installing and linking dependencies..."
|
||||
yarn
|
||||
yarn lerna bootstrap
|
||||
|
||||
echo "Migrating database..."
|
||||
|
||||
cd packages/backend
|
||||
|
||||
yarn db:migrate
|
||||
yarn db:seed:user
|
||||
|
||||
echo "Done!"
|
||||
echo "Done!"
|
||||
|
9
.github/workflows/backend.yml
vendored
9
.github/workflows/backend.yml
vendored
@@ -41,8 +41,11 @@ jobs:
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: cd packages/backend && yarn
|
||||
run: yarn
|
||||
working-directory: packages/backend
|
||||
- name: Copy .env-example.test file to .env.test
|
||||
run: cd packages/backend && cp .env-example.test .env.test
|
||||
run: cp .env-example.test .env.test
|
||||
working-directory: packages/backend
|
||||
- name: Run tests
|
||||
run: cd packages/backend && yarn test
|
||||
run: yarn test:coverage
|
||||
working-directory: packages/backend
|
||||
|
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -18,11 +18,13 @@ jobs:
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
cache-dependency-path: packages/backend/yarn.lock
|
||||
- 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: yarn --frozen-lockfile
|
||||
- run: cd packages/backend && yarn lint
|
||||
working-directory: packages/backend
|
||||
- run: yarn lint
|
||||
working-directory: packages/backend
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||
start-backend-server:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -35,11 +37,13 @@ jobs:
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
cache-dependency-path: packages/backend/yarn.lock
|
||||
- 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: yarn --frozen-lockfile && yarn lerna bootstrap
|
||||
- run: cd packages/backend && yarn start
|
||||
- run: yarn --frozen-lockfile
|
||||
working-directory: packages/backend
|
||||
- run: yarn start
|
||||
working-directory: packages/backend
|
||||
env:
|
||||
ENCRYPTION_KEY: sample_encryption_key
|
||||
WEBHOOK_SECRET_KEY: sample_webhook_secret_key
|
||||
@@ -55,11 +59,13 @@ jobs:
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
cache-dependency-path: packages/backend/yarn.lock
|
||||
- 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: yarn --frozen-lockfile && yarn lerna bootstrap
|
||||
- run: cd packages/backend && yarn start:worker
|
||||
- run: yarn --frozen-lockfile
|
||||
working-directory: packages/backend
|
||||
- run: yarn start:worker
|
||||
working-directory: packages/backend
|
||||
env:
|
||||
ENCRYPTION_KEY: sample_encryption_key
|
||||
WEBHOOK_SECRET_KEY: sample_webhook_secret_key
|
||||
@@ -75,11 +81,13 @@ jobs:
|
||||
with:
|
||||
node-version: '18'
|
||||
cache: 'yarn'
|
||||
cache-dependency-path: yarn.lock
|
||||
cache-dependency-path: packages/web/yarn.lock
|
||||
- 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: yarn --frozen-lockfile && yarn lerna bootstrap
|
||||
- run: cd packages/web && yarn build
|
||||
- run: yarn --frozen-lockfile
|
||||
working-directory: packages/web
|
||||
- run: yarn build
|
||||
working-directory: packages/web
|
||||
env:
|
||||
CI: false
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||
|
28
.github/workflows/playwright.yml
vendored
28
.github/workflows/playwright.yml
vendored
@@ -3,12 +3,13 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/backend/**'
|
||||
- 'packages/e2e-tests/**'
|
||||
- 'packages/web/**'
|
||||
- '!packages/backend/src/apps/**'
|
||||
# TODO: Add pull request after optimizing the total excecution time of the test suite.
|
||||
# pull_request:
|
||||
# paths:
|
||||
# - 'packages/backend/**'
|
||||
# - 'packages/e2e-tests/**'
|
||||
# - 'packages/web/**'
|
||||
# - '!packages/backend/src/apps/**'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -58,13 +59,21 @@ jobs:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Install dependencies
|
||||
run: yarn && yarn lerna bootstrap
|
||||
- name: Install web dependencies
|
||||
run: yarn
|
||||
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
|
||||
run: yarn playwright install --with-deps
|
||||
working-directory: ./packages/e2e-tests
|
||||
- name: Build Automatisch web
|
||||
working-directory: ./packages/web
|
||||
run: yarn build
|
||||
working-directory: ./packages/web
|
||||
env:
|
||||
# Keep this until we clean up warnings in build processes
|
||||
CI: false
|
||||
@@ -105,6 +114,7 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./packages/e2e-tests
|
||||
env:
|
||||
PORT: 3000
|
||||
LOGIN_EMAIL: user@automatisch.io
|
||||
LOGIN_PASSWORD: sample
|
||||
BASE_URL: http://localhost:3000
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,7 +4,6 @@ logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
@@ -11,10 +11,12 @@ WORKDIR /automatisch
|
||||
# copy the app, note .dockerignore
|
||||
COPY . /automatisch
|
||||
|
||||
RUN yarn
|
||||
RUN cd packages/web && yarn
|
||||
|
||||
RUN cd packages/web && yarn build
|
||||
|
||||
RUN cd packages/backend && yarn --production
|
||||
|
||||
RUN \
|
||||
rm -rf /usr/local/share/.cache/ && \
|
||||
apk del build-dependencies
|
||||
|
13
lerna.json
13
lerna.json
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "0.10.0",
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"command": {
|
||||
"add": {
|
||||
"exact": true
|
||||
}
|
||||
}
|
||||
}
|
32
package.json
32
package.json
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
@@ -12,6 +12,7 @@
|
||||
"pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js",
|
||||
"test": "APP_ENV=test vitest run",
|
||||
"test:watch": "APP_ENV=test vitest watch",
|
||||
"test:coverage": "yarn test --coverage",
|
||||
"lint": "eslint .",
|
||||
"db:create": "node ./bin/database/create.js",
|
||||
"db:seed:user": "node ./bin/database/seed-user.js",
|
||||
@@ -23,6 +24,7 @@
|
||||
"dependencies": {
|
||||
"@bull-board/express": "^3.10.1",
|
||||
"@casl/ability": "^6.5.0",
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@node-saml/passport-saml": "^4.0.4",
|
||||
"@rudderstack/rudder-sdk-node": "^1.1.2",
|
||||
"@sentry/node": "^7.42.0",
|
||||
@@ -36,6 +38,9 @@
|
||||
"crypto-js": "^4.1.1",
|
||||
"debug": "~2.6.9",
|
||||
"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-async-errors": "^3.1.1",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
@@ -61,6 +66,7 @@
|
||||
"pg": "^8.7.1",
|
||||
"php-serialize": "^4.0.2",
|
||||
"pluralize": "^8.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"raw-body": "^2.5.2",
|
||||
"showdown": "^2.1.0",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -92,10 +98,11 @@
|
||||
"url": "https://github.com/automatisch/automatisch/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.1.5",
|
||||
"node-gyp": "^10.1.0",
|
||||
"nodemon": "^2.0.13",
|
||||
"supertest": "^6.3.3",
|
||||
"vitest": "^1.1.3"
|
||||
"vitest": "^2.1.5"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
@@ -8,7 +8,7 @@ export default {
|
||||
key: 'instanceUrl',
|
||||
label: 'WordPress instance URL',
|
||||
type: 'string',
|
||||
required: false,
|
||||
required: true,
|
||||
readOnly: false,
|
||||
value: null,
|
||||
placeholder: null,
|
||||
|
@@ -52,7 +52,7 @@ const appConfig = {
|
||||
isDev: appEnv === 'development',
|
||||
isTest: appEnv === 'test',
|
||||
isProd: appEnv === 'production',
|
||||
version: '0.13.1',
|
||||
version: '0.14.0',
|
||||
postgresDatabase: process.env.POSTGRES_DATABASE || 'automatisch_development',
|
||||
postgresSchema: process.env.POSTGRES_SCHEMA || 'public',
|
||||
postgresPort: parseInt(process.env.POSTGRES_PORT || '5432'),
|
||||
|
@@ -92,21 +92,4 @@ describe('DELETE /api/v1/admin/roles/:roleId', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not delete role and permissions on unsuccessful response', async () => {
|
||||
const role = await createRole();
|
||||
const permission = await createPermission({ roleId: role.id });
|
||||
await createUser({ roleId: role.id });
|
||||
|
||||
await request(app)
|
||||
.delete(`/api/v1/admin/roles/${role.id}`)
|
||||
.set('Authorization', token)
|
||||
.expect(422);
|
||||
|
||||
const refetchedRole = await role.$query();
|
||||
const refetchedPermission = await permission.$query();
|
||||
|
||||
expect(refetchedRole).toStrictEqual(role);
|
||||
expect(refetchedPermission).toStrictEqual(permission);
|
||||
});
|
||||
});
|
||||
|
@@ -7,7 +7,7 @@ export default async (request, response) => {
|
||||
.throwIfNotFound();
|
||||
|
||||
const roleMappings = await samlAuthProvider
|
||||
.$relatedQuery('samlAuthProvidersRoleMappings')
|
||||
.$relatedQuery('roleMappings')
|
||||
.orderBy('remote_role_name', 'asc');
|
||||
|
||||
renderObject(response, roleMappings);
|
||||
|
@@ -8,15 +8,14 @@ export default async (request, response) => {
|
||||
.findById(samlAuthProviderId)
|
||||
.throwIfNotFound();
|
||||
|
||||
const samlAuthProvidersRoleMappings =
|
||||
await samlAuthProvider.updateRoleMappings(
|
||||
samlAuthProvidersRoleMappingsParams(request)
|
||||
);
|
||||
const roleMappings = await samlAuthProvider.updateRoleMappings(
|
||||
roleMappingsParams(request)
|
||||
);
|
||||
|
||||
renderObject(response, samlAuthProvidersRoleMappings);
|
||||
renderObject(response, roleMappings);
|
||||
};
|
||||
|
||||
const samlAuthProvidersRoleMappingsParams = (request) => {
|
||||
const roleMappingsParams = (request) => {
|
||||
const roleMappings = request.body;
|
||||
|
||||
return roleMappings.map(({ roleId, remoteRoleName }) => ({
|
||||
|
@@ -6,7 +6,7 @@ import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by
|
||||
import { createRole } from '../../../../../../test/factories/role.js';
|
||||
import { createUser } from '../../../../../../test/factories/user.js';
|
||||
import { createSamlAuthProvider } from '../../../../../../test/factories/saml-auth-provider.ee.js';
|
||||
import { createSamlAuthProvidersRoleMapping } from '../../../../../../test/factories/saml-auth-providers-role-mapping.js';
|
||||
import { createRoleMapping } from '../../../../../../test/factories/role-mapping.js';
|
||||
import createRoleMappingsMock from '../../../../../../test/mocks/rest/api/v1/admin/saml-auth-providers/update-role-mappings.ee.js';
|
||||
import * as license from '../../../../../helpers/license.ee.js';
|
||||
|
||||
@@ -21,12 +21,12 @@ describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappi
|
||||
|
||||
samlAuthProvider = await createSamlAuthProvider();
|
||||
|
||||
await createSamlAuthProvidersRoleMapping({
|
||||
await createRoleMapping({
|
||||
samlAuthProviderId: samlAuthProvider.id,
|
||||
remoteRoleName: 'Viewer',
|
||||
});
|
||||
|
||||
await createSamlAuthProvidersRoleMapping({
|
||||
await createRoleMapping({
|
||||
samlAuthProviderId: samlAuthProvider.id,
|
||||
remoteRoleName: 'Editor',
|
||||
});
|
||||
@@ -64,7 +64,7 @@ describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappi
|
||||
|
||||
it('should delete role mappings when given empty role mappings', async () => {
|
||||
const existingRoleMappings = await samlAuthProvider.$relatedQuery(
|
||||
'samlAuthProvidersRoleMappings'
|
||||
'roleMappings'
|
||||
);
|
||||
|
||||
expect(existingRoleMappings.length).toBe(2);
|
||||
@@ -149,34 +149,4 @@ describe('PATCH /api/v1/admin/saml-auth-providers/:samlAuthProviderId/role-mappi
|
||||
.send(roleMappings)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should not delete existing role mapping when error thrown', async () => {
|
||||
const roleMappings = [
|
||||
{
|
||||
roleId: userRole.id,
|
||||
remoteRoleName: {
|
||||
invalid: 'data',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const roleMappingsBeforeRequest = await samlAuthProvider.$relatedQuery(
|
||||
'samlAuthProvidersRoleMappings'
|
||||
);
|
||||
|
||||
await request(app)
|
||||
.patch(
|
||||
`/api/v1/admin/saml-auth-providers/${samlAuthProvider.id}/role-mappings`
|
||||
)
|
||||
.set('Authorization', token)
|
||||
.send(roleMappings)
|
||||
.expect(422);
|
||||
|
||||
const roleMappingsAfterRequest = await samlAuthProvider.$relatedQuery(
|
||||
'samlAuthProvidersRoleMappings'
|
||||
);
|
||||
|
||||
expect(roleMappingsBeforeRequest).toStrictEqual(roleMappingsAfterRequest);
|
||||
expect(roleMappingsAfterRequest.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
@@ -10,7 +10,7 @@ describe('GET /api/v1/automatisch/version', () => {
|
||||
|
||||
const expectedPayload = {
|
||||
data: {
|
||||
version: '0.13.1',
|
||||
version: '0.14.0',
|
||||
},
|
||||
meta: {
|
||||
count: 1,
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import { renderObject } from '../../../../helpers/renderer.js';
|
||||
|
||||
export default async (request, response) => {
|
||||
let flow = await request.currentUser.$relatedQuery('flows').insert({
|
||||
const flow = await request.currentUser.$relatedQuery('flows').insertAndFetch({
|
||||
name: 'Name your flow',
|
||||
});
|
||||
|
||||
flow = await flow.createInitialSteps();
|
||||
await flow.createInitialSteps();
|
||||
|
||||
renderObject(response, flow, { status: 201 });
|
||||
};
|
||||
|
@@ -6,7 +6,7 @@ export default async (request, response) => {
|
||||
.findById(request.params.flowId)
|
||||
.throwIfNotFound();
|
||||
|
||||
const createdActionStep = await flow.createActionStep(
|
||||
const createdActionStep = await flow.createStepAfter(
|
||||
request.body.previousStepId
|
||||
);
|
||||
|
||||
|
@@ -0,0 +1,52 @@
|
||||
export async function up(knex) {
|
||||
await knex.schema.createTable('role_mappings', (table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||
table
|
||||
.uuid('saml_auth_provider_id')
|
||||
.references('id')
|
||||
.inTable('saml_auth_providers');
|
||||
table.uuid('role_id').references('id').inTable('roles');
|
||||
table.string('remote_role_name').notNullable();
|
||||
|
||||
table.unique(['saml_auth_provider_id', 'remote_role_name']);
|
||||
|
||||
table.timestamps(true, true);
|
||||
});
|
||||
|
||||
const existingRoleMappings = await knex('saml_auth_providers_role_mappings');
|
||||
|
||||
if (existingRoleMappings.length) {
|
||||
await knex('role_mappings').insert(existingRoleMappings);
|
||||
}
|
||||
|
||||
return await knex.schema.dropTable('saml_auth_providers_role_mappings');
|
||||
}
|
||||
|
||||
export async function down(knex) {
|
||||
await knex.schema.createTable(
|
||||
'saml_auth_providers_role_mappings',
|
||||
(table) => {
|
||||
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
|
||||
table
|
||||
.uuid('saml_auth_provider_id')
|
||||
.references('id')
|
||||
.inTable('saml_auth_providers');
|
||||
table.uuid('role_id').references('id').inTable('roles');
|
||||
table.string('remote_role_name').notNullable();
|
||||
|
||||
table.unique(['saml_auth_provider_id', 'remote_role_name']);
|
||||
|
||||
table.timestamps(true, true);
|
||||
}
|
||||
);
|
||||
|
||||
const existingRoleMappings = await knex('role_mappings');
|
||||
|
||||
if (existingRoleMappings.length) {
|
||||
await knex('saml_auth_providers_role_mappings').insert(
|
||||
existingRoleMappings
|
||||
);
|
||||
}
|
||||
|
||||
return await knex.schema.dropTable('role_mappings');
|
||||
}
|
@@ -30,7 +30,7 @@ const findOrCreateUserBySamlIdentity = async (
|
||||
: [mappedUser.role];
|
||||
|
||||
const samlAuthProviderRoleMapping = await samlAuthProvider
|
||||
.$relatedQuery('samlAuthProvidersRoleMappings')
|
||||
.$relatedQuery('roleMappings')
|
||||
.whereIn('remote_role_name', mappedRoles)
|
||||
.limit(1)
|
||||
.first();
|
||||
|
46
packages/backend/src/helpers/user-ability.test.js
Normal file
46
packages/backend/src/helpers/user-ability.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import userAbility from './user-ability.js';
|
||||
|
||||
describe('userAbility', () => {
|
||||
it('should return PureAbility instantiated with user permissions', () => {
|
||||
const user = {
|
||||
permissions: [
|
||||
{
|
||||
subject: 'Flow',
|
||||
action: 'read',
|
||||
conditions: ['isCreator'],
|
||||
},
|
||||
],
|
||||
role: {
|
||||
name: 'User',
|
||||
},
|
||||
};
|
||||
|
||||
const ability = userAbility(user);
|
||||
|
||||
expect(ability.rules).toStrictEqual(user.permissions);
|
||||
});
|
||||
|
||||
it('should return permission-less PureAbility for user with no role', () => {
|
||||
const user = {
|
||||
permissions: [
|
||||
{
|
||||
subject: 'Flow',
|
||||
action: 'read',
|
||||
conditions: ['isCreator'],
|
||||
},
|
||||
],
|
||||
role: null,
|
||||
};
|
||||
const ability = userAbility(user);
|
||||
|
||||
expect(ability.rules).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return permission-less PureAbility for user with no permissions', () => {
|
||||
const user = { permissions: null, role: { name: 'User' } };
|
||||
const ability = userAbility(user);
|
||||
|
||||
expect(ability.rules).toStrictEqual([]);
|
||||
});
|
||||
});
|
42
packages/backend/src/models/__snapshots__/flow.test.js.snap
Normal file
42
packages/backend/src/models/__snapshots__/flow.test.js.snap
Normal file
@@ -0,0 +1,42 @@
|
||||
// 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",
|
||||
}
|
||||
`;
|
@@ -0,0 +1,30 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = `
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"type": "string",
|
||||
},
|
||||
"remoteRoleName": {
|
||||
"minLength": 1,
|
||||
"type": "string",
|
||||
},
|
||||
"roleId": {
|
||||
"format": "uuid",
|
||||
"type": "string",
|
||||
},
|
||||
"samlAuthProviderId": {
|
||||
"format": "uuid",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"samlAuthProviderId",
|
||||
"roleId",
|
||||
"remoteRoleName",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
33
packages/backend/src/models/__snapshots__/role.test.js.snap
Normal file
33
packages/backend/src/models/__snapshots__/role.test.js.snap
Normal file
@@ -0,0 +1,33 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Role model > jsonSchema should have correct validations 1`] = `
|
||||
{
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
"type": "string",
|
||||
},
|
||||
"description": {
|
||||
"maxLength": 255,
|
||||
"type": [
|
||||
"string",
|
||||
"null",
|
||||
],
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"type": "string",
|
||||
},
|
||||
"name": {
|
||||
"minLength": 1,
|
||||
"type": "string",
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
],
|
||||
"type": "object",
|
||||
}
|
||||
`;
|
@@ -0,0 +1,72 @@
|
||||
// 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",
|
||||
}
|
||||
`;
|
@@ -1,6 +1,6 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`SamlAuthProvidersRoleMapping model > jsonSchema should have the correct schema 1`] = `
|
||||
exports[`RoleMapping model > jsonSchema should have the correct schema 1`] = `
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -28,14 +28,3 @@ exports[`SamlAuthProvidersRoleMapping model > jsonSchema should have the correct
|
||||
"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],
|
||||
}
|
||||
`;
|
||||
|
77
packages/backend/src/models/__snapshots__/step.test.js.snap
Normal file
77
packages/backend/src/models/__snapshots__/step.test.js.snap
Normal file
@@ -0,0 +1,77 @@
|
||||
// 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",
|
||||
}
|
||||
`;
|
81
packages/backend/src/models/__snapshots__/user.test.js.snap
Normal file
81
packages/backend/src/models/__snapshots__/user.test.js.snap
Normal file
@@ -0,0 +1,81 @@
|
||||
// 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",
|
||||
}
|
||||
`;
|
@@ -88,15 +88,13 @@ class Flow extends Base {
|
||||
},
|
||||
});
|
||||
|
||||
static async afterFind(args) {
|
||||
const { result } = args;
|
||||
|
||||
const referenceFlow = result[0];
|
||||
static async populateStatusProperty(flows) {
|
||||
const referenceFlow = flows[0];
|
||||
|
||||
if (referenceFlow) {
|
||||
const shouldBePaused = await referenceFlow.isPaused();
|
||||
|
||||
for (const flow of result) {
|
||||
for (const flow of flows) {
|
||||
if (!flow.active) {
|
||||
flow.status = 'draft';
|
||||
} else if (flow.active && shouldBePaused) {
|
||||
@@ -108,6 +106,10 @@ class Flow extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
static async afterFind(args) {
|
||||
await this.populateStatusProperty(args.result);
|
||||
}
|
||||
|
||||
async lastInternalId() {
|
||||
const lastExecution = await this.$relatedQuery('lastExecution');
|
||||
|
||||
@@ -123,13 +125,14 @@ class Flow extends Base {
|
||||
return lastExecutions.map((execution) => execution.internalId);
|
||||
}
|
||||
|
||||
get IncompleteStepsError() {
|
||||
static get IncompleteStepsError() {
|
||||
return new ValidationError({
|
||||
data: {
|
||||
flow: [
|
||||
{
|
||||
message: 'All steps should be completed before updating flow status!'
|
||||
}
|
||||
message:
|
||||
'All steps should be completed before updating flow status!',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'incompleteStepsError',
|
||||
@@ -148,36 +151,48 @@ class Flow extends Base {
|
||||
type: 'action',
|
||||
position: 2,
|
||||
});
|
||||
|
||||
return this.$query().withGraphFetched('steps');
|
||||
}
|
||||
|
||||
async createActionStep(previousStepId) {
|
||||
const previousStep = await this.$relatedQuery('steps')
|
||||
.findById(previousStepId)
|
||||
.throwIfNotFound();
|
||||
async getStepById(stepId) {
|
||||
return await this.$relatedQuery('steps').findById(stepId).throwIfNotFound();
|
||||
}
|
||||
|
||||
const createdStep = await this.$relatedQuery('steps').insertAndFetch({
|
||||
async insertActionStepAtPosition(position) {
|
||||
return await this.$relatedQuery('steps').insertAndFetch({
|
||||
type: 'action',
|
||||
position: previousStep.position + 1,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
const nextSteps = await this.$relatedQuery('steps')
|
||||
.where('position', '>=', createdStep.position)
|
||||
.whereNot('id', createdStep.id);
|
||||
async getStepsAfterPosition(position) {
|
||||
return await this.$relatedQuery('steps').where('position', '>', position);
|
||||
}
|
||||
|
||||
const nextStepQueries = nextSteps.map(async (nextStep, index) => {
|
||||
return await nextStep.$query().patchAndFetch({
|
||||
position: createdStep.position + index + 1,
|
||||
async updateStepPositionsFrom(startPosition, steps) {
|
||||
const stepPositionUpdates = steps.map(async (step, index) => {
|
||||
return await step.$query().patch({
|
||||
position: startPosition + index,
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(nextStepQueries);
|
||||
return await Promise.all(stepPositionUpdates);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
async unregisterWebhook() {
|
||||
const triggerStep = await this.getTriggerStep();
|
||||
const trigger = await triggerStep?.getTriggerCommand();
|
||||
|
||||
@@ -198,15 +213,33 @@ class Flow extends Base {
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteExecutionSteps() {
|
||||
const executionIds = (
|
||||
await this.$relatedQuery('executions').select('executions.id')
|
||||
).map((execution) => execution.id);
|
||||
|
||||
await ExecutionStep.query().delete().whereIn('execution_id', executionIds);
|
||||
return await ExecutionStep.query()
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -291,6 +324,18 @@ class Flow extends Base {
|
||||
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) {
|
||||
if (this.active === newActiveValue) {
|
||||
return this;
|
||||
@@ -299,7 +344,7 @@ class Flow extends Base {
|
||||
const triggerStep = await this.getTriggerStep();
|
||||
|
||||
if (triggerStep.status === 'incomplete') {
|
||||
throw this.IncompleteStepsError;
|
||||
throw Flow.IncompleteStepsError;
|
||||
}
|
||||
|
||||
const trigger = await triggerStep.getTriggerCommand();
|
||||
@@ -353,60 +398,55 @@ class Flow extends Base {
|
||||
});
|
||||
}
|
||||
|
||||
async $beforeUpdate(opt, queryContext) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
|
||||
if (!this.active) return;
|
||||
|
||||
const oldFlow = opt.old;
|
||||
|
||||
const incompleteStep = await oldFlow.$relatedQuery('steps').findOne({
|
||||
async throwIfHavingIncompleteSteps() {
|
||||
const incompleteStep = await this.$relatedQuery('steps').findOne({
|
||||
status: 'incomplete',
|
||||
});
|
||||
|
||||
if (incompleteStep) {
|
||||
throw this.IncompleteStepsError;
|
||||
throw Flow.IncompleteStepsError;
|
||||
}
|
||||
}
|
||||
|
||||
const allSteps = await oldFlow.$relatedQuery('steps');
|
||||
async throwIfHavingLessThanTwoSteps() {
|
||||
const allSteps = await this.$relatedQuery('steps');
|
||||
|
||||
if (allSteps.length < 2) {
|
||||
throw new ValidationError({
|
||||
data: {
|
||||
flow: [
|
||||
{
|
||||
message: 'There should be at least one trigger and one action steps in the flow!'
|
||||
}
|
||||
message:
|
||||
'There should be at least one trigger and one action steps in the flow!',
|
||||
},
|
||||
],
|
||||
},
|
||||
type: 'insufficientStepsError',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
async $beforeUpdate(opt, queryContext) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
|
||||
if (this.active) {
|
||||
await opt.old.throwIfHavingIncompleteSteps();
|
||||
|
||||
await opt.old.throwIfHavingLessThanTwoSteps();
|
||||
}
|
||||
}
|
||||
|
||||
async $afterInsert(queryContext) {
|
||||
await super.$afterInsert(queryContext);
|
||||
|
||||
Telemetry.flowCreated(this);
|
||||
}
|
||||
|
||||
async $afterUpdate(opt, queryContext) {
|
||||
await super.$afterUpdate(opt, queryContext);
|
||||
|
||||
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;
|
||||
|
616
packages/backend/src/models/flow.test.js
Normal file
616
packages/backend/src/models/flow.test.js
Normal file
@@ -0,0 +1,616 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
@@ -1,8 +1,8 @@
|
||||
import Base from './base.js';
|
||||
import SamlAuthProvider from './saml-auth-provider.ee.js';
|
||||
|
||||
class SamlAuthProvidersRoleMapping extends Base {
|
||||
static tableName = 'saml_auth_providers_role_mappings';
|
||||
class RoleMapping extends Base {
|
||||
static tableName = 'role_mappings';
|
||||
|
||||
static jsonSchema = {
|
||||
type: 'object',
|
||||
@@ -21,11 +21,11 @@ class SamlAuthProvidersRoleMapping extends Base {
|
||||
relation: Base.BelongsToOneRelation,
|
||||
modelClass: SamlAuthProvider,
|
||||
join: {
|
||||
from: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
|
||||
from: 'role_mappings.saml_auth_provider_id',
|
||||
to: 'saml_auth_providers.id',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default SamlAuthProvidersRoleMapping;
|
||||
export default RoleMapping;
|
@@ -1,28 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import SamlAuthProvidersRoleMapping from '../models/saml-auth-providers-role-mapping.ee';
|
||||
import RoleMapping from './role-mapping.ee';
|
||||
import SamlAuthProvider from './saml-auth-provider.ee';
|
||||
import Base from './base';
|
||||
|
||||
describe('SamlAuthProvidersRoleMapping model', () => {
|
||||
describe('RoleMapping model', () => {
|
||||
it('tableName should return correct name', () => {
|
||||
expect(SamlAuthProvidersRoleMapping.tableName).toBe(
|
||||
'saml_auth_providers_role_mappings'
|
||||
);
|
||||
expect(RoleMapping.tableName).toBe('role_mappings');
|
||||
});
|
||||
|
||||
it('jsonSchema should have the correct schema', () => {
|
||||
expect(SamlAuthProvidersRoleMapping.jsonSchema).toMatchSnapshot();
|
||||
expect(RoleMapping.jsonSchema).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('relationMappings should return correct associations', () => {
|
||||
const relationMappings = SamlAuthProvidersRoleMapping.relationMappings();
|
||||
const relationMappings = RoleMapping.relationMappings();
|
||||
|
||||
const expectedRelations = {
|
||||
samlAuthProvider: {
|
||||
relation: Base.BelongsToOneRelation,
|
||||
modelClass: SamlAuthProvider,
|
||||
join: {
|
||||
from: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
|
||||
from: 'role_mappings.saml_auth_provider_id',
|
||||
to: 'saml_auth_providers.id',
|
||||
},
|
||||
},
|
@@ -52,57 +52,64 @@ class Role extends Base {
|
||||
return await this.query().findOne({ name: 'Admin' });
|
||||
}
|
||||
|
||||
async updateWithPermissions(data) {
|
||||
if (this.isAdmin) {
|
||||
async preventAlteringAdmin() {
|
||||
const currentRole = await Role.query().findById(this.id);
|
||||
|
||||
if (currentRole.isAdmin) {
|
||||
throw new NotAuthorizedError('The admin role cannot be altered!');
|
||||
}
|
||||
}
|
||||
|
||||
async deletePermissions() {
|
||||
return await this.$relatedQuery('permissions').delete();
|
||||
}
|
||||
|
||||
async createPermissions(permissions) {
|
||||
if (permissions?.length) {
|
||||
const validPermissions = Permission.filter(permissions).map(
|
||||
(permission) => ({
|
||||
...permission,
|
||||
roleId: this.id,
|
||||
})
|
||||
);
|
||||
|
||||
await Permission.query().insert(validPermissions);
|
||||
}
|
||||
}
|
||||
|
||||
async updatePermissions(permissions) {
|
||||
await this.deletePermissions();
|
||||
|
||||
await this.createPermissions(permissions);
|
||||
}
|
||||
|
||||
async updateWithPermissions(data) {
|
||||
const { name, description, permissions } = data;
|
||||
|
||||
return await Role.transaction(async (trx) => {
|
||||
await this.$relatedQuery('permissions', trx).delete();
|
||||
await this.updatePermissions(permissions);
|
||||
|
||||
if (permissions?.length) {
|
||||
const validPermissions = Permission.filter(permissions).map(
|
||||
(permission) => ({
|
||||
...permission,
|
||||
roleId: this.id,
|
||||
})
|
||||
);
|
||||
|
||||
await Permission.query().insert(validPermissions);
|
||||
}
|
||||
|
||||
await this.$query(trx).patch({
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
return await this.$query(trx)
|
||||
.leftJoinRelated({
|
||||
permissions: true,
|
||||
})
|
||||
.withGraphFetched({
|
||||
permissions: true,
|
||||
});
|
||||
await this.$query().patchAndFetch({
|
||||
id: this.id,
|
||||
name,
|
||||
description,
|
||||
});
|
||||
|
||||
return await this.$query()
|
||||
.leftJoinRelated({
|
||||
permissions: true,
|
||||
})
|
||||
.withGraphFetched({
|
||||
permissions: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteWithPermissions() {
|
||||
return await Role.transaction(async (trx) => {
|
||||
await this.$relatedQuery('permissions', trx).delete();
|
||||
await this.deletePermissions();
|
||||
|
||||
return await this.$query(trx).delete();
|
||||
});
|
||||
return await this.$query().delete();
|
||||
}
|
||||
|
||||
async $beforeDelete(queryContext) {
|
||||
await super.$beforeDelete(queryContext);
|
||||
|
||||
if (this.isAdmin) {
|
||||
throw new NotAuthorizedError('The admin role cannot be deleted!');
|
||||
}
|
||||
|
||||
async assertNoRoleUserExists() {
|
||||
const userCount = await this.$relatedQuery('users').limit(1).resultSize();
|
||||
const hasUsers = userCount > 0;
|
||||
|
||||
@@ -118,7 +125,9 @@ class Role extends Base {
|
||||
type: 'ValidationError',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertNoConfigurationUsage() {
|
||||
const samlAuthProviderUsingDefaultRole = await SamlAuthProvider.query()
|
||||
.where({
|
||||
default_role_id: this.id,
|
||||
@@ -140,6 +149,26 @@ class Role extends Base {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async assertRoleIsNotUsed() {
|
||||
await this.assertNoRoleUserExists();
|
||||
|
||||
await this.assertNoConfigurationUsage();
|
||||
}
|
||||
|
||||
async $beforeUpdate(opt, queryContext) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
|
||||
await this.preventAlteringAdmin();
|
||||
}
|
||||
|
||||
async $beforeDelete(queryContext) {
|
||||
await super.$beforeDelete(queryContext);
|
||||
|
||||
await this.preventAlteringAdmin();
|
||||
|
||||
await this.assertRoleIsNotUsed();
|
||||
}
|
||||
}
|
||||
|
||||
export default Role;
|
||||
|
287
packages/backend/src/models/role.test.js
Normal file
287
packages/backend/src/models/role.test.js
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Role from './role';
|
||||
import Base from './base.js';
|
||||
import Permission from './permission.js';
|
||||
import User from './user.js';
|
||||
import { createRole } from '../../test/factories/role.js';
|
||||
import { createPermission } from '../../test/factories/permission.js';
|
||||
import { createUser } from '../../test/factories/user.js';
|
||||
import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js';
|
||||
|
||||
describe('Role model', () => {
|
||||
it('tableName should return correct name', () => {
|
||||
expect(Role.tableName).toBe('roles');
|
||||
});
|
||||
|
||||
it('jsonSchema should have correct validations', () => {
|
||||
expect(Role.jsonSchema).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('relationMappingsshould return correct associations', () => {
|
||||
const relationMappings = Role.relationMappings();
|
||||
|
||||
const expectedRelations = {
|
||||
users: {
|
||||
relation: Base.HasManyRelation,
|
||||
modelClass: User,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
to: 'users.role_id',
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
relation: Base.HasManyRelation,
|
||||
modelClass: Permission,
|
||||
join: {
|
||||
from: 'roles.id',
|
||||
to: 'permissions.role_id',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(relationMappings).toStrictEqual(expectedRelations);
|
||||
});
|
||||
|
||||
it('virtualAttributes should return correct attributes', () => {
|
||||
expect(Role.virtualAttributes).toStrictEqual(['isAdmin']);
|
||||
});
|
||||
|
||||
describe('isAdmin', () => {
|
||||
it('should return true for admin named role', () => {
|
||||
const role = new Role();
|
||||
role.name = 'Admin';
|
||||
|
||||
expect(role.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for not admin named roles', () => {
|
||||
const role = new Role();
|
||||
role.name = 'User';
|
||||
|
||||
expect(role.isAdmin).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('findAdmin should return admin role', async () => {
|
||||
const createdAdminRole = await createRole({ name: 'Admin' });
|
||||
|
||||
const adminRole = await Role.findAdmin();
|
||||
|
||||
expect(createdAdminRole).toStrictEqual(adminRole);
|
||||
});
|
||||
|
||||
describe('preventAlteringAdmin', () => {
|
||||
it('preventAlteringAdmin should throw an error when altering admin role', async () => {
|
||||
const role = await createRole({ name: 'Admin' });
|
||||
|
||||
await expect(() => role.preventAlteringAdmin()).rejects.toThrowError(
|
||||
'The admin role cannot be altered!'
|
||||
);
|
||||
});
|
||||
|
||||
it('preventAlteringAdmin should not throw an error when altering non-admin roles', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
|
||||
expect(await role.preventAlteringAdmin()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("deletePermissions should delete role's permissions", async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
await createPermission({ roleId: role.id });
|
||||
|
||||
await role.deletePermissions();
|
||||
|
||||
expect(await role.$relatedQuery('permissions')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
describe('createPermissions', () => {
|
||||
it('should create permissions', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
|
||||
await role.createPermissions([
|
||||
{ action: 'read', subject: 'Flow', conditions: [] },
|
||||
]);
|
||||
|
||||
expect(await role.$relatedQuery('permissions')).toMatchObject([
|
||||
{
|
||||
action: 'read',
|
||||
subject: 'Flow',
|
||||
conditions: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should call Permission.filter', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
|
||||
const permissions = [{ action: 'read', subject: 'Flow', conditions: [] }];
|
||||
|
||||
const permissionFilterSpy = vi
|
||||
.spyOn(Permission, 'filter')
|
||||
.mockReturnValue(permissions);
|
||||
|
||||
await role.createPermissions(permissions);
|
||||
|
||||
expect(permissionFilterSpy).toHaveBeenCalledWith(permissions);
|
||||
});
|
||||
});
|
||||
|
||||
it('updatePermissions should delete existing permissions and create new permissions', async () => {
|
||||
const permissionsData = [
|
||||
{ action: 'read', subject: 'Flow', conditions: [] },
|
||||
];
|
||||
|
||||
const deletePermissionsSpy = vi
|
||||
.spyOn(Role.prototype, 'deletePermissions')
|
||||
.mockResolvedValueOnce();
|
||||
const createPermissionsSpy = vi
|
||||
.spyOn(Role.prototype, 'createPermissions')
|
||||
.mockResolvedValueOnce();
|
||||
|
||||
const role = await createRole({ name: 'User' });
|
||||
|
||||
await role.updatePermissions(permissionsData);
|
||||
|
||||
expect(deletePermissionsSpy.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
createPermissionsSpy.mock.invocationCallOrder[0]
|
||||
);
|
||||
|
||||
expect(deletePermissionsSpy).toHaveBeenNthCalledWith(1);
|
||||
expect(createPermissionsSpy).toHaveBeenNthCalledWith(1, permissionsData);
|
||||
});
|
||||
|
||||
describe('updateWithPermissions', () => {
|
||||
it('should update role along with given permissions', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
await createPermission({
|
||||
roleId: role.id,
|
||||
subject: 'Flow',
|
||||
action: 'read',
|
||||
conditions: [],
|
||||
});
|
||||
|
||||
const newRoleData = {
|
||||
name: 'Updated user',
|
||||
description: 'Updated description',
|
||||
permissions: [
|
||||
{
|
||||
action: 'update',
|
||||
subject: 'Flow',
|
||||
conditions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await role.updateWithPermissions(newRoleData);
|
||||
|
||||
const roleWithPermissions = await role
|
||||
.$query()
|
||||
.leftJoinRelated({ permissions: true })
|
||||
.withGraphFetched({ permissions: true });
|
||||
|
||||
expect(roleWithPermissions).toMatchObject(newRoleData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteWithPermissions', () => {
|
||||
it('should delete role along with given permissions', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
await createPermission({
|
||||
roleId: role.id,
|
||||
subject: 'Flow',
|
||||
action: 'read',
|
||||
conditions: [],
|
||||
});
|
||||
|
||||
await role.deleteWithPermissions();
|
||||
|
||||
const refetchedRole = await role.$query();
|
||||
const rolePermissions = await Permission.query().where({
|
||||
roleId: role.id,
|
||||
});
|
||||
|
||||
expect(refetchedRole).toBe(undefined);
|
||||
expect(rolePermissions).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNoRoleUserExists', () => {
|
||||
it('should reject with an error when the role has users', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
await createUser({ roleId: role.id });
|
||||
|
||||
await expect(() => role.assertNoRoleUserExists()).rejects.toThrowError(
|
||||
`All users must be migrated away from the "User" role.`
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve when the role does not have any users', async () => {
|
||||
const role = await createRole();
|
||||
|
||||
expect(await role.assertNoRoleUserExists()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNoConfigurationUsage', () => {
|
||||
it('should reject with an error when the role is used in configuration', async () => {
|
||||
const role = await createRole();
|
||||
await createSamlAuthProvider({ defaultRoleId: role.id });
|
||||
|
||||
await expect(() =>
|
||||
role.assertNoConfigurationUsage()
|
||||
).rejects.toThrowError(
|
||||
'samlAuthProvider: You need to change the default role in the SAML configuration before deleting this role.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve when the role does not have any users', async () => {
|
||||
const role = await createRole();
|
||||
|
||||
expect(await role.assertNoConfigurationUsage()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('assertRoleIsNotUsed should call assertNoRoleUserExists and assertNoConfigurationUsage', async () => {
|
||||
const role = new Role();
|
||||
|
||||
const assertNoRoleUserExistsSpy = vi
|
||||
.spyOn(role, 'assertNoRoleUserExists')
|
||||
.mockResolvedValue();
|
||||
|
||||
const assertNoConfigurationUsageSpy = vi
|
||||
.spyOn(role, 'assertNoConfigurationUsage')
|
||||
.mockResolvedValue();
|
||||
|
||||
await role.assertRoleIsNotUsed();
|
||||
|
||||
expect(assertNoRoleUserExistsSpy).toHaveBeenCalledOnce();
|
||||
expect(assertNoConfigurationUsageSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe('$beforeDelete', () => {
|
||||
it('should call preventAlteringAdmin', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
|
||||
const preventAlteringAdminSpy = vi
|
||||
.spyOn(role, 'preventAlteringAdmin')
|
||||
.mockResolvedValue();
|
||||
|
||||
await role.$query().delete();
|
||||
|
||||
expect(preventAlteringAdminSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should call assertRoleIsNotUsed', async () => {
|
||||
const role = await createRole({ name: 'User' });
|
||||
|
||||
const assertRoleIsNotUsedSpy = vi
|
||||
.spyOn(role, 'assertRoleIsNotUsed')
|
||||
.mockResolvedValue();
|
||||
|
||||
await role.$query().delete();
|
||||
|
||||
expect(assertRoleIsNotUsedSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
});
|
@@ -5,7 +5,7 @@ import appConfig from '../config/app.js';
|
||||
import axios from '../helpers/axios-with-proxy.js';
|
||||
import Base from './base.js';
|
||||
import Identity from './identity.ee.js';
|
||||
import SamlAuthProvidersRoleMapping from './saml-auth-providers-role-mapping.ee.js';
|
||||
import RoleMapping from './role-mapping.ee.js';
|
||||
|
||||
class SamlAuthProvider extends Base {
|
||||
static tableName = 'saml_auth_providers';
|
||||
@@ -53,12 +53,12 @@ class SamlAuthProvider extends Base {
|
||||
to: 'saml_auth_providers.id',
|
||||
},
|
||||
},
|
||||
samlAuthProvidersRoleMappings: {
|
||||
roleMappings: {
|
||||
relation: Base.HasManyRelation,
|
||||
modelClass: SamlAuthProvidersRoleMapping,
|
||||
modelClass: RoleMapping,
|
||||
join: {
|
||||
from: 'saml_auth_providers.id',
|
||||
to: 'saml_auth_providers_role_mappings.saml_auth_provider_id',
|
||||
to: 'role_mappings.saml_auth_provider_id',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -133,27 +133,22 @@ class SamlAuthProvider extends Base {
|
||||
}
|
||||
|
||||
async updateRoleMappings(roleMappings) {
|
||||
return await SamlAuthProvider.transaction(async (trx) => {
|
||||
await this.$relatedQuery('samlAuthProvidersRoleMappings', trx).delete();
|
||||
await this.$relatedQuery('roleMappings').delete();
|
||||
|
||||
if (isEmpty(roleMappings)) {
|
||||
return [];
|
||||
}
|
||||
if (isEmpty(roleMappings)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const samlAuthProvidersRoleMappingsData = roleMappings.map(
|
||||
(samlAuthProvidersRoleMapping) => ({
|
||||
...samlAuthProvidersRoleMapping,
|
||||
samlAuthProviderId: this.id,
|
||||
})
|
||||
);
|
||||
const roleMappingsData = roleMappings.map((roleMapping) => ({
|
||||
...roleMapping,
|
||||
samlAuthProviderId: this.id,
|
||||
}));
|
||||
|
||||
const samlAuthProvidersRoleMappings =
|
||||
await SamlAuthProvidersRoleMapping.query(trx).insertAndFetch(
|
||||
samlAuthProvidersRoleMappingsData
|
||||
);
|
||||
const newRoleMappings = await RoleMapping.query().insertAndFetch(
|
||||
roleMappingsData
|
||||
);
|
||||
|
||||
return samlAuthProvidersRoleMappings;
|
||||
});
|
||||
return newRoleMappings;
|
||||
}
|
||||
}
|
||||
|
||||
|
231
packages/backend/src/models/saml-auth-provider.ee.test.js
Normal file
231
packages/backend/src/models/saml-auth-provider.ee.test.js
Normal file
@@ -0,0 +1,231 @@
|
||||
import { vi, beforeEach, describe, it, expect } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import SamlAuthProvider from '../models/saml-auth-provider.ee';
|
||||
import RoleMapping from '../models/role-mapping.ee';
|
||||
import axios from '../helpers/axios-with-proxy.js';
|
||||
import Identity from './identity.ee';
|
||||
import Base from './base';
|
||||
import appConfig from '../config/app';
|
||||
import { createSamlAuthProvider } from '../../test/factories/saml-auth-provider.ee.js';
|
||||
import { createRoleMapping } from '../../test/factories/role-mapping.js';
|
||||
import { createRole } from '../../test/factories/role.js';
|
||||
|
||||
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',
|
||||
},
|
||||
},
|
||||
roleMappings: {
|
||||
relation: Base.HasManyRelation,
|
||||
modelClass: RoleMapping,
|
||||
join: {
|
||||
from: 'saml_auth_providers.id',
|
||||
to: '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'
|
||||
);
|
||||
});
|
||||
|
||||
it('config should return the correct configuration object', () => {
|
||||
const samlAuthProvider = new SamlAuthProvider();
|
||||
|
||||
samlAuthProvider.certificate = 'sample-certificate';
|
||||
samlAuthProvider.signatureAlgorithm = 'sha256';
|
||||
samlAuthProvider.entryPoint = 'https://example.com/saml';
|
||||
samlAuthProvider.issuer = 'sample-issuer';
|
||||
|
||||
vi.spyOn(appConfig, 'baseUrl', 'get').mockReturnValue(
|
||||
'https://automatisch.io'
|
||||
);
|
||||
|
||||
const expectedConfig = {
|
||||
callbackUrl: 'https://automatisch.io/login/saml/sample-issuer/callback',
|
||||
cert: 'sample-certificate',
|
||||
entryPoint: 'https://example.com/saml',
|
||||
issuer: 'sample-issuer',
|
||||
signatureAlgorithm: 'sha256',
|
||||
logoutUrl: 'https://example.com/saml',
|
||||
};
|
||||
|
||||
expect(samlAuthProvider.config).toStrictEqual(expectedConfig);
|
||||
});
|
||||
|
||||
it('generateLogoutRequestBody should return a correctly encoded SAML logout request', () => {
|
||||
vi.mock('uuid', () => ({
|
||||
v4: vi.fn(),
|
||||
}));
|
||||
|
||||
const samlAuthProvider = new SamlAuthProvider();
|
||||
|
||||
samlAuthProvider.entryPoint = 'https://example.com/saml';
|
||||
samlAuthProvider.issuer = 'sample-issuer';
|
||||
|
||||
const mockUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
uuidv4.mockReturnValue(mockUuid);
|
||||
|
||||
const sessionId = 'test-session-id';
|
||||
|
||||
const logoutRequest = samlAuthProvider.generateLogoutRequestBody(sessionId);
|
||||
|
||||
const expectedLogoutRequest = `
|
||||
<samlp:LogoutRequest
|
||||
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
ID="${mockUuid}"
|
||||
Version="2.0"
|
||||
IssueInstant="${new Date().toISOString()}"
|
||||
Destination="https://example.com/saml">
|
||||
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">sample-issuer</saml:Issuer>
|
||||
<samlp:SessionIndex>test-session-id</samlp:SessionIndex>
|
||||
</samlp:LogoutRequest>
|
||||
`;
|
||||
|
||||
const expectedEncodedRequest = Buffer.from(expectedLogoutRequest).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
expect(logoutRequest).toBe(expectedEncodedRequest);
|
||||
});
|
||||
|
||||
it('terminateRemoteSession should send the correct POST request and return the response', async () => {
|
||||
vi.mock('../helpers/axios-with-proxy.js', () => ({
|
||||
default: {
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const samlAuthProvider = new SamlAuthProvider();
|
||||
|
||||
samlAuthProvider.entryPoint = 'https://example.com/saml';
|
||||
samlAuthProvider.generateLogoutRequestBody = vi
|
||||
.fn()
|
||||
.mockReturnValue('mockEncodedLogoutRequest');
|
||||
|
||||
const sessionId = 'test-session-id';
|
||||
|
||||
const mockResponse = { data: 'Logout Successful' };
|
||||
axios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const response = await samlAuthProvider.terminateRemoteSession(sessionId);
|
||||
|
||||
expect(samlAuthProvider.generateLogoutRequestBody).toHaveBeenCalledWith(
|
||||
sessionId
|
||||
);
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'https://example.com/saml',
|
||||
'SAMLRequest=mockEncodedLogoutRequest',
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response).toBe(mockResponse);
|
||||
});
|
||||
|
||||
describe('updateRoleMappings', () => {
|
||||
let samlAuthProvider;
|
||||
|
||||
beforeEach(async () => {
|
||||
samlAuthProvider = await createSamlAuthProvider();
|
||||
});
|
||||
|
||||
it('should remove all existing role mappings', async () => {
|
||||
await createRoleMapping({
|
||||
samlAuthProviderId: samlAuthProvider.id,
|
||||
remoteRoleName: 'Admin',
|
||||
});
|
||||
|
||||
await createRoleMapping({
|
||||
samlAuthProviderId: samlAuthProvider.id,
|
||||
remoteRoleName: 'User',
|
||||
});
|
||||
|
||||
await samlAuthProvider.updateRoleMappings([]);
|
||||
|
||||
const roleMappings = await samlAuthProvider.$relatedQuery('roleMappings');
|
||||
expect(roleMappings).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('should return the updated role mappings when new ones are provided', async () => {
|
||||
const adminRole = await createRole({ name: 'Admin' });
|
||||
const userRole = await createRole({ name: 'User' });
|
||||
|
||||
const newRoleMappings = [
|
||||
{ remoteRoleName: 'Admin', roleId: adminRole.id },
|
||||
{ remoteRoleName: 'User', roleId: userRole.id },
|
||||
];
|
||||
|
||||
const result = await samlAuthProvider.updateRoleMappings(newRoleMappings);
|
||||
|
||||
const refetchedRoleMappings = await samlAuthProvider.$relatedQuery(
|
||||
'roleMappings'
|
||||
);
|
||||
|
||||
expect(result).toStrictEqual(refetchedRoleMappings);
|
||||
});
|
||||
});
|
||||
});
|
@@ -93,6 +93,14 @@ class Step extends Base {
|
||||
return `${appConfig.baseUrl}/apps/${this.appKey}/assets/favicon.svg`;
|
||||
}
|
||||
|
||||
get isTrigger() {
|
||||
return this.type === 'trigger';
|
||||
}
|
||||
|
||||
get isAction() {
|
||||
return this.type === 'action';
|
||||
}
|
||||
|
||||
async computeWebhookPath() {
|
||||
if (this.type === 'action') return null;
|
||||
|
||||
@@ -135,24 +143,6 @@ class Step extends Base {
|
||||
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() {
|
||||
if (!this.appKey) return null;
|
||||
|
||||
@@ -170,12 +160,7 @@ class Step extends Base {
|
||||
}
|
||||
|
||||
async getLastExecutionStep() {
|
||||
const lastExecutionStep = await this.$relatedQuery('executionSteps')
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(1)
|
||||
.first();
|
||||
|
||||
return lastExecutionStep;
|
||||
return await this.$relatedQuery('lastExecutionStep');
|
||||
}
|
||||
|
||||
async getNextStep() {
|
||||
@@ -207,19 +192,18 @@ class Step extends Base {
|
||||
}
|
||||
|
||||
async getSetupFields() {
|
||||
let setupSupsteps;
|
||||
let substeps;
|
||||
|
||||
if (this.isTrigger) {
|
||||
setupSupsteps = (await this.getTriggerCommand()).substeps;
|
||||
substeps = (await this.getTriggerCommand()).substeps;
|
||||
} else {
|
||||
setupSupsteps = (await this.getActionCommand()).substeps;
|
||||
substeps = (await this.getActionCommand()).substeps;
|
||||
}
|
||||
|
||||
const existingArguments = setupSupsteps.find(
|
||||
const setupSubstep = substeps.find(
|
||||
(substep) => substep.key === 'chooseTrigger'
|
||||
).arguments;
|
||||
|
||||
return existingArguments;
|
||||
);
|
||||
return setupSubstep.arguments;
|
||||
}
|
||||
|
||||
async getSetupAndDynamicFields() {
|
||||
@@ -326,23 +310,17 @@ class Step extends Base {
|
||||
.$relatedQuery('steps')
|
||||
.where('position', '>', this.position);
|
||||
|
||||
const nextStepQueries = nextSteps.map(async (nextStep) => {
|
||||
await nextStep.$query().patch({
|
||||
position: nextStep.position - 1,
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(nextStepQueries);
|
||||
await flow.updateStepPositionsFrom(this.position, nextSteps);
|
||||
}
|
||||
|
||||
async updateFor(user, newStepData) {
|
||||
const { connectionId, appKey, key, parameters } = newStepData;
|
||||
const { appKey = this.appKey, connectionId, key, parameters } = newStepData;
|
||||
|
||||
if (connectionId && (appKey || this.appKey)) {
|
||||
if (connectionId && appKey) {
|
||||
await user.authorizedConnections
|
||||
.findOne({
|
||||
id: connectionId,
|
||||
key: appKey || this.appKey,
|
||||
key: appKey,
|
||||
})
|
||||
.throwIfNotFound();
|
||||
}
|
||||
@@ -356,8 +334,8 @@ class Step extends Base {
|
||||
}
|
||||
|
||||
const updatedStep = await this.$query().patchAndFetch({
|
||||
key: key,
|
||||
appKey: appKey,
|
||||
key,
|
||||
appKey,
|
||||
connectionId: connectionId,
|
||||
parameters: parameters,
|
||||
status: 'incomplete',
|
||||
@@ -367,6 +345,16 @@ class Step extends Base {
|
||||
|
||||
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;
|
||||
|
504
packages/backend/src/models/step.test.js
Normal file
504
packages/backend/src/models/step.test.js
Normal file
@@ -0,0 +1,504 @@
|
||||
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({});
|
||||
});
|
||||
});
|
||||
});
|
@@ -212,6 +212,10 @@ class User extends Base {
|
||||
return `${appConfig.webAppUrl}/accept-invitation?token=${this.invitationToken}`;
|
||||
}
|
||||
|
||||
get ability() {
|
||||
return userAbility(this);
|
||||
}
|
||||
|
||||
static async authenticate(email, password) {
|
||||
const user = await User.query().findOne({
|
||||
email: email?.toLowerCase() || null,
|
||||
@@ -223,8 +227,8 @@ class User extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
login(password) {
|
||||
return bcrypt.compare(password, this.password);
|
||||
async login(password) {
|
||||
return await bcrypt.compare(password, this.password);
|
||||
}
|
||||
|
||||
async generateResetPasswordToken() {
|
||||
@@ -407,7 +411,7 @@ class User extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
async startTrialPeriod() {
|
||||
startTrialPeriod() {
|
||||
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate();
|
||||
}
|
||||
|
||||
@@ -583,32 +587,30 @@ class User extends Base {
|
||||
return user;
|
||||
}
|
||||
|
||||
async $beforeInsert(queryContext) {
|
||||
await super.$beforeInsert(queryContext);
|
||||
can(action, subject) {
|
||||
const can = this.ability.can(action, subject);
|
||||
|
||||
this.email = this.email.toLowerCase();
|
||||
await this.generateHash();
|
||||
if (!can) throw new NotAuthorizedError('The user is not authorized!');
|
||||
|
||||
if (appConfig.isCloud) {
|
||||
await this.startTrialPeriod();
|
||||
}
|
||||
const relevantRule = this.ability.relevantRuleFor(action, subject);
|
||||
|
||||
const conditions = relevantRule?.conditions || [];
|
||||
const conditionMap = Object.fromEntries(
|
||||
conditions.map((condition) => [condition, true])
|
||||
);
|
||||
|
||||
return conditionMap;
|
||||
}
|
||||
|
||||
async $beforeUpdate(opt, queryContext) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
|
||||
lowercaseEmail() {
|
||||
if (this.email) {
|
||||
this.email = this.email.toLowerCase();
|
||||
}
|
||||
|
||||
await this.generateHash();
|
||||
}
|
||||
|
||||
async $afterInsert(queryContext) {
|
||||
await super.$afterInsert(queryContext);
|
||||
|
||||
async createUsageData() {
|
||||
if (appConfig.isCloud) {
|
||||
await this.$relatedQuery('usageData').insert({
|
||||
return await this.$relatedQuery('usageData').insertAndFetch({
|
||||
userId: this.id,
|
||||
consumedTaskCount: 0,
|
||||
nextResetAt: DateTime.now().plus({ days: 30 }).toISODate(),
|
||||
@@ -616,8 +618,10 @@ class User extends Base {
|
||||
}
|
||||
}
|
||||
|
||||
async $afterFind() {
|
||||
if (await hasValidLicense()) return this;
|
||||
async omitEnterprisePermissionsWithoutValidLicense() {
|
||||
if (await hasValidLicense()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
if (Array.isArray(this.permissions)) {
|
||||
this.permissions = this.permissions.filter((permission) => {
|
||||
@@ -631,35 +635,35 @@ class User extends Base {
|
||||
return !restrictedSubjects.includes(permission.subject);
|
||||
});
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
get ability() {
|
||||
return userAbility(this);
|
||||
async $beforeInsert(queryContext) {
|
||||
await super.$beforeInsert(queryContext);
|
||||
|
||||
this.lowercaseEmail();
|
||||
await this.generateHash();
|
||||
|
||||
if (appConfig.isCloud) {
|
||||
this.startTrialPeriod();
|
||||
}
|
||||
}
|
||||
|
||||
can(action, subject) {
|
||||
const can = this.ability.can(action, subject);
|
||||
async $beforeUpdate(opt, queryContext) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
|
||||
if (!can) throw new NotAuthorizedError();
|
||||
this.lowercaseEmail();
|
||||
|
||||
const relevantRule = this.ability.relevantRuleFor(action, subject);
|
||||
|
||||
const conditions = relevantRule?.conditions || [];
|
||||
const conditionMap = Object.fromEntries(
|
||||
conditions.map((condition) => [condition, true])
|
||||
);
|
||||
|
||||
return conditionMap;
|
||||
await this.generateHash();
|
||||
}
|
||||
|
||||
cannot(action, subject) {
|
||||
const cannot = this.ability.cannot(action, subject);
|
||||
async $afterInsert(queryContext) {
|
||||
await super.$afterInsert(queryContext);
|
||||
|
||||
if (cannot) throw new NotAuthorizedError();
|
||||
await this.createUsageData();
|
||||
}
|
||||
|
||||
return cannot;
|
||||
async $afterFind() {
|
||||
await this.omitEnterprisePermissionsWithoutValidLicense();
|
||||
}
|
||||
}
|
||||
|
||||
|
1533
packages/backend/src/models/user.test.js
Normal file
1533
packages/backend/src/models/user.test.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ const serializers = {
|
||||
Permission: permissionSerializer,
|
||||
AdminSamlAuthProvider: adminSamlAuthProviderSerializer,
|
||||
SamlAuthProvider: samlAuthProviderSerializer,
|
||||
SamlAuthProvidersRoleMapping: samlAuthProviderRoleMappingSerializer,
|
||||
RoleMapping: samlAuthProviderRoleMappingSerializer,
|
||||
AppAuthClient: appAuthClientSerializer,
|
||||
AppConfig: appConfigSerializer,
|
||||
Flow: flowSerializer,
|
||||
|
@@ -1,16 +1,15 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { createRole } from './role.js';
|
||||
import RoleMapping from '../../src/models/role-mapping.ee.js';
|
||||
import { createSamlAuthProvider } from './saml-auth-provider.ee.js';
|
||||
import SamlAuthProviderRoleMapping from '../../src/models/saml-auth-providers-role-mapping.ee.js';
|
||||
|
||||
export const createRoleMapping = async (params = {}) => {
|
||||
params.roleId = params?.roleId || (await createRole()).id;
|
||||
params.roleId = params.roleId || (await createRole()).id;
|
||||
params.samlAuthProviderId =
|
||||
params?.samlAuthProviderId || (await createSamlAuthProvider()).id;
|
||||
params.samlAuthProviderId || (await createSamlAuthProvider()).id;
|
||||
params.remoteRoleName = params.remoteRoleName || faker.person.jobType();
|
||||
|
||||
params.remoteRoleName = params?.remoteRoleName || 'User';
|
||||
const roleMapping = await RoleMapping.query().insertAndFetch(params);
|
||||
|
||||
const samlAuthProviderRoleMapping =
|
||||
await SamlAuthProviderRoleMapping.query().insertAndFetch(params);
|
||||
|
||||
return samlAuthProviderRoleMapping;
|
||||
return roleMapping;
|
||||
};
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import Role from '../../src/models/role';
|
||||
import Role from '../../src/models/role.js';
|
||||
|
||||
export const createRole = async (params = {}) => {
|
||||
const name = faker.lorem.word();
|
||||
|
@@ -1,16 +0,0 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { createRole } from './role.js';
|
||||
import SamlAuthProvidersRoleMapping from '../../src/models/saml-auth-providers-role-mapping.ee.js';
|
||||
import { createSamlAuthProvider } from './saml-auth-provider.ee.js';
|
||||
|
||||
export const createSamlAuthProvidersRoleMapping = async (params = {}) => {
|
||||
params.roleId = params.roleId || (await createRole()).id;
|
||||
params.samlAuthProviderId =
|
||||
params.samlAuthProviderId || (await createSamlAuthProvider()).id;
|
||||
params.remoteRoleName = params.remoteRoleName || faker.person.jobType();
|
||||
|
||||
const samlAuthProvider =
|
||||
await SamlAuthProvidersRoleMapping.query().insertAndFetch(params);
|
||||
|
||||
return samlAuthProvider;
|
||||
};
|
@@ -15,7 +15,7 @@ const getRoleMappingsMock = async (roleMappings) => {
|
||||
currentPage: null,
|
||||
isArray: true,
|
||||
totalPages: null,
|
||||
type: 'SamlAuthProvidersRoleMapping',
|
||||
type: 'RoleMapping',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@@ -15,7 +15,7 @@ const createRoleMappingsMock = async (roleMappings) => {
|
||||
currentPage: null,
|
||||
isArray: true,
|
||||
totalPages: null,
|
||||
type: 'SamlAuthProvidersRoleMapping',
|
||||
type: 'RoleMapping',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@@ -6,18 +6,6 @@ const createFlowMock = async (flow) => {
|
||||
status: flow.status,
|
||||
createdAt: flow.createdAt.getTime(),
|
||||
updatedAt: flow.updatedAt.getTime(),
|
||||
steps: [
|
||||
{
|
||||
position: 1,
|
||||
status: 'incomplete',
|
||||
type: 'trigger',
|
||||
},
|
||||
{
|
||||
position: 2,
|
||||
status: 'incomplete',
|
||||
type: 'action',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
|
@@ -2,8 +2,25 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
root: './',
|
||||
environment: 'node',
|
||||
setupFiles: ['./test/setup/global-hooks.js'],
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
4821
packages/backend/yarn.lock
Normal file
4821
packages/backend/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/docs/.gitignore
vendored
Normal file
1
packages/docs/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
pages/.vitepress/cache
|
@@ -4,6 +4,7 @@
|
||||
"license": "See LICENSE file",
|
||||
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev pages --port 3002",
|
||||
"build": "vitepress build pages",
|
||||
|
@@ -6,11 +6,19 @@ Clone main branch of Automatisch.
|
||||
git clone git@github.com:automatisch/automatisch.git
|
||||
```
|
||||
|
||||
Then, install the dependencies.
|
||||
Then, install the dependencies for both backend and web packages separately.
|
||||
|
||||
```bash
|
||||
cd automatisch
|
||||
|
||||
# Install backend dependencies
|
||||
cd packages/backend
|
||||
yarn install
|
||||
|
||||
# Install web dependencies
|
||||
cd packages/web
|
||||
yarn install
|
||||
|
||||
```
|
||||
|
||||
## Backend
|
||||
@@ -53,12 +61,14 @@ yarn db:seed:user
|
||||
Start the main backend server.
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Start the worker server in another terminal tab.
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
yarn worker
|
||||
```
|
||||
|
||||
@@ -84,6 +94,7 @@ It will automatically open [http://localhost:3001](http://localhost:3001) in you
|
||||
|
||||
```bash
|
||||
cd packages/docs
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Repository Structure
|
||||
|
||||
We use `lerna` with `yarn workspaces` to manage the mono repository. We have the following packages:
|
||||
We manage a monorepo structure with the following packages:
|
||||
|
||||
```
|
||||
.
|
||||
@@ -15,3 +15,5 @@ We use `lerna` with `yarn workspaces` to manage the mono repository. We have the
|
||||
- `docs` - The docs package contains the documentation website.
|
||||
- `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.
|
||||
|
||||
Each package is independently managed, and has its own package.json file to manage dependencies. This allows for better isolation and flexibility.
|
||||
|
1192
packages/docs/yarn.lock
Normal file
1192
packages/docs/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
21
packages/e2e-tests/fixtures/execution-details-page.js
Normal file
21
packages/e2e-tests/fixtures/execution-details-page.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const { AuthenticatedPage } = require('./authenticated-page');
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
export class ExecutionDetailsPage extends AuthenticatedPage {
|
||||
constructor(page) {
|
||||
super(page);
|
||||
|
||||
this.executionCreatedAt = page.getByTestId('execution-created-at');
|
||||
this.executionId = page.getByTestId('execution-id');
|
||||
this.executionName = page.getByTestId('execution-name');
|
||||
this.executionStep = page.getByTestId('execution-step');
|
||||
}
|
||||
|
||||
async verifyExecutionData(flowId) {
|
||||
await expect(this.executionCreatedAt).toContainText(/\d+ seconds? ago/);
|
||||
await expect(this.executionId).toHaveText(
|
||||
/Execution ID: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
await expect(this.executionName).toHaveText(flowId);
|
||||
}
|
||||
}
|
93
packages/e2e-tests/fixtures/execution-step-details.js
Normal file
93
packages/e2e-tests/fixtures/execution-step-details.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { ExecutionDetailsPage } = require('./execution-details-page');
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
export class ExecutionStepDetails extends ExecutionDetailsPage {
|
||||
constructor(page, executionStep) {
|
||||
super(page);
|
||||
|
||||
this.executionStep = executionStep;
|
||||
this.stepType = executionStep.getByTestId('step-type');
|
||||
this.stepPositionAndName = executionStep.getByTestId(
|
||||
'step-position-and-name'
|
||||
);
|
||||
this.executionStepId = executionStep.getByTestId('execution-step-id');
|
||||
this.executionStepExecutedAt = executionStep.getByTestId(
|
||||
'execution-step-executed-at'
|
||||
);
|
||||
this.dataInTab = executionStep.getByTestId('data-in-tab');
|
||||
this.dataInPanel = executionStep.getByTestId('data-in-panel');
|
||||
this.dataOutTab = executionStep.getByTestId('data-out-tab');
|
||||
this.dataOutPanel = executionStep.getByTestId('data-out-panel');
|
||||
}
|
||||
|
||||
async expectDataInTabToBeSelectedByDefault() {
|
||||
await expect(this.dataInTab).toHaveClass(/Mui-selected/);
|
||||
}
|
||||
|
||||
async expectDataInToContainText(searchText, desiredText) {
|
||||
await expect(this.dataInPanel).toContainText(desiredText);
|
||||
await this.dataInPanel.locator('#search-input').fill(searchText);
|
||||
await expect(this.dataInPanel).toContainText(desiredText);
|
||||
}
|
||||
|
||||
async expectDataOutToContainText(searchText, desiredText) {
|
||||
await expect(this.dataOutPanel).toContainText(desiredText);
|
||||
await this.dataOutPanel.locator('#search-input').fill(searchText);
|
||||
await expect(this.dataOutPanel).toContainText(desiredText);
|
||||
}
|
||||
|
||||
async verifyTriggerExecutionStep({
|
||||
stepPositionAndName,
|
||||
stepDataInKey,
|
||||
stepDataInValue,
|
||||
stepDataOutKey,
|
||||
stepDataOutValue,
|
||||
}) {
|
||||
await expect(this.stepType).toHaveText('Trigger');
|
||||
await this.verifyExecutionStep({
|
||||
stepPositionAndName,
|
||||
stepDataInKey,
|
||||
stepDataInValue,
|
||||
stepDataOutKey,
|
||||
stepDataOutValue,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyActionExecutionStep({
|
||||
stepPositionAndName,
|
||||
stepDataInKey,
|
||||
stepDataInValue,
|
||||
stepDataOutKey,
|
||||
stepDataOutValue,
|
||||
}) {
|
||||
await expect(this.stepType).toHaveText('Action');
|
||||
await this.verifyExecutionStep({
|
||||
stepPositionAndName,
|
||||
stepDataInKey,
|
||||
stepDataInValue,
|
||||
stepDataOutKey,
|
||||
stepDataOutValue,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyExecutionStep({
|
||||
stepPositionAndName,
|
||||
stepDataInKey,
|
||||
stepDataInValue,
|
||||
stepDataOutKey,
|
||||
stepDataOutValue,
|
||||
}) {
|
||||
await expect(this.stepPositionAndName).toHaveText(stepPositionAndName);
|
||||
await expect(this.executionStepId).toHaveText(
|
||||
/ID: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
await expect(this.executionStepExecutedAt).toContainText(
|
||||
/executed \d+ seconds? ago/
|
||||
);
|
||||
await this.expectDataInTabToBeSelectedByDefault();
|
||||
await this.expectDataInToContainText(stepDataInKey, stepDataInValue);
|
||||
await this.dataOutTab.click();
|
||||
await expect(this.dataOutPanel).toContainText(stepDataOutValue);
|
||||
await this.expectDataOutToContainText(stepDataOutKey, stepDataOutValue);
|
||||
}
|
||||
}
|
@@ -2,4 +2,11 @@ const { AuthenticatedPage } = require('./authenticated-page');
|
||||
|
||||
export class ExecutionsPage extends AuthenticatedPage {
|
||||
screenshotPath = '/executions';
|
||||
|
||||
constructor(page) {
|
||||
super(page);
|
||||
|
||||
this.executionRow = this.page.getByTestId('execution-row');
|
||||
this.executionsPageLoader = this.page.getByTestId('executions-loader');
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ const { test, expect } = require('@playwright/test');
|
||||
const { ApplicationsPage } = require('./applications-page');
|
||||
const { ConnectionsPage } = require('./connections-page');
|
||||
const { ExecutionsPage } = require('./executions-page');
|
||||
const { ExecutionDetailsPage } = require('./execution-details-page');
|
||||
const { FlowEditorPage } = require('./flow-editor-page');
|
||||
const { UserInterfacePage } = require('./user-interface-page');
|
||||
const { LoginPage } = require('./login-page');
|
||||
@@ -29,6 +30,9 @@ exports.test = test.extend({
|
||||
executionsPage: async ({ page }, use) => {
|
||||
await use(new ExecutionsPage(page));
|
||||
},
|
||||
executionDetailsPage: async ({ page }, use) => {
|
||||
await use(new ExecutionDetailsPage(page));
|
||||
},
|
||||
flowEditorPage: async ({ page }, use) => {
|
||||
await use(new FlowEditorPage(page));
|
||||
},
|
||||
|
15
packages/e2e-tests/helpers/auth-api-helper.js
Normal file
15
packages/e2e-tests/helpers/auth-api-helper.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { expect } = require('../fixtures/index');
|
||||
|
||||
export const getToken = async (apiRequest) => {
|
||||
const tokenResponse = await apiRequest.post(
|
||||
`http://localhost:${process.env.PORT}/api/v1/access-tokens`,
|
||||
{
|
||||
data: {
|
||||
email: process.env.LOGIN_EMAIL,
|
||||
password: process.env.LOGIN_PASSWORD,
|
||||
},
|
||||
}
|
||||
);
|
||||
await expect(tokenResponse.status()).toBe(200);
|
||||
return await tokenResponse.json();
|
||||
};
|
108
packages/e2e-tests/helpers/flow-api-helper.js
Normal file
108
packages/e2e-tests/helpers/flow-api-helper.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { expect } = require('../fixtures/index');
|
||||
|
||||
export const createFlow = async (request, token) => {
|
||||
const response = await request.post(
|
||||
`http://localhost:${process.env.PORT}/api/v1/flows`,
|
||||
{ headers: { Authorization: token } }
|
||||
);
|
||||
await expect(response.status()).toBe(201);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const getFlow = async (request, token, flowId) => {
|
||||
const response = await request.get(
|
||||
`http://localhost:${process.env.PORT}/api/v1/flows/${flowId}`,
|
||||
{ headers: { Authorization: token } }
|
||||
);
|
||||
await expect(response.status()).toBe(200);
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateFlowName = async (request, token, flowId) => {
|
||||
const updateFlowNameResponse = await request.patch(
|
||||
`http://localhost:${process.env.PORT}/api/v1/flows/${flowId}`,
|
||||
{
|
||||
headers: { Authorization: token },
|
||||
data: { name: flowId },
|
||||
}
|
||||
);
|
||||
await expect(updateFlowNameResponse.status()).toBe(200);
|
||||
};
|
||||
|
||||
export const updateFlowStep = async (request, token, stepId, requestBody) => {
|
||||
const updateTriggerStepResponse = await request.patch(
|
||||
`http://localhost:${process.env.PORT}/api/v1/steps/${stepId}`,
|
||||
{
|
||||
headers: { Authorization: token },
|
||||
data: requestBody,
|
||||
}
|
||||
);
|
||||
await expect(updateTriggerStepResponse.status()).toBe(200);
|
||||
return await updateTriggerStepResponse.json();
|
||||
};
|
||||
|
||||
export const testStep = async (request, token, stepId) => {
|
||||
const testTriggerStepResponse = await request.post(
|
||||
`http://localhost:${process.env.PORT}/api/v1/steps/${stepId}/test`,
|
||||
{
|
||||
headers: { Authorization: token },
|
||||
}
|
||||
);
|
||||
await expect(testTriggerStepResponse.status()).toBe(200);
|
||||
};
|
||||
|
||||
export const publishFlow = async (request, token, flowId) => {
|
||||
const publishFlowResponse = await request.patch(
|
||||
`http://localhost:${process.env.PORT}/api/v1/flows/${flowId}/status`,
|
||||
{
|
||||
headers: { Authorization: token },
|
||||
data: { active: true },
|
||||
}
|
||||
);
|
||||
await expect(publishFlowResponse.status()).toBe(200);
|
||||
return publishFlowResponse.json();
|
||||
};
|
||||
|
||||
export const triggerFlow = async (request, url) => {
|
||||
const triggerFlowResponse = await request.get(url);
|
||||
await expect(triggerFlowResponse.status()).toBe(204);
|
||||
};
|
||||
|
||||
export const addWebhookFlow = async (request, token) => {
|
||||
let flow = await createFlow(request, token);
|
||||
const flowId = flow.data.id;
|
||||
await updateFlowName(request, token, flowId);
|
||||
flow = await getFlow(request, token, flowId);
|
||||
const flowSteps = flow.data.steps;
|
||||
|
||||
const triggerStepId = flowSteps.find((step) => step.type === 'trigger').id;
|
||||
const actionStepId = flowSteps.find((step) => step.type === 'action').id;
|
||||
|
||||
const triggerStep = await updateFlowStep(request, token, triggerStepId, {
|
||||
appKey: 'webhook',
|
||||
key: 'catchRawWebhook',
|
||||
parameters: {
|
||||
workSynchronously: false,
|
||||
},
|
||||
});
|
||||
await request.get(triggerStep.data.webhookUrl);
|
||||
await testStep(request, token, triggerStepId);
|
||||
|
||||
await updateFlowStep(request, token, actionStepId, {
|
||||
appKey: 'webhook',
|
||||
key: 'respondWith',
|
||||
parameters: {
|
||||
statusCode: '200',
|
||||
body: 'ok',
|
||||
headers: [
|
||||
{
|
||||
key: '',
|
||||
value: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await testStep(request, token, actionStepId);
|
||||
|
||||
return flowId;
|
||||
};
|
@@ -29,10 +29,12 @@
|
||||
"@playwright/test": "^1.45.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"knex": "^2.4.0",
|
||||
"luxon": "^3.4.4",
|
||||
"micro": "^10.0.1",
|
||||
"pg": "^8.12.0",
|
||||
|
@@ -2,11 +2,14 @@ const { publicTest: setup, expect } = require('../../fixtures/index');
|
||||
|
||||
setup.describe.serial('Admin setup page', () => {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
setup('should not be able to login if admin is not created', async ({ page, adminSetupPage, loginPage }) => {
|
||||
await expect(async () => {
|
||||
await expect(await page.url()).toContain(adminSetupPage.path);
|
||||
}).toPass();
|
||||
});
|
||||
setup(
|
||||
'should not be able to login if admin is not created',
|
||||
async ({ page, adminSetupPage }) => {
|
||||
await expect(async () => {
|
||||
await expect(await page.url()).toContain(adminSetupPage.path);
|
||||
}).toPass();
|
||||
}
|
||||
);
|
||||
|
||||
setup('should validate the inputs', async ({ adminSetupPage }) => {
|
||||
await adminSetupPage.open();
|
||||
|
@@ -1,37 +1,138 @@
|
||||
const { request } = require('@playwright/test');
|
||||
const { test, expect } = require('../../fixtures/index');
|
||||
const {
|
||||
triggerFlow,
|
||||
publishFlow,
|
||||
addWebhookFlow,
|
||||
} = require('../../helpers/flow-api-helper');
|
||||
const {
|
||||
ExecutionStepDetails,
|
||||
} = require('../../fixtures/execution-step-details');
|
||||
const { getToken } = require('../../helpers/auth-api-helper');
|
||||
|
||||
test.describe('Executions page', () => {
|
||||
let flowId;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const apiRequest = await request.newContext();
|
||||
const tokenJsonResponse = await getToken(apiRequest);
|
||||
|
||||
flowId = await addWebhookFlow(apiRequest, tokenJsonResponse.data.token);
|
||||
|
||||
const { data } = await publishFlow(
|
||||
apiRequest,
|
||||
tokenJsonResponse.data.token,
|
||||
flowId
|
||||
);
|
||||
|
||||
const triggerStepWebhookUrl = data.steps.find(
|
||||
(step) => step.type === 'trigger'
|
||||
).webhookUrl;
|
||||
|
||||
await triggerFlow(apiRequest, triggerStepWebhookUrl);
|
||||
});
|
||||
|
||||
// no execution data exists in an empty account
|
||||
test.describe.skip('Executions page', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('executions-page-drawer-link').click();
|
||||
await page.getByTestId('execution-row').first().click();
|
||||
|
||||
await expect(page).toHaveURL(/\/executions\//);
|
||||
});
|
||||
|
||||
test('displays data in by default', async ({ page, executionsPage }) => {
|
||||
await expect(page.getByTestId('execution-step').last()).toBeVisible();
|
||||
await expect(page.getByTestId('execution-step')).toHaveCount(2);
|
||||
test('show correct step data for trigger and actions on test and non-test execution', async ({
|
||||
page,
|
||||
executionsPage,
|
||||
executionDetailsPage,
|
||||
}) => {
|
||||
await executionsPage.executionsPageLoader.waitFor({
|
||||
state: 'detached',
|
||||
});
|
||||
const flowExecutions = await executionsPage.executionRow.filter({
|
||||
hasText: flowId,
|
||||
});
|
||||
await test.step('show only trigger step on test execution', async () => {
|
||||
await expect(flowExecutions.last()).toContainText('Test run');
|
||||
await flowExecutions.last().click();
|
||||
|
||||
await executionsPage.screenshot({
|
||||
path: 'Execution - data in.png',
|
||||
await executionDetailsPage.verifyExecutionData(flowId);
|
||||
await expect(executionDetailsPage.executionStep).toHaveCount(1);
|
||||
|
||||
const executionStepDetails = new ExecutionStepDetails(
|
||||
page,
|
||||
executionDetailsPage.executionStep.last()
|
||||
);
|
||||
await executionStepDetails.verifyTriggerExecutionStep({
|
||||
stepPositionAndName: '1. Webhook',
|
||||
stepDataInKey: 'workSynchronously',
|
||||
stepDataInValue: 'workSynchronously',
|
||||
stepDataOutKey: 'host',
|
||||
stepDataOutValue: 'localhost',
|
||||
});
|
||||
|
||||
await page.goBack();
|
||||
});
|
||||
|
||||
await test.step('show trigger and action step on action test execution', async () => {
|
||||
await expect(flowExecutions.nth(1)).toContainText('Test run');
|
||||
await flowExecutions.nth(1).click();
|
||||
|
||||
await expect(executionDetailsPage.executionStep).toHaveCount(2);
|
||||
await executionDetailsPage.verifyExecutionData(flowId);
|
||||
|
||||
const firstExecutionStepDetails = new ExecutionStepDetails(
|
||||
page,
|
||||
executionDetailsPage.executionStep.first()
|
||||
);
|
||||
await firstExecutionStepDetails.verifyTriggerExecutionStep({
|
||||
stepPositionAndName: '1. Webhook',
|
||||
stepDataInKey: 'workSynchronously',
|
||||
stepDataInValue: 'workSynchronously',
|
||||
stepDataOutKey: 'host',
|
||||
stepDataOutValue: 'localhost',
|
||||
});
|
||||
|
||||
const lastExecutionStepDetails = new ExecutionStepDetails(
|
||||
page,
|
||||
executionDetailsPage.executionStep.last()
|
||||
);
|
||||
await lastExecutionStepDetails.verifyActionExecutionStep({
|
||||
stepPositionAndName: '2. Webhook',
|
||||
stepDataInKey: 'body',
|
||||
stepDataInValue: 'body:"ok"',
|
||||
stepDataOutKey: 'body',
|
||||
stepDataOutValue: 'body:"ok"',
|
||||
});
|
||||
|
||||
await page.goBack();
|
||||
});
|
||||
|
||||
await test.step('show trigger and action step on flow execution', async () => {
|
||||
await expect(flowExecutions.first()).not.toContainText('Test run');
|
||||
await flowExecutions.first().click();
|
||||
|
||||
await expect(executionDetailsPage.executionStep).toHaveCount(2);
|
||||
await executionDetailsPage.verifyExecutionData(flowId);
|
||||
|
||||
const firstExecutionStepDetails = new ExecutionStepDetails(
|
||||
page,
|
||||
executionDetailsPage.executionStep.first()
|
||||
);
|
||||
await firstExecutionStepDetails.verifyTriggerExecutionStep({
|
||||
stepPositionAndName: '1. Webhook',
|
||||
stepDataInKey: 'workSynchronously',
|
||||
stepDataInValue: 'workSynchronously',
|
||||
stepDataOutKey: 'host',
|
||||
stepDataOutValue: 'localhost',
|
||||
});
|
||||
|
||||
const lastExecutionStepDetails = new ExecutionStepDetails(
|
||||
page,
|
||||
executionDetailsPage.executionStep.last()
|
||||
);
|
||||
await lastExecutionStepDetails.verifyActionExecutionStep({
|
||||
stepPositionAndName: '2. Webhook',
|
||||
stepDataInKey: 'body',
|
||||
stepDataInValue: 'body:"ok"',
|
||||
stepDataOutKey: 'body',
|
||||
stepDataOutValue: 'body:"ok"',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('displays data out', async ({ page, executionsPage }) => {
|
||||
const executionStepCount = await page.getByTestId('execution-step').count();
|
||||
for (let i = 0; i < executionStepCount; i++) {
|
||||
await page.getByTestId('data-out-tab').nth(i).click();
|
||||
await expect(page.getByTestId('data-out-panel').nth(i)).toBeVisible();
|
||||
|
||||
await executionsPage.screenshot({
|
||||
path: `Execution - data out - ${i}.png`,
|
||||
animations: 'disabled',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('does not display error', async ({ page }) => {
|
||||
await expect(page.getByTestId('error-tab')).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
@@ -1,17 +1,54 @@
|
||||
const { request } = require('@playwright/test');
|
||||
const { test, expect } = require('../../fixtures/index');
|
||||
const {
|
||||
triggerFlow,
|
||||
publishFlow,
|
||||
addWebhookFlow,
|
||||
} = require('../../helpers/flow-api-helper');
|
||||
const { getToken } = require('../../helpers/auth-api-helper');
|
||||
|
||||
test.describe('Executions page', () => {
|
||||
let flowId;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const apiRequest = await request.newContext();
|
||||
const tokenJsonResponse = await getToken(apiRequest);
|
||||
|
||||
flowId = await addWebhookFlow(apiRequest, tokenJsonResponse.data.token);
|
||||
|
||||
const { data } = await publishFlow(
|
||||
apiRequest,
|
||||
tokenJsonResponse.data.token,
|
||||
flowId
|
||||
);
|
||||
|
||||
const triggerStepWebhookUrl = data.steps.find(
|
||||
(step) => step.type === 'trigger'
|
||||
).webhookUrl;
|
||||
|
||||
await triggerFlow(apiRequest, triggerStepWebhookUrl);
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.getByTestId('executions-page-drawer-link').click();
|
||||
});
|
||||
|
||||
// no executions exist in an empty account
|
||||
test.skip('displays executions', async ({ page, executionsPage }) => {
|
||||
await page.getByTestId('executions-loader').waitFor({
|
||||
test('should be able to see normal and test executions', async ({
|
||||
executionsPage,
|
||||
}) => {
|
||||
await executionsPage.executionsPageLoader.waitFor({
|
||||
state: 'detached',
|
||||
});
|
||||
await expect(page.getByTestId('execution-row').first()).toBeVisible();
|
||||
const flowExecutions = await executionsPage.executionRow.filter({
|
||||
hasText: flowId,
|
||||
});
|
||||
|
||||
await executionsPage.screenshot({ path: 'Executions.png' });
|
||||
await expect(flowExecutions).toHaveCount(4);
|
||||
await expect(flowExecutions.first()).toContainText('Success');
|
||||
await expect(flowExecutions.first()).not.toContainText('Test run');
|
||||
for (let testFlow = 1; testFlow < 4; testFlow++) {
|
||||
await expect(flowExecutions.nth(testFlow)).toContainText('Test run');
|
||||
await expect(flowExecutions.nth(testFlow)).toContainText('Success');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
1099
packages/e2e-tests/yarn.lock
Normal file
1099
packages/e2e-tests/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"axios": "^1.6.0",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"compare-versions": "^4.1.3",
|
||||
"lodash": "^4.17.21",
|
||||
|
@@ -112,7 +112,7 @@ export default function ResetPasswordForm() {
|
||||
<Alert
|
||||
data-test="accept-invitation-form-error"
|
||||
severity="error"
|
||||
sx={{ mt: 1, fontWeight: 500 }}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
{formatMessage('acceptInvitationForm.invalidToken')}
|
||||
</Alert>
|
||||
|
@@ -126,7 +126,7 @@ function AddAppConnection(props) {
|
||||
</DialogTitle>
|
||||
|
||||
{authDocUrl && (
|
||||
<Alert severity="info" sx={{ fontWeight: 300 }}>
|
||||
<Alert severity="info">
|
||||
{formatMessage('addAppConnection.callToDocs', {
|
||||
appName: name,
|
||||
docsLink: generateExternalLink(authDocUrl),
|
||||
@@ -138,7 +138,7 @@ function AddAppConnection(props) {
|
||||
<Alert
|
||||
data-test="add-connection-error"
|
||||
severity="error"
|
||||
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
|
||||
sx={{ mt: 1, wordBreak: 'break-all' }}
|
||||
>
|
||||
{!errorDetails && errorMessage}
|
||||
{errorDetails && (
|
||||
|
@@ -32,10 +32,7 @@ function AdminApplicationAuthClientDialog(props) {
|
||||
<Dialog open={true} onClose={onClose}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mt: 1, fontWeight: 500, wordBreak: 'break-all' }}
|
||||
>
|
||||
<Alert severity="error" sx={{ mt: 1, wordBreak: 'break-all' }}>
|
||||
{error.message}
|
||||
</Alert>
|
||||
)}
|
||||
|
@@ -29,6 +29,8 @@ function ControlledAutocomplete(props) {
|
||||
options = [],
|
||||
dependsOn = [],
|
||||
showOptionValue,
|
||||
renderInput,
|
||||
showHelperText = true,
|
||||
...autocompleteProps
|
||||
} = props;
|
||||
let dependsOnValues = [];
|
||||
@@ -105,16 +107,18 @@ function ControlledAutocomplete(props) {
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
renderInput={(params) => renderInput(params, fieldState)}
|
||||
/>
|
||||
|
||||
<FormHelperText
|
||||
variant="outlined"
|
||||
error={Boolean(fieldState.isTouched && fieldState.error)}
|
||||
>
|
||||
{fieldState.isTouched
|
||||
? fieldState.error?.message || description
|
||||
: description}
|
||||
</FormHelperText>
|
||||
{showHelperText && (
|
||||
<FormHelperText
|
||||
variant="outlined"
|
||||
error={Boolean(fieldState.isTouched && fieldState.error)}
|
||||
>
|
||||
{fieldState.isTouched
|
||||
? fieldState.error?.message || description
|
||||
: description}
|
||||
</FormHelperText>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -132,6 +136,8 @@ ControlledAutocomplete.propTypes = {
|
||||
onBlur: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
options: PropTypes.array,
|
||||
renderInput: PropTypes.func.isRequired,
|
||||
showHelperText: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ControlledAutocomplete;
|
||||
|
@@ -6,7 +6,7 @@ import FormHelperText from '@mui/material/FormHelperText';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { ActionButtonsWrapper } from './style';
|
||||
import ClickAwayListener from '@mui/base/ClickAwayListener';
|
||||
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import { createEditor } from 'slate';
|
||||
import { Editable, ReactEditor } from 'slate-react';
|
||||
|
@@ -10,7 +10,7 @@ import { ExecutionPropType } from 'propTypes/propTypes';
|
||||
|
||||
function ExecutionName(props) {
|
||||
return (
|
||||
<Typography variant="h3" gutterBottom>
|
||||
<Typography data-test="execution-name" variant="h3" gutterBottom>
|
||||
{props.name}
|
||||
</Typography>
|
||||
);
|
||||
@@ -29,7 +29,7 @@ function ExecutionId(props) {
|
||||
);
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<Typography variant="body2">
|
||||
<Typography data-test="execution-id" variant="body2">
|
||||
{formatMessage('execution.id', { id })}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -47,7 +47,7 @@ function ExecutionDate(props) {
|
||||
<Tooltip
|
||||
title={createdAt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
|
||||
>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
<Typography data-test="execution-created-at" variant="body1" gutterBottom>
|
||||
{relativeCreatedAt}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
|
@@ -36,7 +36,11 @@ function ExecutionStepId(props) {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }} gridArea="id">
|
||||
<Typography variant="caption" fontWeight="bold">
|
||||
<Typography
|
||||
data-test="execution-step-id"
|
||||
variant="caption"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{formatMessage('executionStep.id', { id })}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -56,7 +60,11 @@ function ExecutionStepDate(props) {
|
||||
<Tooltip
|
||||
title={createdAt.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}
|
||||
>
|
||||
<Typography variant="caption" gutterBottom>
|
||||
<Typography
|
||||
data-test="execution-step-executed-at"
|
||||
variant="caption"
|
||||
gutterBottom
|
||||
>
|
||||
{formatMessage('executionStep.executedAt', {
|
||||
datetime: relativeCreatedAt,
|
||||
})}
|
||||
@@ -119,12 +127,12 @@ function ExecutionStep(props) {
|
||||
<ExecutionStepId id={executionStep.step.id} />
|
||||
|
||||
<Box flex="1" gridArea="step">
|
||||
<Typography variant="caption">
|
||||
<Typography data-test="step-type" variant="caption">
|
||||
{isTrigger && formatMessage('flowStep.triggerType')}
|
||||
{isAction && formatMessage('flowStep.actionType')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2">
|
||||
<Typography data-test="step-position-and-name" variant="body2">
|
||||
{step.position}. {app?.name}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -152,7 +160,7 @@ function ExecutionStep(props) {
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={activeTabIndex} index={0}>
|
||||
<TabPanel value={activeTabIndex} index={0} data-test="data-in-panel">
|
||||
<SearchableJSONViewer data={executionStep.dataIn} />
|
||||
</TabPanel>
|
||||
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import useForgotPassword from 'hooks/useForgotPassword';
|
||||
import Form from 'components/Form';
|
||||
@@ -12,25 +12,17 @@ import useFormatMessage from 'hooks/useFormatMessage';
|
||||
export default function ForgotPasswordForm() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const {
|
||||
mutateAsync: forgotPassword,
|
||||
mutate: forgotPassword,
|
||||
isPending: loading,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
} = useForgotPassword();
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
const { email } = values;
|
||||
try {
|
||||
await forgotPassword({
|
||||
email,
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(
|
||||
error?.message || formatMessage('forgotPasswordForm.error'),
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
const handleSubmit = ({ email }) => {
|
||||
forgotPassword({
|
||||
email,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -57,6 +49,16 @@ export default function ForgotPasswordForm() {
|
||||
margin="dense"
|
||||
autoComplete="username"
|
||||
/>
|
||||
{isError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error?.message || formatMessage('forgotPasswordForm.error')}
|
||||
</Alert>
|
||||
)}
|
||||
{isSuccess && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{formatMessage('forgotPasswordForm.instructionsSent')}
|
||||
</Alert>
|
||||
)}
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
@@ -68,14 +70,6 @@ export default function ForgotPasswordForm() {
|
||||
>
|
||||
{formatMessage('forgotPasswordForm.submit')}
|
||||
</LoadingButton>
|
||||
{isSuccess && (
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: (theme) => theme.palette.success.main }}
|
||||
>
|
||||
{formatMessage('forgotPasswordForm.instructionsSent')}
|
||||
</Typography>
|
||||
)}
|
||||
</Form>
|
||||
</Paper>
|
||||
);
|
||||
|
@@ -13,12 +13,14 @@ function Form(props) {
|
||||
resolver,
|
||||
render,
|
||||
mode = 'all',
|
||||
reValidateMode = 'onBlur',
|
||||
automaticValidation = true,
|
||||
...formProps
|
||||
} = props;
|
||||
|
||||
const methods = useForm({
|
||||
defaultValues,
|
||||
reValidateMode: 'onBlur',
|
||||
reValidateMode,
|
||||
resolver,
|
||||
mode,
|
||||
});
|
||||
@@ -30,7 +32,9 @@ function Form(props) {
|
||||
* For fields having `dependsOn` fields, we need to re-validate the form.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
methods.trigger();
|
||||
if (automaticValidation) {
|
||||
methods.trigger();
|
||||
}
|
||||
}, [methods.trigger, form]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -56,6 +60,8 @@ Form.propTypes = {
|
||||
render: PropTypes.func,
|
||||
resolver: PropTypes.func,
|
||||
mode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit', 'onTouched', 'all']),
|
||||
reValidateMode: PropTypes.oneOf(['onChange', 'onBlur', 'onSubmit']),
|
||||
automaticValidation: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Form;
|
||||
|
@@ -188,7 +188,7 @@ function InstallationForm() {
|
||||
)}
|
||||
/>
|
||||
{install.isSuccess && (
|
||||
<Alert data-test="success-alert" severity="success" sx={{ mt: 3, fontWeight: 500 }}>
|
||||
<Alert data-test="success-alert" severity="success" sx={{ mt: 3 }}>
|
||||
{formatMessage('installationForm.success', {
|
||||
link: (str) => (
|
||||
<Link
|
||||
|
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { useNavigate, Link as RouterLink } from 'react-router-dom';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Link from '@mui/material/Link';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import useAuthentication from 'hooks/useAuthentication';
|
||||
@@ -11,16 +12,18 @@ import Form from 'components/Form';
|
||||
import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useCreateAccessToken from 'hooks/useCreateAccessToken';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
|
||||
function LoginForm() {
|
||||
const isCloud = useCloud();
|
||||
const navigate = useNavigate();
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const authentication = useAuthentication();
|
||||
const { mutateAsync: createAccessToken, isPending: loading } =
|
||||
useCreateAccessToken();
|
||||
const {
|
||||
mutateAsync: createAccessToken,
|
||||
isPending: loading,
|
||||
error,
|
||||
isError,
|
||||
} = useCreateAccessToken();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (authentication.isAuthenticated) {
|
||||
@@ -37,11 +40,19 @@ function LoginForm() {
|
||||
});
|
||||
const { token } = data;
|
||||
authentication.updateToken(token);
|
||||
} catch (error) {
|
||||
enqueueSnackbar(error?.message || formatMessage('loginForm.error'), {
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const renderError = () => {
|
||||
const errors = error?.response?.data?.errors?.general || [
|
||||
error?.message || formatMessage('loginForm.error'),
|
||||
];
|
||||
|
||||
return errors.map((error) => (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -94,6 +105,8 @@ function LoginForm() {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{isError && renderError()}
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
@@ -0,0 +1,51 @@
|
||||
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;
|
@@ -25,7 +25,6 @@ function PermissionSettings(props) {
|
||||
subject,
|
||||
actions,
|
||||
conditions,
|
||||
defaultChecked,
|
||||
} = props;
|
||||
const formatMessage = useFormatMessage();
|
||||
const { getValues, resetField } = useFormContext();
|
||||
@@ -34,7 +33,7 @@ function PermissionSettings(props) {
|
||||
for (const action of actions) {
|
||||
for (const condition of conditions) {
|
||||
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
|
||||
resetField(fieldName);
|
||||
resetField(fieldName, { keepTouched: true });
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
@@ -45,7 +44,7 @@ function PermissionSettings(props) {
|
||||
for (const condition of conditions) {
|
||||
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
|
||||
const value = getValues(fieldName);
|
||||
resetField(fieldName, { defaultValue: value });
|
||||
resetField(fieldName, { defaultValue: value, keepTouched: true });
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
@@ -56,6 +55,7 @@ function PermissionSettings(props) {
|
||||
open={open}
|
||||
onClose={cancel}
|
||||
data-test={`${subject}-role-conditions-modal`}
|
||||
keepMounted
|
||||
>
|
||||
<DialogTitle>{formatMessage('permissionSettings.title')}</DialogTitle>
|
||||
|
||||
@@ -65,10 +65,10 @@ function PermissionSettings(props) {
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell component="th" />
|
||||
|
||||
{actions.map((action) => (
|
||||
<TableCell component="th" key={action.key}>
|
||||
<Typography
|
||||
component="div"
|
||||
variant="subtitle1"
|
||||
align="center"
|
||||
sx={{
|
||||
@@ -89,7 +89,7 @@ function PermissionSettings(props) {
|
||||
sx={{ '&:last-child td': { border: 0 } }}
|
||||
>
|
||||
<TableCell scope="row">
|
||||
<Typography variant="subtitle2">
|
||||
<Typography variant="subtitle2" component="div">
|
||||
{condition.label}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
@@ -99,14 +99,13 @@ function PermissionSettings(props) {
|
||||
key={`${action.key}.${condition.key}`}
|
||||
align="center"
|
||||
>
|
||||
<Typography variant="subtitle2">
|
||||
<Typography variant="subtitle2" component="div">
|
||||
{action.subjects.includes(subject) && (
|
||||
<ControlledCheckbox
|
||||
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
|
||||
dataTest={`${
|
||||
condition.key
|
||||
}-${action.key.toLowerCase()}-checkbox`}
|
||||
defaultValue={defaultChecked}
|
||||
disabled={
|
||||
getValues(
|
||||
`${fieldPrefix}.${action.key}.value`,
|
||||
@@ -144,7 +143,6 @@ PermissionSettings.propTypes = {
|
||||
fieldPrefix: PropTypes.string.isRequired,
|
||||
subject: PropTypes.string.isRequired,
|
||||
open: PropTypes.bool,
|
||||
defaultChecked: PropTypes.bool,
|
||||
actions: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
|
@@ -12,15 +12,15 @@ import TableRow from '@mui/material/TableRow';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import * as React from 'react';
|
||||
|
||||
import ControlledCheckbox from 'components/ControlledCheckbox';
|
||||
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
|
||||
import PermissionSettings from './PermissionSettings.ee';
|
||||
import PermissionCatalogFieldLoader from './PermissionCatalogFieldLoader';
|
||||
import ActionField from './ActionField';
|
||||
|
||||
const PermissionCatalogField = ({
|
||||
name = 'permissions',
|
||||
disabled = false,
|
||||
defaultChecked = false,
|
||||
syncIsCreator = false,
|
||||
}) => {
|
||||
const { data, isLoading: isPermissionCatalogLoading } =
|
||||
usePermissionCatalog();
|
||||
@@ -39,6 +39,7 @@ const PermissionCatalogField = ({
|
||||
{permissionCatalog?.actions.map((action) => (
|
||||
<TableCell component="th" key={action.key}>
|
||||
<Typography
|
||||
component="div"
|
||||
variant="subtitle1"
|
||||
align="center"
|
||||
sx={{
|
||||
@@ -62,20 +63,23 @@ const PermissionCatalogField = ({
|
||||
data-test={`${subject.key}-permission-row`}
|
||||
>
|
||||
<TableCell scope="row">
|
||||
<Typography variant="subtitle2">{subject.label}</Typography>
|
||||
<Typography variant="subtitle2" component="div">
|
||||
{subject.label}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
|
||||
{permissionCatalog?.actions.map((action) => (
|
||||
<TableCell key={`${subject.key}.${action.key}`} align="center">
|
||||
<Typography variant="subtitle2">
|
||||
<Typography variant="subtitle2" component="div">
|
||||
{action.subjects.includes(subject.key) && (
|
||||
<ControlledCheckbox
|
||||
<ActionField
|
||||
action={action}
|
||||
subject={subject}
|
||||
disabled={disabled}
|
||||
name={`${name}.${subject.key}.${action.key}.value`}
|
||||
dataTest={`${action.key.toLowerCase()}-checkbox`}
|
||||
name={name}
|
||||
syncIsCreator={syncIsCreator}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!action.subjects.includes(subject.key) && '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
@@ -100,7 +104,6 @@ const PermissionCatalogField = ({
|
||||
subject={subject.key}
|
||||
actions={permissionCatalog?.actions}
|
||||
conditions={permissionCatalog?.conditions}
|
||||
defaultChecked={defaultChecked}
|
||||
/>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
@@ -114,7 +117,7 @@ const PermissionCatalogField = ({
|
||||
PermissionCatalogField.propTypes = {
|
||||
name: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
defaultChecked: PropTypes.bool,
|
||||
syncIsCreator: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default PermissionCatalogField;
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ClickAwayListener from '@mui/base/ClickAwayListener';
|
||||
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
|
||||
import FormHelperText from '@mui/material/FormHelperText';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import * as React from 'react';
|
||||
|
@@ -2,6 +2,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
@@ -30,6 +31,8 @@ export default function ResetPasswordForm() {
|
||||
mutateAsync: resetPassword,
|
||||
isPending,
|
||||
isSuccess,
|
||||
error,
|
||||
isError,
|
||||
} = useResetPassword();
|
||||
const token = searchParams.get('token');
|
||||
|
||||
@@ -47,14 +50,23 @@ export default function ResetPasswordForm() {
|
||||
},
|
||||
});
|
||||
navigate(URLS.LOGIN);
|
||||
} catch (error) {
|
||||
enqueueSnackbar(
|
||||
error?.message || formatMessage('resetPasswordForm.error'),
|
||||
{
|
||||
variant: 'error',
|
||||
},
|
||||
);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const renderError = () => {
|
||||
if (!isError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errors = error?.response?.data?.errors?.general || [
|
||||
error?.message || formatMessage('resetPasswordForm.error'),
|
||||
];
|
||||
|
||||
return errors.map((error) => (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -96,7 +108,6 @@ export default function ResetPasswordForm() {
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label={formatMessage(
|
||||
'resetPasswordForm.confirmPasswordFieldLabel',
|
||||
@@ -117,7 +128,7 @@ export default function ResetPasswordForm() {
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
{renderError()}
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
@@ -7,7 +7,7 @@ import FormControl from '@mui/material/FormControl';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
|
||||
export default function SearchInput({ onChange }) {
|
||||
export default function SearchInput({ onChange, defaultValue = '' }) {
|
||||
const formatMessage = useFormatMessage();
|
||||
return (
|
||||
<FormControl variant="outlined" fullWidth>
|
||||
@@ -16,6 +16,7 @@ export default function SearchInput({ onChange }) {
|
||||
</InputLabel>
|
||||
|
||||
<OutlinedInput
|
||||
defaultValue={defaultValue}
|
||||
id="search-input"
|
||||
type="text"
|
||||
size="medium"
|
||||
@@ -34,4 +35,5 @@ export default function SearchInput({ onChange }) {
|
||||
|
||||
SearchInput.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
defaultValue: PropTypes.string,
|
||||
};
|
||||
|
@@ -7,9 +7,9 @@ function Variable({ attributes, children, element, disabled }) {
|
||||
const focused = useFocused();
|
||||
const label = (
|
||||
<>
|
||||
{children}
|
||||
<span style={{ fontWeight: 500 }}>{element.name}</span>:{' '}
|
||||
<span style={{ fontWeight: 300 }}>{element.sampleValue}</span>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
|
@@ -2,7 +2,7 @@ import PropTypes from 'prop-types';
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import Button from '@mui/material/Button';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
|
@@ -11,14 +11,17 @@ export default function SubscriptionCancelledAlert() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const subscription = useSubscription();
|
||||
const trial = useUserTrial();
|
||||
|
||||
if (subscription?.data?.status === 'active' || trial.hasTrial)
|
||||
return <React.Fragment />;
|
||||
|
||||
const cancellationEffectiveDateObject = DateTime.fromISO(
|
||||
subscription?.data?.cancellationEffectiveDate,
|
||||
);
|
||||
|
||||
if (
|
||||
subscription?.data?.status === 'active' ||
|
||||
trial.hasTrial ||
|
||||
!cancellationEffectiveDateObject.isValid
|
||||
)
|
||||
return <React.Fragment />;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="warning"
|
||||
|
@@ -84,10 +84,7 @@ function TestSubstep(props) {
|
||||
}}
|
||||
>
|
||||
{hasError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 2, fontWeight: 500, width: '100%' }}
|
||||
>
|
||||
<Alert severity="error" sx={{ mb: 2, width: '100%' }}>
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(errorDetails, null, 2)}
|
||||
</pre>
|
||||
@@ -104,13 +101,11 @@ function TestSubstep(props) {
|
||||
severity="warning"
|
||||
sx={{ mb: 1, width: '100%' }}
|
||||
>
|
||||
<AlertTitle sx={{ fontWeight: 700 }}>
|
||||
<AlertTitle>
|
||||
{formatMessage('flowEditor.noTestDataTitle')}
|
||||
</AlertTitle>
|
||||
|
||||
<Box sx={{ fontWeight: 400 }}>
|
||||
{formatMessage('flowEditor.noTestDataMessage')}
|
||||
</Box>
|
||||
<Box>{formatMessage('flowEditor.noTestDataMessage')}</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
@@ -31,6 +31,7 @@ function TextField(props) {
|
||||
onBlur,
|
||||
onChange,
|
||||
'data-test': dataTest,
|
||||
showError = false,
|
||||
...textFieldProps
|
||||
} = props;
|
||||
return (
|
||||
@@ -47,6 +48,7 @@ function TextField(props) {
|
||||
onBlur: controllerOnBlur,
|
||||
...field
|
||||
},
|
||||
fieldState: { error },
|
||||
}) => (
|
||||
<MuiTextField
|
||||
{...textFieldProps}
|
||||
@@ -72,6 +74,7 @@ function TextField(props) {
|
||||
inputProps={{
|
||||
'data-test': dataTest,
|
||||
}}
|
||||
{...(showError && { helperText: error?.message, error: !!error })}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -89,6 +92,7 @@ TextField.propTypes = {
|
||||
disabled: PropTypes.bool,
|
||||
onBlur: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
showError: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default TextField;
|
||||
|
@@ -44,7 +44,7 @@ function BillingCard(props) {
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" fontWeight="bold">
|
||||
{title}
|
||||
{title || '---'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
|
||||
@@ -119,12 +119,12 @@ export default function UsageDataInformation() {
|
||||
text: 'Upgrade plan',
|
||||
},
|
||||
nextBillAmount: {
|
||||
title: '---',
|
||||
title: null,
|
||||
action: null,
|
||||
text: null,
|
||||
},
|
||||
nextBillDate: {
|
||||
title: '---',
|
||||
title: null,
|
||||
action: null,
|
||||
text: null,
|
||||
},
|
||||
@@ -137,7 +137,9 @@ export default function UsageDataInformation() {
|
||||
text: formatMessage('usageDataInformation.cancelPlan'),
|
||||
},
|
||||
nextBillAmount: {
|
||||
title: `€${subscription?.nextBillAmount}`,
|
||||
title: subscription?.nextBillAmount
|
||||
? `€${subscription?.nextBillAmount}`
|
||||
: null,
|
||||
action: subscription?.updateUrl,
|
||||
text: formatMessage('usageDataInformation.updatePaymentMethod'),
|
||||
},
|
||||
|
@@ -45,3 +45,36 @@ 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;
|
||||
};
|
||||
|
@@ -5,6 +5,8 @@ import Stack from '@mui/material/Stack';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import { useMemo } from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import Form from 'components/Form';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
@@ -23,13 +25,49 @@ 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 }) {
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const {
|
||||
mutateAsync: updateSamlAuthProvidersRoleMappings,
|
||||
isPending: isUpdateSamlAuthProvidersRoleMappingsPending,
|
||||
mutateAsync: updateRoleMappings,
|
||||
isPending: isUpdateRoleMappingsPending,
|
||||
} = useAdminUpdateSamlAuthProviderRoleMappings(provider?.id);
|
||||
|
||||
const { data, isLoading: isAdminSamlAuthProviderRoleMappingsLoading } =
|
||||
@@ -41,7 +79,7 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
const handleRoleMappingsUpdate = async (values) => {
|
||||
try {
|
||||
if (provider?.id) {
|
||||
await updateSamlAuthProvidersRoleMappings(
|
||||
await updateRoleMappings(
|
||||
values.roleMappings.map(({ roleId, remoteRoleName }) => ({
|
||||
roleId,
|
||||
remoteRoleName,
|
||||
@@ -94,7 +132,15 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
<Typography variant="h3">
|
||||
{formatMessage('roleMappingsForm.title')}
|
||||
</Typography>
|
||||
<Form defaultValues={defaultValues} onSubmit={handleRoleMappingsUpdate}>
|
||||
<Form
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={handleRoleMappingsUpdate}
|
||||
resolver={yupResolver(getValidationSchema(formatMessage))}
|
||||
mode="onSubmit"
|
||||
reValidateMode="onChange"
|
||||
noValidate
|
||||
automaticValidation={false}
|
||||
>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<RoleMappingsFieldArray />
|
||||
<LoadingButton
|
||||
@@ -102,7 +148,7 @@ function RoleMappings({ provider, providerLoading }) {
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isUpdateSamlAuthProvidersRoleMappingsPending}
|
||||
loading={isUpdateRoleMappingsPending}
|
||||
>
|
||||
{formatMessage('roleMappingsForm.save')}
|
||||
</LoadingButton>
|
||||
|
@@ -55,6 +55,7 @@ function RoleMappingsFieldArray() {
|
||||
label={formatMessage('roleMappingsForm.remoteRoleName')}
|
||||
fullWidth
|
||||
required
|
||||
showError
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
name={`roleMappings.${index}.roleId`}
|
||||
@@ -62,14 +63,17 @@ function RoleMappingsFieldArray() {
|
||||
disablePortal
|
||||
disableClearable
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
renderInput={(params, { error }) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
label={formatMessage('roleMappingsForm.role')}
|
||||
required
|
||||
error={!!error}
|
||||
helperText={error?.message}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
showHelperText={false}
|
||||
/>
|
||||
</Stack>
|
||||
<IconButton
|
||||
|
@@ -11,9 +11,13 @@ import Form from 'components/Form';
|
||||
import PageTitle from 'components/PageTitle';
|
||||
import TextField from 'components/TextField';
|
||||
import * as URLS from 'config/urls';
|
||||
import { getPermissions } from 'helpers/computePermissions.ee';
|
||||
import {
|
||||
getComputedPermissionsDefaultValues,
|
||||
getPermissions,
|
||||
} from 'helpers/computePermissions.ee';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useAdminCreateRole from 'hooks/useAdminCreateRole';
|
||||
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
|
||||
|
||||
export default function CreateRole() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,6 +25,21 @@ export default function CreateRole() {
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const { mutateAsync: createRole, isPending: isCreateRolePending } =
|
||||
useAdminCreateRole();
|
||||
const { data: permissionCatalogData } = usePermissionCatalog();
|
||||
|
||||
const defaultValues = React.useMemo(
|
||||
() => ({
|
||||
name: '',
|
||||
description: '',
|
||||
computedPermissions: getComputedPermissionsDefaultValues(
|
||||
permissionCatalogData?.data,
|
||||
{
|
||||
isCreator: true,
|
||||
},
|
||||
),
|
||||
}),
|
||||
[permissionCatalogData],
|
||||
);
|
||||
|
||||
const handleRoleCreation = async (roleData) => {
|
||||
try {
|
||||
@@ -64,7 +83,7 @@ export default function CreateRole() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Form onSubmit={handleRoleCreation}>
|
||||
<Form onSubmit={handleRoleCreation} defaultValues={defaultValues}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
@@ -81,10 +100,7 @@ export default function CreateRole() {
|
||||
data-test="description-input"
|
||||
/>
|
||||
|
||||
<PermissionCatalogField
|
||||
name="computedPermissions"
|
||||
defaultChecked={true}
|
||||
/>
|
||||
<PermissionCatalogField name="computedPermissions" />
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
|
@@ -124,7 +124,6 @@ export default function CreateUser() {
|
||||
<Alert
|
||||
severity="info"
|
||||
color="primary"
|
||||
sx={{ fontWeight: '500' }}
|
||||
data-test="invitation-email-info-alert"
|
||||
>
|
||||
{formatMessage('createUser.invitationEmailInfo', {
|
||||
|
@@ -5,6 +5,7 @@ import Stack from '@mui/material/Stack';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { merge } from 'lodash';
|
||||
|
||||
import Container from 'components/Container';
|
||||
import Form from 'components/Form';
|
||||
@@ -13,21 +14,25 @@ import PermissionCatalogField from 'components/PermissionCatalogField/index.ee';
|
||||
import TextField from 'components/TextField';
|
||||
import * as URLS from 'config/urls';
|
||||
import {
|
||||
getComputedPermissionsDefaultValues,
|
||||
getPermissions,
|
||||
getRoleWithComputedPermissions,
|
||||
} from 'helpers/computePermissions.ee';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useAdminUpdateRole from 'hooks/useAdminUpdateRole';
|
||||
import useRole from 'hooks/useRole.ee';
|
||||
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
|
||||
|
||||
export default function EditRole() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const navigate = useNavigate();
|
||||
const { roleId } = useParams();
|
||||
const { data, loading: isRoleLoading } = useRole({ roleId });
|
||||
const { data: roleData, isLoading: isRoleLoading } = useRole({ roleId });
|
||||
const { mutateAsync: updateRole, isPending: isUpdateRolePending } =
|
||||
useAdminUpdateRole(roleId);
|
||||
const role = data?.data;
|
||||
const { data: permissionCatalogData } = usePermissionCatalog();
|
||||
const role = roleData?.data;
|
||||
const permissionCatalog = permissionCatalogData?.data;
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
|
||||
const handleRoleUpdate = async (roleData) => {
|
||||
@@ -52,7 +57,20 @@ export default function EditRole() {
|
||||
}
|
||||
};
|
||||
|
||||
const roleWithComputedPermissions = getRoleWithComputedPermissions(role);
|
||||
const defaultValues = React.useMemo(() => {
|
||||
const roleWithComputedPermissions = getRoleWithComputedPermissions(role);
|
||||
const computedPermissionsDefaultValues =
|
||||
getComputedPermissionsDefaultValues(permissionCatalog);
|
||||
|
||||
return {
|
||||
...roleWithComputedPermissions,
|
||||
computedPermissions: merge(
|
||||
{},
|
||||
computedPermissionsDefaultValues,
|
||||
roleWithComputedPermissions.computedPermissions,
|
||||
),
|
||||
};
|
||||
}, [role, permissionCatalog]);
|
||||
|
||||
return (
|
||||
<Container sx={{ py: 3, display: 'flex', justifyContent: 'center' }}>
|
||||
@@ -64,10 +82,7 @@ export default function EditRole() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Form
|
||||
defaultValues={roleWithComputedPermissions}
|
||||
onSubmit={handleRoleUpdate}
|
||||
>
|
||||
<Form defaultValues={defaultValues} onSubmit={handleRoleUpdate}>
|
||||
<Stack direction="column" gap={2}>
|
||||
{isRoleLoading && (
|
||||
<>
|
||||
@@ -95,12 +110,11 @@ export default function EditRole() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PermissionCatalogField
|
||||
name="computedPermissions"
|
||||
disabled={role?.isAdmin}
|
||||
syncIsCreator
|
||||
/>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
|
@@ -42,13 +42,9 @@ export default function Execution() {
|
||||
<Grid container item sx={{ mt: 2, mb: [2, 5] }} rowGap={3}>
|
||||
{!isExecutionStepsLoading && !data?.pages?.[0].data.length && (
|
||||
<Alert severity="warning" sx={{ flex: 1 }}>
|
||||
<AlertTitle sx={{ fontWeight: 700 }}>
|
||||
{formatMessage('execution.noDataTitle')}
|
||||
</AlertTitle>
|
||||
<AlertTitle>{formatMessage('execution.noDataTitle')}</AlertTitle>
|
||||
|
||||
<Box sx={{ fontWeight: 400 }}>
|
||||
{formatMessage('execution.noDataMessage')}
|
||||
</Box>
|
||||
<Box>{formatMessage('execution.noDataMessage')}</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import debounce from 'lodash/debounce';
|
||||
import Box from '@mui/material/Box';
|
||||
import Grid from '@mui/material/Grid';
|
||||
@@ -23,13 +23,18 @@ import useLazyFlows from 'hooks/useLazyFlows';
|
||||
|
||||
export default function Flows() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const page = parseInt(searchParams.get('page') || '', 10) || 1;
|
||||
const [flowName, setFlowName] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const flowName = searchParams.get('flowName') || '';
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
|
||||
const { data, mutate: fetchFlows } = useLazyFlows(
|
||||
const {
|
||||
data,
|
||||
mutate: fetchFlows,
|
||||
isSuccess,
|
||||
} = useLazyFlows(
|
||||
{ flowName, page },
|
||||
{
|
||||
onSettled: () => {
|
||||
@@ -38,6 +43,36 @@ 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(
|
||||
() => debounce(fetchFlows, 300),
|
||||
[fetchFlows],
|
||||
@@ -54,21 +89,14 @@ export default function Flows() {
|
||||
}, [fetchData, flowName, page]);
|
||||
|
||||
React.useEffect(
|
||||
function resetPageOnSearch() {
|
||||
// reset search params which only consists of `page`
|
||||
setSearchParams({});
|
||||
function redirectToLastPage() {
|
||||
if (navigateToLastPage) {
|
||||
navigate(getPathWithSearchParams(pageInfo.totalPages, flowName));
|
||||
}
|
||||
},
|
||||
[flowName],
|
||||
[navigateToLastPage],
|
||||
);
|
||||
|
||||
const flows = data?.data || [];
|
||||
const pageInfo = data?.meta;
|
||||
const hasFlows = flows?.length;
|
||||
|
||||
const onSearchChange = React.useCallback((event) => {
|
||||
setFlowName(event.target.value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ py: 3 }}>
|
||||
<Container>
|
||||
@@ -78,7 +106,7 @@ export default function Flows() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm="auto" order={{ xs: 2, sm: 1 }}>
|
||||
<SearchInput onChange={onSearchChange} />
|
||||
<SearchInput onChange={onSearchChange} defaultValue={flowName} />
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
@@ -111,7 +139,7 @@ export default function Flows() {
|
||||
</Grid>
|
||||
|
||||
<Divider sx={{ mt: [2, 0], mb: 2 }} />
|
||||
{isLoading && (
|
||||
{(isLoading || navigateToLastPage) && (
|
||||
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||
)}
|
||||
{!isLoading &&
|
||||
@@ -119,11 +147,11 @@ export default function Flows() {
|
||||
<FlowRow
|
||||
key={flow.id}
|
||||
flow={flow}
|
||||
onDuplicateFlow={fetchFlows}
|
||||
onDuplicateFlow={onDuplicateFlow}
|
||||
onDeleteFlow={fetchFlows}
|
||||
/>
|
||||
))}
|
||||
{!isLoading && !hasFlows && (
|
||||
{!isLoading && !navigateToLastPage && !hasFlows && (
|
||||
<NoResultFound
|
||||
text={formatMessage('flows.noFlows')}
|
||||
{...(currentUserAbility.can('create', 'Flow') && {
|
||||
@@ -131,23 +159,23 @@ export default function Flows() {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && pageInfo && pageInfo.totalPages > 1 && (
|
||||
<Pagination
|
||||
sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}
|
||||
page={pageInfo?.currentPage}
|
||||
count={pageInfo?.totalPages}
|
||||
onChange={(event, page) =>
|
||||
setSearchParams({ page: page.toString() })
|
||||
}
|
||||
renderItem={(item) => (
|
||||
<PaginationItem
|
||||
component={Link}
|
||||
to={`${item.page === 1 ? '' : `?page=${item.page}`}`}
|
||||
{...item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!navigateToLastPage &&
|
||||
pageInfo &&
|
||||
pageInfo.totalPages > 1 && (
|
||||
<Pagination
|
||||
sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}
|
||||
page={pageInfo?.currentPage}
|
||||
count={pageInfo?.totalPages}
|
||||
renderItem={(item) => (
|
||||
<PaginationItem
|
||||
component={Link}
|
||||
to={getPathWithSearchParams(item.page, flowName)}
|
||||
{...item}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
|
@@ -266,8 +266,8 @@ function ProfileSettings() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Alert variant="outlined" severity="error" sx={{ fontWeight: 500 }}>
|
||||
<AlertTitle sx={{ fontWeight: 700 }}>
|
||||
<Alert variant="outlined" severity="error">
|
||||
<AlertTitle>
|
||||
{formatMessage('profileSettings.deleteMyAccount')}
|
||||
</AlertTitle>
|
||||
|
||||
|
@@ -278,6 +278,20 @@ export const defaultTheme = createTheme({
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
fontWeight: theme.typography.fontWeightRegular,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiAlertTitle: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
fontWeight: theme.typography.fontWeightBold,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const mationTheme = createTheme(
|
||||
|
11069
packages/web/yarn.lock
Normal file
11069
packages/web/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user