Compare commits

..

68 Commits

Author SHA1 Message Date
Ali BARIN
7dee26c16d chore: allow custom seed user creds 2024-03-07 08:41:30 +00:00
Ali BARIN
3d6847a3a2 Merge pull request #1558 from automatisch/AUT-606
fix: remove unnecessery styling causing the problem with scrolling Edit/Create role pages
2024-03-07 00:38:20 +01:00
Ali BARIN
613abaec1b Merge pull request #1550 from automatisch/AUT-563
feat: render AdminSettingsLayout once for all child routes
2024-03-06 18:56:59 +01:00
Ömer Faruk Aydın
b51ae9ac38 Merge pull request #1698 from automatisch/rest-get-execution-steps
feat: Implement get execution steps API endpoint
2024-03-06 17:30:33 +01:00
Faruk AYDIN
c4fd03542b feat: Implement get execution steps API endpoint 2024-03-06 17:23:56 +01:00
Faruk AYDIN
e40d6c5ef0 feat: Implement execution step serializer 2024-03-06 17:23:56 +01:00
kasia.oczkowska
9f7dee3baa test: introuce test fix, select role by exact name 2024-03-06 16:28:17 +01:00
kasia.oczkowska
d3da62c04a feat: render AdminSettingsLayout once for all child routes 2024-03-06 16:28:17 +01:00
kasia.oczkowska
aa7bb3f8c9 fix: remove unnecessery styling causing the problem 2024-03-06 15:55:03 +01:00
Ali BARIN
3a9dfe339a Merge pull request #1689 from automatisch/fix-auth-in-rq
fix: initialize auth in RQ client
2024-03-06 15:52:17 +01:00
Ali BARIN
08830003a3 Merge pull request #1560 from automatisch/AUT-619
fix: add optional chaining to data.getTrialStatus check
2024-03-06 15:34:04 +01:00
kasia.oczkowska
efeeb6cb02 fix: add optional chaining to data.getTrialStatus check and introduce minor code improvements 2024-03-06 15:12:02 +01:00
Ömer Faruk Aydın
ac59ce2deb Merge pull request #1697 from automatisch/rest-get-executions
feat: Implement get executions API endpoint
2024-03-06 15:08:35 +01:00
Faruk AYDIN
3ff89a03ac feat: Implement get executions API endpoint 2024-03-06 14:46:19 +01:00
Faruk AYDIN
25e231cd7c feat: Extend execution serializer with status 2024-03-06 14:46:10 +01:00
Ömer Faruk Aydın
f4d8d909b0 Merge pull request #1695 from automatisch/sentry-enhancement
chore: Do not enable sentry for dev and test environments
2024-03-05 15:53:36 +01:00
Faruk AYDIN
35ea18a117 chore: Do not enable sentry for dev and test environments 2024-03-05 15:47:07 +01:00
Ömer Faruk Aydın
ee9433261b Merge pull request #1693 from automatisch/refactor-get-executions-tests
refactor: Add steps to get execution tests
2024-03-04 16:23:09 +01:00
Faruk AYDIN
1cb48c7760 refactor: Add steps to get execution tests 2024-03-04 16:14:11 +01:00
Ömer Faruk Aydın
172bf4bd51 Merge pull request #1692 from automatisch/remove-empty-associations
refactor: Remove empty associations from serializers
2024-03-04 16:14:00 +01:00
Faruk AYDIN
fe1039cfbc test: Add permissions to get current user tests 2024-03-04 16:07:18 +01:00
Faruk AYDIN
a9de79546b refactor: Remove empty associations from serializers 2024-03-04 16:06:50 +01:00
kasia.oczkowska
7afdf43872 feat: introduce propTypes 2024-03-04 15:21:07 +01:00
Rıdvan Akca
bfc7d5d0dd refactor(web): rewrite useAdminAppAuthClient with react-query (#1690) 2024-03-04 13:09:42 +01:00
Ömer Faruk Aydın
690832052a Merge pull request #1691 from automatisch/error-handler
feat: Send rest API errors to Sentry
2024-03-04 13:01:20 +01:00
Faruk AYDIN
f7c1a47d52 feat: Send rest API errors to Sentry 2024-03-04 12:19:53 +01:00
Ali BARIN
930843d065 fix: initialize auth in RQ client 2024-03-04 10:48:55 +00:00
Ömer Faruk Aydın
a9ee609502 Merge pull request #1688 from automatisch/rest-get-execution
feat: Implement get execution API endpoint
2024-03-03 19:06:10 +01:00
Faruk AYDIN
9fd2125923 test: Add tests for execution serializer 2024-03-03 18:58:54 +01:00
Faruk AYDIN
ede8703f9d feat: Implement get execution API endpoint 2024-03-03 18:53:14 +01:00
Faruk AYDIN
6d85623d9b feat: Add execution serializer 2024-03-03 18:52:32 +01:00
Faruk AYDIN
6236ee8f6d feat: Add authorizedExecutions to user model 2024-03-03 18:51:50 +01:00
Ömer Faruk Aydın
92665d80d6 Merge pull request #1687 from automatisch/get-action-substeps
feat: Implement get action substeps API endpoint
2024-03-02 16:30:43 +01:00
Faruk AYDIN
70ae0bc77e feat: Implement get action substeps API endpoint 2024-03-02 16:24:20 +01:00
Faruk AYDIN
e28c757352 feat: Add find action substeps method to App model 2024-03-02 16:24:20 +01:00
Ömer Faruk Aydın
7cdcf7ebab Merge pull request #1686 from automatisch/get-app-actions
feat: Implement get app actions API endpoint
2024-03-02 16:19:57 +01:00
Faruk AYDIN
7c368af5ed feat: Implement get app actions API endpoint 2024-03-02 16:13:13 +01:00
Faruk AYDIN
df2fbbabc6 feat: Implement action serializer 2024-03-02 16:12:51 +01:00
Faruk AYDIN
48141eb199 feat: Add find actions by key method to App model 2024-03-02 16:09:57 +01:00
Ömer Faruk Aydın
343fbb282c Merge pull request #1685 from automatisch/get-trigger-substeps
feat: Implement get trigger substeps API endpoint
2024-03-02 14:44:06 +01:00
Faruk AYDIN
cea9ed056b feat: Implement get trigger substeps API endpoint 2024-03-01 16:35:46 +01:00
Ali BARIN
73bd90c555 refactor(web): utilize REACT_APP_BACKEND_URL for GQL and REST API URLs 2024-03-01 15:14:22 +01:00
Ali BARIN
917de46c45 chore(web): build with API URL 2024-03-01 15:14:22 +01:00
Ali BARIN
b592092923 chore: update building web in e2e tests 2024-03-01 15:14:22 +01:00
Ali BARIN
760bc1c22a fix(useAutomatischInfo): pass signal correctly 2024-03-01 15:14:22 +01:00
Ali BARIN
1f8b81ee78 refactor: remove GetAutomatischInfo GQL query 2024-03-01 15:14:22 +01:00
Ali BARIN
70af7d05e3 refactor: remove AutomatischInfo context 2024-03-01 15:14:22 +01:00
Ali BARIN
14c04ee4ac refactor: rewrite useAutomatischInfo with RQ and REST API 2024-03-01 15:14:22 +01:00
Ali BARIN
83815d3caa feat: introduce react-query and react-query-devtools 2024-03-01 15:14:22 +01:00
Ali BARIN
487c621fa5 chore: update react and react-dom to 18 2024-03-01 15:14:22 +01:00
Ömer Faruk Aydın
304eab801c Merge pull request #1684 from automatisch/rest-get-triggers
feat: Implement get triggers API endpoint
2024-03-01 15:10:31 +01:00
Faruk AYDIN
dfe3add1cc feat: Implement get triggers API endpoint 2024-03-01 15:03:53 +01:00
Faruk AYDIN
a32bf5539e feat: Add find triggers by key method to App model 2024-03-01 15:02:56 +01:00
Faruk AYDIN
e944333e5f feat: Implement trigger serializer 2024-03-01 15:02:21 +01:00
Ömer Faruk Aydın
dad23a52b0 Merge pull request #1683 from automatisch/rest-get-apps
feat: Implement get apps API endpoint
2024-03-01 14:27:59 +01:00
Faruk AYDIN
53606c306d feat: Implement get apps API endpoint 2024-03-01 14:21:41 +01:00
Ömer Faruk Aydın
53b03f8231 Merge pull request #1680 from automatisch/available-apps
docs: Add datastore to available apps
2024-03-01 12:03:02 +01:00
Faruk AYDIN
ac5f6dc024 docs: Add datastore to available apps 2024-03-01 11:50:42 +01:00
Ali BARIN
2c15e0dd32 Merge pull request #1665 from automatisch/ts-removal-in-web
refactor(web): remove typescript
2024-02-29 11:43:07 +01:00
Ömer Faruk Aydın
007334fef0 Merge pull request #1671 from automatisch/helix-integration
feat: Use new API endpoint from Helix
2024-02-29 11:41:19 +01:00
Ali BARIN
b3ae2d2748 refactor(web): remove typescript 2024-02-29 09:38:32 +00:00
Ömer Faruk Aydın
7149c766d0 Merge pull request #1675 from automatisch/get-app-auth
feat: Implement get app auth API endpoint
2024-02-28 16:25:40 +01:00
Faruk AYDIN
5dca0191d2 feat: Implement get app auth API endpoint 2024-02-28 16:16:13 +01:00
Faruk AYDIN
356668a68d feat: Implement auth serializer 2024-02-28 16:16:13 +01:00
Faruk AYDIN
63c9442126 feat: Add find by auth method to App model 2024-02-28 16:16:13 +01:00
Faruk AYDIN
0031d7911d fix: Adjust description of app serializer test 2024-02-28 16:16:13 +01:00
Faruk AYDIN
31c92b43b4 feat: Add optional system prompt variable to Helix app 2024-02-28 14:18:55 +01:00
Faruk AYDIN
2667548041 feat: Introduce optional session ID for helix app 2024-02-28 13:44:04 +01:00
412 changed files with 4253 additions and 5283 deletions

View File

@@ -28,7 +28,7 @@ cd packages/web
rm -rf .env rm -rf .env
echo " echo "
PORT=$WEB_PORT PORT=$WEB_PORT
REACT_APP_GRAPHQL_URL=http://localhost:$BACKEND_PORT/graphql REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT
" >> .env " >> .env
cd $CURRENT_DIR cd $CURRENT_DIR

View File

@@ -1,18 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
],
overrides: [
{
files: ['**/*.test.ts', '**/test/**/*.ts'],
rules: {
'@typescript-eslint/ban-ts-comment': ['off'],
},
},
],
};

View File

@@ -22,7 +22,7 @@ jobs:
- run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner."
- run: echo "🖥️ The workflow is now ready to test your code on the runner." - run: echo "🖥️ The workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- run: yarn lint - run: cd packages/backend && yarn lint
- run: echo "🍏 This job's status is ${{ job.status }}." - run: echo "🍏 This job's status is ${{ job.status }}."
start-backend-server: start-backend-server:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -62,8 +62,9 @@ jobs:
run: yarn && yarn lerna bootstrap run: yarn && yarn lerna bootstrap
- name: Install Playwright Browsers - name: Install Playwright Browsers
run: yarn playwright install --with-deps run: yarn playwright install --with-deps
- name: Build Automatisch - name: Build Automatisch web
run: yarn lerna run --scope=@*/{web,cli} build working-directory: ./packages/web
run: yarn build
env: env:
# Keep this until we clean up warnings in build processes # Keep this until we clean up warnings in build processes
CI: false CI: false

View File

@@ -6,7 +6,7 @@ ENV PORT 3000
RUN \ RUN \
apk --no-cache add --virtual build-dependencies python3 build-base git apk --no-cache add --virtual build-dependencies python3 build-base git
RUN git clone -b helix-new-endpoint https://github.com/automatisch/automatisch.git RUN git clone https://github.com/automatisch/automatisch.git
WORKDIR /automatisch WORKDIR /automatisch

View File

@@ -6,7 +6,6 @@
"start": "lerna run --stream --parallel --scope=@*/{web,backend} dev", "start": "lerna run --stream --parallel --scope=@*/{web,backend} dev",
"start:web": "lerna run --stream --scope=@*/web dev", "start:web": "lerna run --stream --scope=@*/web dev",
"start:backend": "lerna run --stream --scope=@*/backend dev", "start:backend": "lerna run --stream --scope=@*/backend dev",
"lint": "lerna run --no-bail --stream --parallel --scope=@*/{web,backend} lint",
"build:docs": "cd ./packages/docs && yarn install && yarn build" "build:docs": "cd ./packages/docs && yarn install && yarn build"
}, },
"workspaces": { "workspaces": {
@@ -21,8 +20,6 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.9.1",
"@typescript-eslint/parser": "^5.9.1",
"eslint": "^8.13.0", "eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",

View File

@@ -18,8 +18,8 @@ async function fetchAdminRole() {
} }
export async function createUser( export async function createUser(
email = 'user@automatisch.io', email = appConfig.seedUserEmail,
password = 'sample' password = appConfig.seedUserPassword
) { ) {
const UNIQUE_VIOLATION_CODE = '23505'; const UNIQUE_VIOLATION_CODE = '23505';

View File

@@ -11,7 +11,7 @@
"start:worker": "node src/worker.js", "start:worker": "node src/worker.js",
"pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js", "pretest": "APP_ENV=test node ./test/setup/prepare-test-env.js",
"test": "APP_ENV=test vitest run", "test": "APP_ENV=test vitest run",
"lint": "eslint . --ignore-path ../../.eslintignore", "lint": "eslint .",
"db:create": "node ./bin/database/create.js", "db:create": "node ./bin/database/create.js",
"db:seed:user": "node ./bin/database/seed-user.js", "db:seed:user": "node ./bin/database/seed-user.js",
"db:drop": "node ./bin/database/drop.js", "db:drop": "node ./bin/database/drop.js",
@@ -95,7 +95,6 @@
"url": "https://github.com/automatisch/automatisch/issues" "url": "https://github.com/automatisch/automatisch/issues"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/utils": "^7.0.2",
"nodemon": "^2.0.13", "nodemon": "^2.0.13",
"supertest": "^6.3.3", "supertest": "^6.3.3",
"vitest": "^1.1.3" "vitest": "^1.1.3"

View File

@@ -5,19 +5,38 @@ export default defineAction({
key: 'newChat', key: 'newChat',
description: 'Create a new chat session for Helix AI.', description: 'Create a new chat session for Helix AI.',
arguments: [ arguments: [
{
label: 'Session ID',
key: 'sessionId',
type: 'string',
required: false,
description:
'ID of the chat session to continue. Leave empty to start a new chat.',
variables: true,
},
{
label: 'System Prompt',
key: 'systemPrompt',
type: 'string',
required: false,
description:
'Optional system prompt to start the chat with. It will be used only for new chat sessions.',
variables: true,
},
{ {
label: 'Input', label: 'Input',
key: 'input', key: 'input',
type: 'string', type: 'string',
required: true, required: true,
description: 'Prompt to start the chat with.', description: 'User input to start the chat with.',
variables: true, variables: true,
}, },
], ],
async run($) { async run($) {
const response = await $.http.post('/api/v1/sessions/chat', { const response = await $.http.post('/api/v1/sessions/chat', {
session_id: '', session_id: $.step.parameters.sessionId,
system: $.step.parameters.systemPrompt,
messages: [ messages: [
{ {
role: 'user', role: 'user',

View File

@@ -94,6 +94,8 @@ const appConfig = {
disableFavicon: process.env.DISABLE_FAVICON === 'true', disableFavicon: process.env.DISABLE_FAVICON === 'true',
additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK, additionalDrawerLink: process.env.ADDITIONAL_DRAWER_LINK,
additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT, additionalDrawerLinkText: process.env.ADDITIONAL_DRAWER_LINK_TEXT,
seedUserEmail: process.env.SEED_USER_EMAIL || 'user@automatisch.io',
seedUserPassword: process.env.SEED_USER_PASSWORD || 'sample',
}; };
if (!appConfig.encryptionKey) { if (!appConfig.encryptionKey) {

View File

@@ -0,0 +1,11 @@
import App from '../../../../models/app.js';
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const substeps = await App.findActionSubsteps(
request.params.appKey,
request.params.actionKey
);
renderObject(response, substeps);
};

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import App from '../../../../models/app';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import getActionSubstepsMock from '../../../../../test/mocks/rest/api/v1/apps/get-action-substeps.js';
describe('GET /api/v1/apps/:appKey/actions/:actionKey/substeps', () => {
let currentUser, exampleApp, token;
beforeEach(async () => {
currentUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
exampleApp = await App.findOneByKey('github');
});
it('should return the app auth info', async () => {
const actions = await App.findActionsByKey('github');
const exampleAction = actions.find(
(action) => action.key === 'createIssue'
);
const endpointUrl = `/api/v1/apps/${exampleApp.key}/actions/${exampleAction.key}/substeps`;
const response = await request(app)
.get(endpointUrl)
.set('Authorization', token)
.expect(200);
const expectedPayload = getActionSubstepsMock(exampleAction.substeps);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for invalid app key', async () => {
await request(app)
.get('/api/v1/apps/invalid-app-key/actions/invalid-actions-key/substeps')
.set('Authorization', token)
.expect(404);
});
it('should return empty array for invalid action key', async () => {
const endpointUrl = `/api/v1/apps/${exampleApp.key}/actions/invalid-action-key/substeps`;
const response = await request(app)
.get(endpointUrl)
.set('Authorization', token)
.expect(200);
expect(response.body.data).toEqual([]);
});
});

View File

@@ -0,0 +1,8 @@
import App from '../../../../models/app.js';
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const actions = await App.findActionsByKey(request.params.appKey);
renderObject(response, actions, { serializer: 'Action' });
};

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import App from '../../../../models/app';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import getActionsMock from '../../../../../test/mocks/rest/api/v1/apps/get-actions.js';
describe('GET /api/v1/apps/:appKey/actions', () => {
let currentUser, token;
beforeEach(async () => {
currentUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the app actions', async () => {
const exampleApp = await App.findOneByKey('github');
const response = await request(app)
.get(`/api/v1/apps/${exampleApp.key}/actions`)
.set('Authorization', token)
.expect(200);
const expectedPayload = getActionsMock(exampleApp.actions);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for invalid app key', async () => {
await request(app)
.get('/api/v1/apps/invalid-app-key/actions')
.set('Authorization', token)
.expect(404);
});
});

View File

@@ -0,0 +1,16 @@
import App from '../../../../models/app.js';
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
let apps = await App.findAll(request.query.name);
if (request.query.onlyWithTriggers) {
apps = apps.filter((app) => app.triggers?.length);
}
if (request.query.onlyWithActions) {
apps = apps.filter((app) => app.actions?.length);
}
renderObject(response, apps, { serializer: 'App' });
};

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import App from '../../../../models/app';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import getAppsMock from '../../../../../test/mocks/rest/api/v1/apps/get-apps.js';
describe('GET /api/v1/apps', () => {
let currentUser, apps, token;
beforeEach(async () => {
currentUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
apps = await App.findAll();
});
it('should return all apps', async () => {
const response = await request(app)
.get('/api/v1/apps')
.set('Authorization', token)
.expect(200);
const expectedPayload = getAppsMock(apps);
expect(response.body).toEqual(expectedPayload);
});
it('should return all apps filtered by name', async () => {
const appsWithNameGit = apps.filter((app) => app.name.includes('Git'));
const response = await request(app)
.get('/api/v1/apps?name=Git')
.set('Authorization', token)
.expect(200);
const expectedPayload = getAppsMock(appsWithNameGit);
expect(response.body).toEqual(expectedPayload);
});
it('should return only the apps with triggers', async () => {
const appsWithTriggers = apps.filter((app) => app.triggers?.length > 0);
const response = await request(app)
.get('/api/v1/apps?onlyWithTriggers=true')
.set('Authorization', token)
.expect(200);
const expectedPayload = getAppsMock(appsWithTriggers);
expect(response.body).toEqual(expectedPayload);
});
it('should return only the apps with actions', async () => {
const appsWithActions = apps.filter((app) => app.actions?.length > 0);
const response = await request(app)
.get('/api/v1/apps?onlyWithActions=true')
.set('Authorization', token)
.expect(200);
const expectedPayload = getAppsMock(appsWithActions);
expect(response.body).toEqual(expectedPayload);
});
});

View File

@@ -0,0 +1,8 @@
import App from '../../../../models/app.js';
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const auth = await App.findAuthByKey(request.params.appKey);
renderObject(response, auth, { serializer: 'Auth' });
};

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import App from '../../../../models/app';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import getAuthMock from '../../../../../test/mocks/rest/api/v1/apps/get-auth.js';
describe('GET /api/v1/apps/:appKey/auth', () => {
let currentUser, token;
beforeEach(async () => {
currentUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the app auth info', async () => {
const exampleApp = await App.findOneByKey('github');
const response = await request(app)
.get(`/api/v1/apps/${exampleApp.key}/auth`)
.set('Authorization', token)
.expect(200);
const expectedPayload = getAuthMock(exampleApp.auth);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for invalid app key', async () => {
await request(app)
.get('/api/v1/apps/invalid-app-key/auth')
.set('Authorization', token)
.expect(404);
});
});

View File

@@ -0,0 +1,11 @@
import App from '../../../../models/app.js';
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const substeps = await App.findTriggerSubsteps(
request.params.appKey,
request.params.triggerKey
);
renderObject(response, substeps);
};

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import App from '../../../../models/app';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import getTriggerSubstepsMock from '../../../../../test/mocks/rest/api/v1/apps/get-trigger-substeps.js';
describe('GET /api/v1/apps/:appKey/triggers/:triggerKey/substeps', () => {
let currentUser, exampleApp, token;
beforeEach(async () => {
currentUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
exampleApp = await App.findOneByKey('github');
});
it('should return the app auth info', async () => {
const triggers = await App.findTriggersByKey('github');
const exampleTrigger = triggers.find(
(trigger) => trigger.key === 'newIssues'
);
const endpointUrl = `/api/v1/apps/${exampleApp.key}/triggers/${exampleTrigger.key}/substeps`;
const response = await request(app)
.get(endpointUrl)
.set('Authorization', token)
.expect(200);
const expectedPayload = getTriggerSubstepsMock(exampleTrigger.substeps);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for invalid app key', async () => {
await request(app)
.get('/api/v1/apps/invalid-app-key/triggers/invalid-trigger-key/substeps')
.set('Authorization', token)
.expect(404);
});
it('should return empty array for invalid trigger key', async () => {
const endpointUrl = `/api/v1/apps/${exampleApp.key}/triggers/invalid-trigger-key/substeps`;
const response = await request(app)
.get(endpointUrl)
.set('Authorization', token)
.expect(200);
expect(response.body.data).toEqual([]);
});
});

View File

@@ -0,0 +1,8 @@
import App from '../../../../models/app.js';
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const triggers = await App.findTriggersByKey(request.params.appKey);
renderObject(response, triggers, { serializer: 'Trigger' });
};

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import App from '../../../../models/app';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import getTriggersMock from '../../../../../test/mocks/rest/api/v1/apps/get-triggers.js';
describe('GET /api/v1/apps/:appKey/triggers', () => {
let currentUser, token;
beforeEach(async () => {
currentUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the app triggers', async () => {
const exampleApp = await App.findOneByKey('github');
const response = await request(app)
.get(`/api/v1/apps/${exampleApp.key}/triggers`)
.set('Authorization', token)
.expect(200);
const expectedPayload = getTriggersMock(exampleApp.triggers);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for invalid app key', async () => {
await request(app)
.get('/api/v1/apps/invalid-app-key/triggers')
.set('Authorization', token)
.expect(404);
});
});

View File

@@ -0,0 +1,23 @@
import { renderObject } from '../../../../helpers/renderer.js';
import paginateRest from '../../../../helpers/pagination-rest.js';
export default async (request, response) => {
const execution = await request.currentUser.authorizedExecutions
.clone()
.withSoftDeleted()
.findById(request.params.executionId)
.throwIfNotFound();
const executionStepsQuery = execution
.$relatedQuery('executionSteps')
.withSoftDeleted()
.withGraphFetched('step')
.orderBy('created_at', 'asc');
const executionSteps = await paginateRest(
executionStepsQuery,
request.query.page
);
renderObject(response, executionSteps);
};

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import Crypto from 'crypto';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
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';
import { createPermission } from '../../../../../test/factories/permission';
import getExecutionStepsMock from '../../../../../test/mocks/rest/api/v1/executions/get-execution-steps';
describe('GET /api/v1/executions/:executionId/execution-steps', () => {
let currentUser, currentUserRole, anotherUser, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
anotherUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the execution steps of current user execution', async () => {
const currentUserFlow = await createFlow({
userId: currentUser.id,
});
const stepOne = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
});
const stepTwo = await createStep({
flowId: currentUserFlow.id,
type: 'action',
});
const currentUserExecution = await createExecution({
flowId: currentUserFlow.id,
});
const currentUserExecutionStepOne = await createExecutionStep({
executionId: currentUserExecution.id,
stepId: stepOne.id,
});
const currentUserExecutionStepTwo = await createExecutionStep({
executionId: currentUserExecution.id,
stepId: stepTwo.id,
});
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.get(`/api/v1/executions/${currentUserExecution.id}/execution-steps`)
.set('Authorization', token)
.expect(200);
const expectedPayload = await getExecutionStepsMock(
[currentUserExecutionStepOne, currentUserExecutionStepTwo],
[stepOne, stepTwo]
);
expect(response.body).toEqual(expectedPayload);
});
it('should return the execution steps of another user execution', async () => {
const anotherUserFlow = await createFlow({
userId: anotherUser.id,
});
const stepOne = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
});
const stepTwo = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
});
const anotherUserExecution = await createExecution({
flowId: anotherUserFlow.id,
});
const anotherUserExecutionStepOne = await createExecutionStep({
executionId: anotherUserExecution.id,
stepId: stepOne.id,
});
const anotherUserExecutionStepTwo = await createExecutionStep({
executionId: anotherUserExecution.id,
stepId: stepTwo.id,
});
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.get(`/api/v1/executions/${anotherUserExecution.id}/execution-steps`)
.set('Authorization', token)
.expect(200);
const expectedPayload = await getExecutionStepsMock(
[anotherUserExecutionStepOne, anotherUserExecutionStepTwo],
[stepOne, stepTwo]
);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for not existing execution step UUID', async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
const notExistingExcecutionUUID = Crypto.randomUUID();
await request(app)
.get(`/api/v1/executions/${notExistingExcecutionUUID}/execution-steps`)
.set('Authorization', token)
.expect(404);
});
it('should return bad request response for invalid UUID', async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
await request(app)
.get('/api/v1/executions/invalidExecutionUUID/execution-steps')
.set('Authorization', token)
.expect(400);
});
});

View File

@@ -0,0 +1,15 @@
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const execution = await request.currentUser.authorizedExecutions
.withGraphFetched({
flow: {
steps: true,
},
})
.withSoftDeleted()
.findById(request.params.executionId)
.throwIfNotFound();
renderObject(response, execution);
};

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import Crypto from 'crypto';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import { createFlow } from '../../../../../test/factories/flow.js';
import { createStep } from '../../../../../test/factories/step.js';
import { createExecution } from '../../../../../test/factories/execution.js';
import { createPermission } from '../../../../../test/factories/permission';
import getExecutionMock from '../../../../../test/mocks/rest/api/v1/executions/get-execution';
describe('GET /api/v1/executions/:executionId', () => {
let currentUser, currentUserRole, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the execution data of current user', async () => {
const currentUserFlow = await createFlow({
userId: currentUser.id,
});
const stepOne = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
});
const stepTwo = await createStep({
flowId: currentUserFlow.id,
type: 'action',
});
const currentUserExecution = await createExecution({
flowId: currentUserFlow.id,
});
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.get(`/api/v1/executions/${currentUserExecution.id}`)
.set('Authorization', token)
.expect(200);
const expectedPayload = await getExecutionMock(
currentUserExecution,
currentUserFlow,
[stepOne, stepTwo]
);
expect(response.body).toEqual(expectedPayload);
});
it('should return the execution data of another user', async () => {
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({
userId: anotherUser.id,
});
const stepOne = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
});
const stepTwo = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
});
const anotherUserExecution = await createExecution({
flowId: anotherUserFlow.id,
});
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.get(`/api/v1/executions/${anotherUserExecution.id}`)
.set('Authorization', token)
.expect(200);
const expectedPayload = await getExecutionMock(
anotherUserExecution,
anotherUserFlow,
[stepOne, stepTwo]
);
expect(response.body).toEqual(expectedPayload);
});
it('should return not found response for not existing execution UUID', async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
const notExistingExcecutionUUID = Crypto.randomUUID();
await request(app)
.get(`/api/v1/executions/${notExistingExcecutionUUID}`)
.set('Authorization', token)
.expect(404);
});
it('should return bad request response for invalid UUID', async () => {
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
await request(app)
.get('/api/v1/executions/invalidExecutionUUID')
.set('Authorization', token)
.expect(400);
});
});

View File

@@ -0,0 +1,26 @@
import { renderObject } from '../../../../helpers/renderer.js';
import paginateRest from '../../../../helpers/pagination-rest.js';
export default async (request, response) => {
const executionsQuery = request.currentUser.authorizedExecutions
.withSoftDeleted()
.orderBy('created_at', 'desc')
.withGraphFetched({
flow: {
steps: true,
},
});
const executions = await paginateRest(executionsQuery, request.query.page);
for (const execution of executions.records) {
const executionSteps = await execution.$relatedQuery('executionSteps');
const status = executionSteps.some((step) => step.status === 'failure')
? 'failure'
: 'success';
execution.status = status;
}
renderObject(response, executions);
};

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createUser } from '../../../../../test/factories/user';
import { createFlow } from '../../../../../test/factories/flow.js';
import { createStep } from '../../../../../test/factories/step.js';
import { createExecution } from '../../../../../test/factories/execution.js';
import { createPermission } from '../../../../../test/factories/permission';
import getExecutionsMock from '../../../../../test/mocks/rest/api/v1/executions/get-executions';
describe('GET /api/v1/executions', () => {
let currentUser, currentUserRole, anotherUser, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
anotherUser = await createUser();
token = createAuthTokenByUserId(currentUser.id);
});
it('should return the executions of current user', async () => {
const currentUserFlow = await createFlow({
userId: currentUser.id,
});
const stepOne = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
});
const stepTwo = await createStep({
flowId: currentUserFlow.id,
type: 'action',
});
const currentUserExecutionOne = await createExecution({
flowId: currentUserFlow.id,
});
const currentUserExecutionTwo = await createExecution({
flowId: currentUserFlow.id,
deletedAt: new Date().toISOString(),
});
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.get('/api/v1/executions')
.set('Authorization', token)
.expect(200);
const expectedPayload = await getExecutionsMock(
[currentUserExecutionTwo, currentUserExecutionOne],
currentUserFlow,
[stepOne, stepTwo]
);
expect(response.body).toEqual(expectedPayload);
});
it('should return the executions of another user', async () => {
const anotherUserFlow = await createFlow({
userId: anotherUser.id,
});
const stepOne = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
});
const stepTwo = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
});
const anotherUserExecutionOne = await createExecution({
flowId: anotherUserFlow.id,
});
const anotherUserExecutionTwo = await createExecution({
flowId: anotherUserFlow.id,
deletedAt: new Date().toISOString(),
});
await createPermission({
action: 'read',
subject: 'Execution',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.get('/api/v1/executions')
.set('Authorization', token)
.expect(200);
const expectedPayload = await getExecutionsMock(
[anotherUserExecutionTwo, anotherUserExecutionOne],
anotherUserFlow,
[stepOne, stepTwo]
);
expect(response.body).toEqual(expectedPayload);
});
});

View File

@@ -2,15 +2,29 @@ import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest'; import request from 'supertest';
import app from '../../../../app.js'; import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id'; import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id';
import { createPermission } from '../../../../../test/factories/permission';
import { createRole } from '../../../../../test/factories/role';
import { createUser } from '../../../../../test/factories/user'; import { createUser } from '../../../../../test/factories/user';
import getCurrentUserMock from '../../../../../test/mocks/rest/api/v1/users/get-current-user'; import getCurrentUserMock from '../../../../../test/mocks/rest/api/v1/users/get-current-user';
describe('GET /api/v1/users/me', () => { describe('GET /api/v1/users/me', () => {
let role, currentUser, token; let role, permissionOne, permissionTwo, currentUser, token;
beforeEach(async () => { beforeEach(async () => {
currentUser = await createUser(); role = await createRole();
role = await currentUser.$relatedQuery('role');
permissionOne = await createPermission({
roleId: role.id,
});
permissionTwo = await createPermission({
roleId: role.id,
});
currentUser = await createUser({
roleId: role.id,
});
token = createAuthTokenByUserId(currentUser.id); token = createAuthTokenByUserId(currentUser.id);
}); });
@@ -20,7 +34,11 @@ describe('GET /api/v1/users/me', () => {
.set('Authorization', token) .set('Authorization', token)
.expect(200); .expect(200);
const expectedPayload = getCurrentUserMock(currentUser, role); const expectedPayload = getCurrentUserMock(currentUser, role, [
permissionOne,
permissionTwo,
]);
expect(response.body).toEqual(expectedPayload); expect(response.body).toEqual(expectedPayload);
}); });
}); });

View File

@@ -1,21 +0,0 @@
import appConfig from '../../config/app.js';
import { getLicense } from '../../helpers/license.ee.js';
const getAutomatischInfo = async () => {
const license = await getLicense();
const computedLicense = {
id: license ? license.id : null,
name: license ? license.name : null,
expireAt: license ? license.expireAt : null,
verified: license ? true : false,
};
return {
isCloud: appConfig.isCloud,
isMation: appConfig.isMation,
license: computedLicense,
};
};
export default getAutomatischInfo;

View File

@@ -1,190 +0,0 @@
import { vi, describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import app from '../../app';
import * as license from '../../helpers/license.ee';
import appConfig from '../../config/app';
describe('graphQL getAutomatischInfo query', () => {
const query = `
query {
getAutomatischInfo {
isCloud
isMation
license {
id
name
expireAt
verified
}
}
}
`;
describe('and without valid license', () => {
beforeEach(async () => {
vi.spyOn(license, 'getLicense').mockResolvedValue(false);
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(false);
});
it('should return empty license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isCloud: false,
isMation: false,
license: {
id: null,
name: null,
expireAt: null,
verified: false,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with valid license', () => {
beforeEach(async () => {
const mockedLicense = {
id: '123123',
name: 'Test License',
expireAt: '2025-08-09T10:56:54.144Z',
verified: true,
};
vi.spyOn(license, 'getLicense').mockResolvedValue(mockedLicense);
});
describe('and with cloud flag enabled', () => {
beforeEach(async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(true);
});
it('should return all license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isCloud: true,
isMation: false,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
name: 'Test License',
verified: true,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with cloud flag disabled', () => {
beforeEach(async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
});
it('should return all license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isCloud: false,
isMation: false,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
name: 'Test License',
verified: true,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with mation flag enabled', () => {
beforeEach(async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(true);
});
it('should return all license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isCloud: false,
isMation: true,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
name: 'Test License',
verified: true,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
describe('and with mation flag disabled', () => {
beforeEach(async () => {
vi.spyOn(appConfig, 'isCloud', 'get').mockReturnValue(false);
vi.spyOn(appConfig, 'isMation', 'get').mockReturnValue(false);
});
it('should return all license data', async () => {
const response = await request(app)
.post('/graphql')
.send({ query })
.expect(200);
const expectedResponsePayload = {
data: {
getAutomatischInfo: {
isMation: false,
isCloud: false,
license: {
expireAt: '2025-08-09T10:56:54.144Z',
id: '123123',
name: 'Test License',
verified: true,
},
},
},
};
expect(response.body).toEqual(expectedResponsePayload);
});
});
});
});

View File

@@ -3,7 +3,6 @@ import getAppAuthClient from './queries/get-app-auth-client.ee.js';
import getAppAuthClients from './queries/get-app-auth-clients.ee.js'; import getAppAuthClients from './queries/get-app-auth-clients.ee.js';
import getAppConfig from './queries/get-app-config.ee.js'; import getAppConfig from './queries/get-app-config.ee.js';
import getApps from './queries/get-apps.js'; import getApps from './queries/get-apps.js';
import getAutomatischInfo from './queries/get-automatisch-info.js';
import getBillingAndUsage from './queries/get-billing-and-usage.ee.js'; import getBillingAndUsage from './queries/get-billing-and-usage.ee.js';
import getConfig from './queries/get-config.ee.js'; import getConfig from './queries/get-config.ee.js';
import getConnectedApps from './queries/get-connected-apps.js'; import getConnectedApps from './queries/get-connected-apps.js';
@@ -39,7 +38,6 @@ const queryResolvers = {
getAppAuthClients, getAppAuthClients,
getAppConfig, getAppConfig,
getApps, getApps,
getAutomatischInfo,
getBillingAndUsage, getBillingAndUsage,
getConfig, getConfig,
getConnectedApps, getConnectedApps,

View File

@@ -40,7 +40,6 @@ type Query {
key: String! key: String!
parameters: JSONObject parameters: JSONObject
): [SubstepArgument] ): [SubstepArgument]
getAutomatischInfo: GetAutomatischInfo
getBillingAndUsage: GetBillingAndUsage getBillingAndUsage: GetBillingAndUsage
getCurrentUser: User getCurrentUser: User
getConfig(keys: [String]): JSONObject getConfig(keys: [String]): JSONObject
@@ -644,12 +643,6 @@ type AppHealth {
version: String version: String
} }
type GetAutomatischInfo {
isCloud: Boolean
isMation: Boolean
license: License
}
type License { type License {
id: String id: String
name: String name: String

View File

@@ -42,7 +42,6 @@ const isAuthenticatedRule = rule()(isAuthenticated);
export const authenticationRules = { export const authenticationRules = {
Query: { Query: {
'*': isAuthenticatedRule, '*': isAuthenticatedRule,
getAutomatischInfo: allow,
getConfig: allow, getConfig: allow,
getNotifications: allow, getNotifications: allow,
healthcheck: allow, healthcheck: allow,

View File

@@ -11,6 +11,18 @@ const authorizationList = {
action: 'read', action: 'read',
subject: 'Flow', subject: 'Flow',
}, },
'GET /api/v1/executions/:executionId': {
action: 'read',
subject: 'Execution',
},
'GET /api/v1/executions/': {
action: 'read',
subject: 'Execution',
},
'GET /api/v1/executions/:executionId/execution-steps': {
action: 'read',
subject: 'Execution',
},
}; };
export const authorizeUser = async (request, response, next) => { export const authorizeUser = async (request, response, next) => {

View File

@@ -1,5 +1,6 @@
import logger from './logger.js'; import logger from './logger.js';
import objection from 'objection'; import objection from 'objection';
import * as Sentry from './sentry.ee.js';
const { NotFoundError, DataError } = objection; const { NotFoundError, DataError } = objection;
// Do not remove `next` argument as the function signature will not fit for an error handler middleware // Do not remove `next` argument as the function signature will not fit for an error handler middleware
@@ -17,8 +18,21 @@ const errorHandler = (error, request, response, next) => {
response.status(400).end(); response.status(400).end();
} }
logger.error(error.message + '\n' + error.stack); const statusCode = error.statusCode || 500;
response.status(error.statusCode || 500).end();
logger.error(request.method + ' ' + request.url + ' ' + statusCode);
logger.error(error.stack);
Sentry.captureException(error, {
tags: { rest: true },
extra: {
url: request?.url,
method: request?.method,
params: request?.params,
},
});
response.status(statusCode).end();
}; };
const notFoundAppError = (error) => { const notFoundAppError = (error) => {

View File

@@ -3,10 +3,13 @@ import * as Tracing from '@sentry/tracing';
import appConfig from '../config/app.js'; import appConfig from '../config/app.js';
const isSentryEnabled = !!appConfig.sentryDsn; const isSentryEnabled = () => {
if (appConfig.isDev || appConfig.isTest) return false;
return !!appConfig.sentryDsn;
};
export function init(app) { export function init(app) {
if (!isSentryEnabled) return; if (!isSentryEnabled()) return;
return Sentry.init({ return Sentry.init({
enabled: !!appConfig.sentryDsn, enabled: !!appConfig.sentryDsn,
@@ -21,19 +24,19 @@ export function init(app) {
} }
export function attachRequestHandler(app) { export function attachRequestHandler(app) {
if (!isSentryEnabled) return; if (!isSentryEnabled()) return;
app.use(Sentry.Handlers.requestHandler()); app.use(Sentry.Handlers.requestHandler());
} }
export function attachTracingHandler(app) { export function attachTracingHandler(app) {
if (!isSentryEnabled) return; if (!isSentryEnabled()) return;
app.use(Sentry.Handlers.tracingHandler()); app.use(Sentry.Handlers.tracingHandler());
} }
export function attachErrorHandler(app) { export function attachErrorHandler(app) {
if (!isSentryEnabled) return; if (!isSentryEnabled()) return;
app.use( app.use(
Sentry.Handlers.errorHandler({ Sentry.Handlers.errorHandler({
@@ -46,7 +49,7 @@ export function attachErrorHandler(app) {
} }
export function captureException(exception, captureContext) { export function captureException(exception, captureContext) {
if (!isSentryEnabled) return; if (!isSentryEnabled()) return;
return Sentry.captureException(exception, captureContext); return Sentry.captureException(exception, captureContext);
} }

View File

@@ -39,6 +39,47 @@ class App {
return appInfoConverter(rawAppData); return appInfoConverter(rawAppData);
} }
static async findAuthByKey(key, stripFuncs = false) {
const rawAppData = await getApp(key, stripFuncs);
const appData = appInfoConverter(rawAppData);
return appData?.auth || {};
}
static async findTriggersByKey(key, stripFuncs = false) {
const rawAppData = await getApp(key, stripFuncs);
const appData = appInfoConverter(rawAppData);
return appData?.triggers || [];
}
static async findTriggerSubsteps(appKey, triggerKey, stripFuncs = false) {
const rawAppData = await getApp(appKey, stripFuncs);
const appData = appInfoConverter(rawAppData);
const trigger = appData?.triggers?.find(
(trigger) => trigger.key === triggerKey
);
return trigger?.substeps || [];
}
static async findActionsByKey(key, stripFuncs = false) {
const rawAppData = await getApp(key, stripFuncs);
const appData = appInfoConverter(rawAppData);
return appData?.actions || [];
}
static async findActionSubsteps(appKey, actionKey, stripFuncs = false) {
const rawAppData = await getApp(appKey, stripFuncs);
const appData = appInfoConverter(rawAppData);
const action = appData?.actions?.find((action) => action.key === actionKey);
return action?.substeps || [];
}
static async checkAppAndAction(appKey, actionKey) { static async checkAppAndAction(appKey, actionKey) {
const app = await this.findOneByKey(appKey); const app = await this.findOneByKey(appKey);

View File

@@ -149,6 +149,13 @@ class User extends Base {
return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query(); return conditions.isCreator ? this.$relatedQuery('flows') : Flow.query();
} }
get authorizedExecutions() {
const conditions = this.can('read', 'Execution');
return conditions.isCreator
? this.$relatedQuery('executions')
: Execution.query();
}
login(password) { login(password) {
return bcrypt.compare(password, this.password); return bcrypt.compare(password, this.password);
} }

View File

@@ -2,9 +2,41 @@ import { Router } from 'express';
import asyncHandler from 'express-async-handler'; import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.js'; import { authenticateUser } from '../../../helpers/authentication.js';
import getAppAction from '../../../controllers/api/v1/apps/get-app.js'; import getAppAction from '../../../controllers/api/v1/apps/get-app.js';
import getAppsAction from '../../../controllers/api/v1/apps/get-apps.js';
import getAuthAction from '../../../controllers/api/v1/apps/get-auth.js';
import getTriggersAction from '../../../controllers/api/v1/apps/get-triggers.js';
import getTriggerSubstepsAction from '../../../controllers/api/v1/apps/get-trigger-substeps.js';
import getActionsAction from '../../../controllers/api/v1/apps/get-actions.js';
import getActionSubstepsAction from '../../../controllers/api/v1/apps/get-action-substeps.js';
const router = Router(); const router = Router();
router.get('/', authenticateUser, asyncHandler(getAppsAction));
router.get('/:appKey', authenticateUser, asyncHandler(getAppAction)); router.get('/:appKey', authenticateUser, asyncHandler(getAppAction));
router.get('/:appKey/auth', authenticateUser, asyncHandler(getAuthAction));
router.get(
'/:appKey/triggers',
authenticateUser,
asyncHandler(getTriggersAction)
);
router.get(
'/:appKey/triggers/:triggerKey/substeps',
authenticateUser,
asyncHandler(getTriggerSubstepsAction)
);
router.get(
'/:appKey/actions',
authenticateUser,
asyncHandler(getActionsAction)
);
router.get(
'/:appKey/actions/:actionKey/substeps',
authenticateUser,
asyncHandler(getActionSubstepsAction)
);
export default router; export default router;

View File

@@ -0,0 +1,32 @@
import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.js';
import { authorizeUser } from '../../../helpers/authorization.js';
import getExecutionsAction from '../../../controllers/api/v1/executions/get-executions.js';
import getExecutionAction from '../../../controllers/api/v1/executions/get-execution.js';
import getExecutionStepsAction from '../../../controllers/api/v1/executions/get-execution-steps.js';
const router = Router();
router.get(
'/',
authenticateUser,
authorizeUser,
asyncHandler(getExecutionsAction)
);
router.get(
'/:executionId',
authenticateUser,
authorizeUser,
asyncHandler(getExecutionAction)
);
router.get(
'/:executionId/execution-steps',
authenticateUser,
authorizeUser,
asyncHandler(getExecutionStepsAction)
);
export default router;

View File

@@ -9,6 +9,7 @@ import paymentRouter from './api/v1/payment.ee.js';
import appAuthClientsRouter from './api/v1/app-auth-clients.js'; import appAuthClientsRouter from './api/v1/app-auth-clients.js';
import flowsRouter from './api/v1/flows.js'; import flowsRouter from './api/v1/flows.js';
import appsRouter from './api/v1/apps.js'; import appsRouter from './api/v1/apps.js';
import executionsRouter from './api/v1/executions.js';
import samlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.js'; import samlAuthProvidersRouter from './api/v1/admin/saml-auth-providers.ee.js';
import rolesRouter from './api/v1/admin/roles.ee.js'; import rolesRouter from './api/v1/admin/roles.ee.js';
import permissionsRouter from './api/v1/admin/permissions.ee.js'; import permissionsRouter from './api/v1/admin/permissions.ee.js';
@@ -27,6 +28,7 @@ router.use('/api/v1/payment', paymentRouter);
router.use('/api/v1/app-auth-clients', appAuthClientsRouter); router.use('/api/v1/app-auth-clients', appAuthClientsRouter);
router.use('/api/v1/flows', flowsRouter); router.use('/api/v1/flows', flowsRouter);
router.use('/api/v1/apps', appsRouter); router.use('/api/v1/apps', appsRouter);
router.use('/api/v1/executions', executionsRouter);
router.use('/api/v1/admin/saml-auth-providers', samlAuthProvidersRouter); router.use('/api/v1/admin/saml-auth-providers', samlAuthProvidersRouter);
router.use('/api/v1/admin/roles', rolesRouter); router.use('/api/v1/admin/roles', rolesRouter);
router.use('/api/v1/admin/permissions', permissionsRouter); router.use('/api/v1/admin/permissions', permissionsRouter);

View File

@@ -0,0 +1,9 @@
const actionSerializer = (action) => {
return {
name: action.name,
key: action.key,
description: action.description,
};
};
export default actionSerializer;

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import App from '../models/app';
import actionSerializer from './action';
describe('actionSerializer', () => {
it('should return the action data', async () => {
const actions = await App.findActionsByKey('github');
const action = actions[0];
const expectedPayload = {
description: action.description,
key: action.key,
name: action.name,
pollInterval: action.pollInterval,
showWebhookUrl: action.showWebhookUrl,
type: action.type,
};
expect(actionSerializer(action)).toEqual(expectedPayload);
});
});

View File

@@ -3,7 +3,7 @@ import App from '../models/app';
import appSerializer from './app'; import appSerializer from './app';
describe('appSerializer', () => { describe('appSerializer', () => {
it('should return permission data', async () => { it('should return app data', async () => {
const app = await App.findOneByKey('deepl'); const app = await App.findOneByKey('deepl');
const expectedPayload = { const expectedPayload = {

View File

@@ -0,0 +1,9 @@
const authSerializer = (auth) => {
return {
fields: auth.fields,
authenticationSteps: auth.authenticationSteps,
reconnectionSteps: auth.reconnectionSteps,
};
};
export default authSerializer;

View File

@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import App from '../models/app';
import authSerializer from './auth';
describe('authSerializer', () => {
it('should return auth data', async () => {
const auth = await App.findAuthByKey('deepl');
const expectedPayload = {
fields: auth.fields,
authenticationSteps: auth.authenticationSteps,
reconnectionSteps: auth.reconnectionSteps,
};
expect(authSerializer(auth)).toEqual(expectedPayload);
});
});

View File

@@ -0,0 +1,21 @@
import stepSerializer from './step.js';
const executionStepSerializer = (executionStep) => {
let executionStepData = {
id: executionStep.id,
dataIn: executionStep.dataIn,
dataOut: executionStep.dataOut,
errorDetails: executionStep.errorDetails,
status: executionStep.status,
createdAt: executionStep.createdAt.getTime(),
updatedAt: executionStep.updatedAt.getTime(),
};
if (executionStep.step) {
executionStepData.step = stepSerializer(executionStep.step);
}
return executionStepData;
};
export default executionStepSerializer;

View File

@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach } from 'vitest';
import executionStepSerializer from './execution-step';
import stepSerializer from './step';
import { createExecutionStep } from '../../test/factories/execution-step';
import { createStep } from '../../test/factories/step';
describe('executionStepSerializer', () => {
let executionStep, step;
beforeEach(async () => {
step = await createStep();
executionStep = await createExecutionStep({
stepId: step.id,
});
});
it('should return the execution step data', async () => {
const expectedPayload = {
id: executionStep.id,
dataIn: executionStep.dataIn,
dataOut: executionStep.dataOut,
errorDetails: executionStep.errorDetails,
status: executionStep.status,
createdAt: executionStep.createdAt.getTime(),
updatedAt: executionStep.updatedAt.getTime(),
};
expect(executionStepSerializer(executionStep)).toEqual(expectedPayload);
});
it('should return the execution step data with the step', async () => {
executionStep.step = step;
const expectedPayload = {
step: stepSerializer(step),
};
expect(executionStepSerializer(executionStep)).toMatchObject(
expectedPayload
);
});
});

View File

@@ -0,0 +1,22 @@
import flowSerializer from './flow.js';
const executionSerializer = (execution) => {
let executionData = {
id: execution.id,
testRun: execution.testRun,
createdAt: execution.createdAt.getTime(),
updatedAt: execution.updatedAt.getTime(),
};
if (execution.status) {
executionData.status = execution.status;
}
if (execution.flow) {
executionData.flow = flowSerializer(execution.flow);
}
return executionData;
};
export default executionSerializer;

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import executionSerializer from './execution';
import flowSerializer from './flow';
import { createExecution } from '../../test/factories/execution';
import { createFlow } from '../../test/factories/flow';
describe('executionSerializer', () => {
let flow, execution;
beforeEach(async () => {
flow = await createFlow();
execution = await createExecution({
flowId: flow.id,
});
});
it('should return the execution data', async () => {
const expectedPayload = {
id: execution.id,
testRun: execution.testRun,
createdAt: execution.createdAt.getTime(),
updatedAt: execution.updatedAt.getTime(),
};
expect(executionSerializer(execution)).toEqual(expectedPayload);
});
it('should return the execution data with status', async () => {
execution.status = 'success';
const expectedPayload = {
id: execution.id,
testRun: execution.testRun,
createdAt: execution.createdAt.getTime(),
updatedAt: execution.updatedAt.getTime(),
status: 'success',
};
expect(executionSerializer(execution)).toEqual(expectedPayload);
});
it('should return the execution data with the flow', async () => {
execution.flow = flow;
const expectedPayload = {
flow: flowSerializer(flow),
};
expect(executionSerializer(execution)).toMatchObject(expectedPayload);
});
});

View File

@@ -8,7 +8,7 @@ const flowSerializer = (flow) => {
status: flow.status, status: flow.status,
}; };
if (flow.steps) { if (flow.steps?.length > 0) {
flowData.steps = flow.steps.map((step) => stepSerializer(step)); flowData.steps = flow.steps.map((step) => stepSerializer(step));
} }

View File

@@ -6,6 +6,11 @@ import appAuthClientSerializer from './app-auth-client.js';
import flowSerializer from './flow.js'; import flowSerializer from './flow.js';
import stepSerializer from './step.js'; import stepSerializer from './step.js';
import appSerializer from './app.js'; import appSerializer from './app.js';
import authSerializer from './auth.js';
import triggerSerializer from './trigger.js';
import actionSerializer from './action.js';
import executionSerializer from './execution.js';
import executionStepSerializer from './execution-step.js';
const serializers = { const serializers = {
User: userSerializer, User: userSerializer,
@@ -16,6 +21,11 @@ const serializers = {
Flow: flowSerializer, Flow: flowSerializer,
Step: stepSerializer, Step: stepSerializer,
App: appSerializer, App: appSerializer,
Auth: authSerializer,
Trigger: triggerSerializer,
Action: actionSerializer,
Execution: executionSerializer,
ExecutionStep: executionStepSerializer,
}; };
export default serializers; export default serializers;

View File

@@ -11,7 +11,7 @@ const roleSerializer = (role) => {
isAdmin: role.isAdmin, isAdmin: role.isAdmin,
}; };
if (role.permissions) { if (role.permissions?.length > 0) {
roleData.permissions = role.permissions.map((permission) => roleData.permissions = role.permissions.map((permission) =>
permissionSerializer(permission) permissionSerializer(permission)
); );

View File

@@ -0,0 +1,12 @@
const triggerSerializer = (trigger) => {
return {
description: trigger.description,
key: trigger.key,
name: trigger.name,
pollInterval: trigger.pollInterval,
showWebhookUrl: trigger.showWebhookUrl,
type: trigger.type,
};
};
export default triggerSerializer;

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import App from '../models/app';
import triggerSerializer from './trigger';
describe('triggerSerializer', () => {
it('should return the trigger data', async () => {
const triggers = await App.findTriggersByKey('github');
const trigger = triggers[0];
const expectedPayload = {
description: trigger.description,
key: trigger.key,
name: trigger.name,
pollInterval: trigger.pollInterval,
showWebhookUrl: trigger.showWebhookUrl,
type: trigger.type,
};
expect(triggerSerializer(trigger)).toEqual(expectedPayload);
});
});

View File

@@ -15,7 +15,7 @@ const userSerializer = (user) => {
userData.role = roleSerializer(user.role); userData.role = roleSerializer(user.role);
} }
if (user.permissions) { if (user.permissions?.length > 0) {
userData.permissions = user.permissions.map((permission) => userData.permissions = user.permissions.map((permission) =>
permissionSerializer(permission) permissionSerializer(permission)
); );

View File

@@ -0,0 +1,14 @@
const getActionSubstepsMock = (substeps) => {
return {
data: substeps,
meta: {
count: substeps.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Object',
},
};
};
export default getActionSubstepsMock;

View File

@@ -0,0 +1,22 @@
const getActionsMock = (actions) => {
const actionsData = actions.map((trigger) => {
return {
name: trigger.name,
key: trigger.key,
description: trigger.description,
};
});
return {
data: actionsData,
meta: {
count: actions.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Object',
},
};
};
export default getActionsMock;

View File

@@ -0,0 +1,23 @@
const getAppsMock = (apps) => {
const appsData = apps.map((app) => ({
authDocUrl: app.authDocUrl,
iconUrl: app.iconUrl,
key: app.key,
name: app.name,
primaryColor: app.primaryColor,
supportsConnections: app.supportsConnections,
}));
return {
data: appsData,
meta: {
count: appsData.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Object',
},
};
};
export default getAppsMock;

View File

@@ -0,0 +1,18 @@
const getAuthMock = (auth) => {
return {
data: {
fields: auth.fields,
authenticationSteps: auth.authenticationSteps,
reconnectionSteps: auth.reconnectionSteps,
},
meta: {
count: 1,
currentPage: null,
isArray: false,
totalPages: null,
type: 'Object',
},
};
};
export default getAuthMock;

View File

@@ -0,0 +1,14 @@
const getTriggerSubstepsMock = (substeps) => {
return {
data: substeps,
meta: {
count: substeps.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Object',
},
};
};
export default getTriggerSubstepsMock;

View File

@@ -0,0 +1,25 @@
const getTriggersMock = (triggers) => {
const triggersData = triggers.map((trigger) => {
return {
description: trigger.description,
key: trigger.key,
name: trigger.name,
pollInterval: trigger.pollInterval,
showWebhookUrl: trigger.showWebhookUrl,
type: trigger.type,
};
});
return {
data: triggersData,
meta: {
count: triggers.length,
currentPage: null,
isArray: true,
totalPages: null,
type: 'Object',
},
};
};
export default getTriggersMock;

View File

@@ -0,0 +1,39 @@
const getExecutionStepsMock = async (executionSteps, steps) => {
const data = executionSteps.map((executionStep) => {
const step = steps.find((step) => step.id === executionStep.stepId);
return {
id: executionStep.id,
dataIn: executionStep.dataIn,
dataOut: executionStep.dataOut,
errorDetails: executionStep.errorDetails,
status: executionStep.status,
createdAt: executionStep.createdAt.getTime(),
updatedAt: executionStep.updatedAt.getTime(),
step: {
id: step.id,
type: step.type,
key: step.key,
appKey: step.appKey,
iconUrl: step.iconUrl,
webhookUrl: step.webhookUrl,
status: step.status,
position: step.position,
parameters: step.parameters,
},
};
});
return {
data: data,
meta: {
count: executionSteps.length,
currentPage: 1,
isArray: true,
totalPages: 1,
type: 'ExecutionStep',
},
};
};
export default getExecutionStepsMock;

View File

@@ -0,0 +1,38 @@
const getExecutionMock = async (execution, flow, steps) => {
const data = {
id: execution.id,
testRun: execution.testRun,
createdAt: execution.createdAt.getTime(),
updatedAt: execution.updatedAt.getTime(),
flow: {
id: flow.id,
name: flow.name,
active: flow.active,
status: flow.active ? 'published' : 'draft',
steps: steps.map((step) => ({
id: step.id,
type: step.type,
key: step.key,
appKey: step.appKey,
iconUrl: step.iconUrl,
webhookUrl: step.webhookUrl,
status: step.status,
position: step.position,
parameters: step.parameters,
})),
},
};
return {
data: data,
meta: {
count: 1,
currentPage: null,
isArray: false,
totalPages: null,
type: 'Execution',
},
};
};
export default getExecutionMock;

View File

@@ -0,0 +1,39 @@
const getExecutionsMock = async (executions, flow, steps) => {
const data = executions.map((execution) => ({
id: execution.id,
testRun: execution.testRun,
createdAt: execution.createdAt.getTime(),
updatedAt: execution.updatedAt.getTime(),
status: 'success',
flow: {
id: flow.id,
name: flow.name,
active: flow.active,
status: flow.active ? 'published' : 'draft',
steps: steps.map((step) => ({
id: step.id,
type: step.type,
key: step.key,
appKey: step.appKey,
iconUrl: step.iconUrl,
webhookUrl: step.webhookUrl,
status: step.status,
position: step.position,
parameters: step.parameters,
})),
},
}));
return {
data: data,
meta: {
count: executions.length,
currentPage: 1,
isArray: true,
totalPages: 1,
type: 'Execution',
},
};
};
export default getExecutionsMock;

View File

@@ -1,11 +1,19 @@
const getCurrentUserMock = (currentUser, role) => { const getCurrentUserMock = (currentUser, role, permissions) => {
return { return {
data: { data: {
createdAt: currentUser.createdAt.getTime(), createdAt: currentUser.createdAt.getTime(),
email: currentUser.email, email: currentUser.email,
fullName: currentUser.fullName, fullName: currentUser.fullName,
id: currentUser.id, id: currentUser.id,
permissions: [], permissions: permissions.map((permission) => ({
id: permission.id,
roleId: permission.roleId,
action: permission.action,
subject: permission.subject,
conditions: permission.conditions,
createdAt: permission.createdAt.getTime(),
updatedAt: permission.updatedAt.getTime(),
})),
role: { role: {
createdAt: role.createdAt.getTime(), createdAt: role.createdAt.getTime(),
description: null, description: null,

View File

@@ -3,6 +3,7 @@
The following integrations are currently supported by Automatisch. The following integrations are currently supported by Automatisch.
- [Carbone](/apps/carbone/actions) - [Carbone](/apps/carbone/actions)
- [Datastore](/apps/datastore/actions)
- [DeepL](/apps/deepl/actions) - [DeepL](/apps/deepl/actions)
- [Delay](/apps/delay/actions) - [Delay](/apps/delay/actions)
- [Discord](/apps/discord/actions) - [Discord](/apps/discord/actions)

View File

@@ -31,7 +31,7 @@ export class AdminRolesPage extends AuthenticatedPage {
await this.roleDrawerLink.click(); await this.roleDrawerLink.click();
await this.isMounted(); await this.isMounted();
await this.rolesLoader.waitFor({ await this.rolesLoader.waitFor({
state: 'detached' state: 'detached',
}); });
} }
@@ -43,9 +43,7 @@ export class AdminRolesPage extends AuthenticatedPage {
state: 'detached', state: 'detached',
}); });
return this.roleRow.filter({ return this.roleRow.filter({
has: this.page.getByTestId('role-name').filter({ has: this.page.getByTestId('role-name').getByText(name, { exact: true }),
hasText: name,
}),
}); });
} }

View File

@@ -28,8 +28,6 @@
"@playwright/test": "^1.36.2" "@playwright/test": "^1.36.2"
}, },
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "^5.9.1",
"@typescript-eslint/parser": "^5.9.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.13.0", "eslint": "^8.13.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",

View File

@@ -197,7 +197,7 @@ test.describe('Role management page', () => {
await adminCreateUserPage.passwordInput.fill('sample'); await adminCreateUserPage.passwordInput.fill('sample');
await adminCreateUserPage.roleInput.click(); await adminCreateUserPage.roleInput.click();
await adminCreateUserPage.page await adminCreateUserPage.page
.getByRole('option', { name: 'Delete Role' }) .getByRole('option', { name: 'Delete Role', exact: true })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await adminUsersPage.snackbar.waitFor({ await adminUsersPage.snackbar.waitFor({

View File

@@ -1,4 +1,4 @@
PORT=3001 PORT=3001
REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql REACT_APP_BACKEND_URL=http://localhost:3000
# HTTPS=true # HTTPS=true
REACT_APP_BASE_URL=http://localhost:3001 REACT_APP_BASE_URL=http://localhost:3001

View File

@@ -0,0 +1,4 @@
node_modules
build
source
.eslintrc.js

10
packages/web/.eslintrc.js Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
extends: [
'react-app',
'plugin:@tanstack/eslint-plugin-query/recommended',
'prettier',
],
rules: {
'react/prop-types': 'warn',
},
};

View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}

View File

@@ -13,17 +13,10 @@
"@mui/icons-material": "^5.11.9", "@mui/icons-material": "^5.11.9",
"@mui/lab": "^5.0.0-alpha.120", "@mui/lab": "^5.0.0-alpha.120",
"@mui/material": "^5.11.10", "@mui/material": "^5.11.10",
"@tanstack/react-query": "^5.24.1",
"@testing-library/jest-dom": "^5.11.4", "@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/lodash": "^4.14.182",
"@types/luxon": "^2.0.8",
"@types/node": "^12.0.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"@types/react-window": "^1.8.5",
"@types/uuid": "^9.0.0",
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"compare-versions": "^4.1.3", "compare-versions": "^4.1.3",
"graphql": "^15.6.0", "graphql": "^15.6.0",
@@ -31,8 +24,8 @@
"luxon": "^2.3.1", "luxon": "^2.3.1",
"mui-color-input": "^2.0.0", "mui-color-input": "^2.0.0",
"notistack": "^3.0.1", "notistack": "^3.0.1",
"react": "^17.0.2", "react": "^18.2.0",
"react-dom": "^17.0.2", "react-dom": "^18.2.0",
"react-hook-form": "^7.45.2", "react-hook-form": "^7.45.2",
"react-intl": "^5.20.12", "react-intl": "^5.20.12",
"react-json-tree": "^0.16.2", "react-json-tree": "^0.16.2",
@@ -42,7 +35,6 @@
"slate": "^0.94.1", "slate": "^0.94.1",
"slate-history": "^0.93.0", "slate-history": "^0.93.0",
"slate-react": "^0.94.2", "slate-react": "^0.94.2",
"typescript": "^4.6.3",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"web-vitals": "^1.0.1", "web-vitals": "^1.0.1",
"yup": "^0.32.11" "yup": "^0.32.11"
@@ -54,8 +46,8 @@
"build:watch": "yarn nodemon --exec react-scripts build --watch 'src/**/*.ts' --watch 'public/**/*' --ext ts,html", "build:watch": "yarn nodemon --exec react-scripts build --watch 'src/**/*.ts' --watch 'public/**/*' --ext ts,html",
"test": "react-scripts test", "test": "react-scripts test",
"eject": "react-scripts eject", "eject": "react-scripts eject",
"lint": "eslint . --ignore-path ../../.eslintignore", "lint": "eslint src --ext .js,.jsx",
"prepack": "REACT_APP_GRAPHQL_URL=/graphql yarn build" "prepack": "yarn build"
}, },
"files": [ "files": [
"/build" "/build"
@@ -87,5 +79,17 @@
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.20.1",
"@tanstack/react-query-devtools": "^5.24.1",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "^7.0.1",
"prettier": "^3.2.5"
},
"eslintConfig": {
"extends": [
"./.eslintrc.js"
]
} }
} }

View File

@@ -1,5 +1,5 @@
import { Route, Navigate } from 'react-router-dom'; import { Route, Navigate } from 'react-router-dom';
import AdminSettingsLayout from 'components/AdminSettingsLayout';
import Users from 'pages/Users'; import Users from 'pages/Users';
import EditUser from 'pages/EditUser'; import EditUser from 'pages/EditUser';
import CreateUser from 'pages/CreateUser'; import CreateUser from 'pages/CreateUser';
@@ -8,12 +8,10 @@ import CreateRole from 'pages/CreateRole/index.ee';
import EditRole from 'pages/EditRole/index.ee'; import EditRole from 'pages/EditRole/index.ee';
import Authentication from 'pages/Authentication'; import Authentication from 'pages/Authentication';
import UserInterface from 'pages/UserInterface'; import UserInterface from 'pages/UserInterface';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import Can from 'components/Can'; import Can from 'components/Can';
import AdminApplications from 'pages/AdminApplications'; import AdminApplications from 'pages/AdminApplications';
import AdminApplication from 'pages/AdminApplication'; import AdminApplication from 'pages/AdminApplication';
// TODO: consider introducing redirections to `/` as fallback // TODO: consider introducing redirections to `/` as fallback
export default ( export default (
<> <>
@@ -21,9 +19,7 @@ export default (
path={URLS.USERS} path={URLS.USERS}
element={ element={
<Can I="read" a="User"> <Can I="read" a="User">
<AdminSettingsLayout> <Users />
<Users />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -32,9 +28,7 @@ export default (
path={URLS.CREATE_USER} path={URLS.CREATE_USER}
element={ element={
<Can I="create" a="User"> <Can I="create" a="User">
<AdminSettingsLayout> <CreateUser />
<CreateUser />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -43,9 +37,7 @@ export default (
path={URLS.USER_PATTERN} path={URLS.USER_PATTERN}
element={ element={
<Can I="update" a="User"> <Can I="update" a="User">
<AdminSettingsLayout> <EditUser />
<EditUser />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -54,9 +46,7 @@ export default (
path={URLS.ROLES} path={URLS.ROLES}
element={ element={
<Can I="read" a="Role"> <Can I="read" a="Role">
<AdminSettingsLayout> <Roles />
<Roles />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -65,9 +55,7 @@ export default (
path={URLS.CREATE_ROLE} path={URLS.CREATE_ROLE}
element={ element={
<Can I="create" a="Role"> <Can I="create" a="Role">
<AdminSettingsLayout> <CreateRole />
<CreateRole />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -76,9 +64,7 @@ export default (
path={URLS.ROLE_PATTERN} path={URLS.ROLE_PATTERN}
element={ element={
<Can I="update" a="Role"> <Can I="update" a="Role">
<AdminSettingsLayout> <EditRole />
<EditRole />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -87,9 +73,7 @@ export default (
path={URLS.USER_INTERFACE} path={URLS.USER_INTERFACE}
element={ element={
<Can I="update" a="Config"> <Can I="update" a="Config">
<AdminSettingsLayout> <UserInterface />
<UserInterface />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -100,9 +84,7 @@ export default (
<Can I="read" a="SamlAuthProvider"> <Can I="read" a="SamlAuthProvider">
<Can I="update" a="SamlAuthProvider"> <Can I="update" a="SamlAuthProvider">
<Can I="create" a="SamlAuthProvider"> <Can I="create" a="SamlAuthProvider">
<AdminSettingsLayout> <Authentication />
<Authentication />
</AdminSettingsLayout>
</Can> </Can>
</Can> </Can>
</Can> </Can>
@@ -113,9 +95,7 @@ export default (
path={URLS.ADMIN_APPS} path={URLS.ADMIN_APPS}
element={ element={
<Can I="update" a="App"> <Can I="update" a="App">
<AdminSettingsLayout> <AdminApplications />
<AdminApplications />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />
@@ -124,9 +104,7 @@ export default (
path={`${URLS.ADMIN_APP_PATTERN}/*`} path={`${URLS.ADMIN_APP_PATTERN}/*`}
element={ element={
<Can I="update" a="App"> <Can I="update" a="App">
<AdminSettingsLayout> <AdminApplication />
<AdminApplication />
</AdminSettingsLayout>
</Can> </Can>
} }
/> />

View File

@@ -1,40 +1,25 @@
import * as React from 'react'; import * as React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PropTypes from 'prop-types';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import Menu, { MenuProps } from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Can from 'components/Can'; import Can from 'components/Can';
import apolloClient from 'graphql/client'; import apolloClient from 'graphql/client';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useAuthentication from 'hooks/useAuthentication'; import useAuthentication from 'hooks/useAuthentication';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
function AccountDropdownMenu(props) {
type AccountDropdownMenuProps = {
open: boolean;
onClose: () => void;
anchorEl: MenuProps['anchorEl'];
id: string;
};
function AccountDropdownMenu(
props: AccountDropdownMenuProps
): React.ReactElement {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const authentication = useAuthentication(); const authentication = useAuthentication();
const navigate = useNavigate(); const navigate = useNavigate();
const { open, onClose, anchorEl, id } = props; const { open, onClose, anchorEl, id } = props;
const logout = async () => { const logout = async () => {
authentication.updateToken(''); authentication.updateToken('');
await apolloClient.clearStore(); await apolloClient.clearStore();
onClose(); onClose();
navigate(URLS.LOGIN); navigate(URLS.LOGIN);
}; };
return ( return (
<Menu <Menu
anchorEl={anchorEl} anchorEl={anchorEl}
@@ -51,7 +36,7 @@ function AccountDropdownMenu(
open={open} open={open}
onClose={onClose} onClose={onClose}
> >
<MenuItem component={Link} to={URLS.SETTINGS_DASHBOARD}> <MenuItem component={Link} to={URLS.SETTINGS_DASHBOARD} onClick={onClose}>
{formatMessage('accountDropdownMenu.settings')} {formatMessage('accountDropdownMenu.settings')}
</MenuItem> </MenuItem>
@@ -59,6 +44,7 @@ function AccountDropdownMenu(
<MenuItem <MenuItem
component={Link} component={Link}
to={URLS.ADMIN_SETTINGS_DASHBOARD} to={URLS.ADMIN_SETTINGS_DASHBOARD}
onClick={onClose}
> >
{formatMessage('accountDropdownMenu.adminSettings')} {formatMessage('accountDropdownMenu.adminSettings')}
</MenuItem> </MenuItem>
@@ -71,4 +57,11 @@ function AccountDropdownMenu(
); );
} }
AccountDropdownMenu.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
id: PropTypes.string.isRequired,
};
export default AccountDropdownMenu; export default AccountDropdownMenu;

View File

@@ -1,4 +1,4 @@
import type { IApp, IField, IJSONObject } from 'types'; import PropTypes from 'prop-types';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
@@ -6,32 +6,24 @@ import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import * as React from 'react'; import * as React from 'react';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { AppPropType } from 'propTypes/propTypes';
import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee'; import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee';
import InputCreator from 'components/InputCreator'; import InputCreator from 'components/InputCreator';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useAuthenticateApp from 'hooks/useAuthenticateApp.ee'; import useAuthenticateApp from 'hooks/useAuthenticateApp.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { generateExternalLink } from '../../helpers/translationValues'; import { generateExternalLink } from 'helpers/translationValues';
import { Form } from './style'; import { Form } from './style';
type AddAppConnectionProps = { function AddAppConnection(props) {
onClose: (response: Record<string, unknown>) => void;
application: IApp;
connectionId?: string;
};
export default function AddAppConnection(
props: AddAppConnectionProps
): React.ReactElement {
const { application, connectionId, onClose } = props; const { application, connectionId, onClose } = props;
const { name, authDocUrl, key, auth } = application; const { name, authDocUrl, key, auth } = application;
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [error, setError] = React.useState<IJSONObject | null>(null); const [error, setError] = React.useState(null);
const [inProgress, setInProgress] = React.useState(false); const [inProgress, setInProgress] = React.useState(false);
const hasConnection = Boolean(connectionId); const hasConnection = Boolean(connectionId);
const useShared = searchParams.get('shared') === 'true'; const useShared = searchParams.get('shared') === 'true';
@@ -42,7 +34,6 @@ export default function AddAppConnection(
appAuthClientId, appAuthClientId,
useShared: !!appAuthClientId, useShared: !!appAuthClientId,
}); });
React.useEffect(function relayProviderData() { React.useEffect(function relayProviderData() {
if (window.opener) { if (window.opener) {
window.opener.postMessage({ window.opener.postMessage({
@@ -52,51 +43,41 @@ export default function AddAppConnection(
window.close(); window.close();
} }
}, []); }, []);
React.useEffect( React.useEffect(
function initiateSharedAuthenticationForGivenAuthClient() { function initiateSharedAuthenticationForGivenAuthClient() {
if (!appAuthClientId) return; if (!appAuthClientId) return;
if (!authenticate) return; if (!authenticate) return;
const asyncAuthenticate = async () => { const asyncAuthenticate = async () => {
await authenticate(); await authenticate();
navigate(URLS.APP_CONNECTIONS(key)); navigate(URLS.APP_CONNECTIONS(key));
}; };
asyncAuthenticate(); asyncAuthenticate();
}, },
[appAuthClientId, authenticate] [appAuthClientId, authenticate],
); );
const handleClientClick = (appAuthClientId) =>
const handleClientClick = (appAuthClientId: string) =>
navigate(URLS.APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID(key, appAuthClientId)); navigate(URLS.APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID(key, appAuthClientId));
const handleAuthClientsDialogClose = () => const handleAuthClientsDialogClose = () =>
navigate(URLS.APP_CONNECTIONS(key)); navigate(URLS.APP_CONNECTIONS(key));
const submitHandler = React.useCallback(
const submitHandler: SubmitHandler<FieldValues> = React.useCallback(
async (data) => { async (data) => {
if (!authenticate) return; if (!authenticate) return;
setInProgress(true); setInProgress(true);
try { try {
const response = await authenticate({ const response = await authenticate({
fields: data, fields: data,
}); });
onClose(response as Record<string, unknown>); onClose(response);
} catch (err) { } catch (err) {
const error = err as IJSONObject; const error = err;
console.log(error); console.log(error);
setError((error.graphQLErrors as IJSONObject[])?.[0]); setError(error.graphQLErrors?.[0]);
} finally { } finally {
setInProgress(false); setInProgress(false);
} }
}, },
[authenticate] [authenticate],
); );
if (useShared) if (useShared)
return ( return (
<AppAuthClientsDialog <AppAuthClientsDialog
@@ -105,9 +86,7 @@ export default function AddAppConnection(
onClientClick={handleClientClick} onClientClick={handleClientClick}
/> />
); );
if (appAuthClientId) return <React.Fragment />; if (appAuthClientId) return <React.Fragment />;
return ( return (
<Dialog open={true} onClose={onClose} data-test="add-app-connection-dialog"> <Dialog open={true} onClose={onClose} data-test="add-app-connection-dialog">
<DialogTitle> <DialogTitle>
@@ -142,7 +121,7 @@ export default function AddAppConnection(
<DialogContent> <DialogContent>
<DialogContentText tabIndex={-1} component="div"> <DialogContentText tabIndex={-1} component="div">
<Form onSubmit={submitHandler}> <Form onSubmit={submitHandler}>
{auth?.fields?.map((field: IField) => ( {auth?.fields?.map((field) => (
<InputCreator key={field.key} schema={field} /> <InputCreator key={field.key} schema={field} />
))} ))}
@@ -162,3 +141,11 @@ export default function AddAppConnection(
</Dialog> </Dialog>
); );
} }
AddAppConnection.propTypes = {
onClose: PropTypes.func.isRequired,
application: AppPropType.isRequired,
connectionId: PropTypes.string,
};
export default AddAppConnection;

View File

@@ -1,6 +1,5 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import BaseForm from 'components/Form'; import BaseForm from 'components/Form';
export const Form = styled(BaseForm)(({ theme }) => ({ export const Form = styled(BaseForm)(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import PropTypes from 'prop-types';
import { useLazyQuery } from '@apollo/client'; import { useLazyQuery } from '@apollo/client';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@@ -19,67 +20,53 @@ import InputLabel from '@mui/material/InputLabel';
import OutlinedInput from '@mui/material/OutlinedInput'; import OutlinedInput from '@mui/material/OutlinedInput';
import FormControl from '@mui/material/FormControl'; import FormControl from '@mui/material/FormControl';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import type { IApp } from 'types';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import AppIcon from 'components/AppIcon'; import AppIcon from 'components/AppIcon';
import { GET_APPS } from 'graphql/queries/get-apps'; import { GET_APPS } from 'graphql/queries/get-apps';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
function createConnectionOrFlow(appKey: string, supportsConnections = false) { function createConnectionOrFlow(appKey, supportsConnections = false) {
if (!supportsConnections) { if (!supportsConnections) {
return URLS.CREATE_FLOW_WITH_APP(appKey); return URLS.CREATE_FLOW_WITH_APP(appKey);
} }
return URLS.APP_ADD_CONNECTION(appKey); return URLS.APP_ADD_CONNECTION(appKey);
} }
function AddNewAppConnection(props) {
type AddNewAppConnectionProps = {
onClose: () => void;
};
export default function AddNewAppConnection(
props: AddNewAppConnectionProps
): React.ReactElement {
const { onClose } = props; const { onClose } = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('sm')); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('sm'));
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [appName, setAppName] = React.useState<string | null>(null); const [appName, setAppName] = React.useState(null);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [getApps, { data }] = useLazyQuery(GET_APPS, { const [getApps, { data }] = useLazyQuery(GET_APPS, {
onCompleted: () => { onCompleted: () => {
setLoading(false); setLoading(false);
}, },
}); });
const fetchData = React.useMemo( const fetchData = React.useMemo(
() => debounce((name) => getApps({ variables: { name } }), 300), () => debounce((name) => getApps({ variables: { name } }), 300),
[getApps] [getApps],
); );
React.useEffect( React.useEffect(
function fetchAppsOnAppNameChange() { function fetchAppsOnAppNameChange() {
setLoading(true); setLoading(true);
fetchData(appName); fetchData(appName);
}, },
[fetchData, appName] [fetchData, appName],
); );
React.useEffect(function cancelDebounceOnUnmount() { React.useEffect(function cancelDebounceOnUnmount() {
return () => { return () => {
fetchData.cancel(); fetchData.cancel();
}; };
}, []); }, []);
return ( return (
<Dialog <Dialog
open={true} open={true}
onClose={onClose} onClose={onClose}
maxWidth="sm" maxWidth="sm"
fullWidth fullWidth
data-test="add-app-connection-dialog"> data-test="add-app-connection-dialog"
>
<DialogTitle>{formatMessage('apps.addNewAppConnection')}</DialogTitle> <DialogTitle>{formatMessage('apps.addNewAppConnection')}</DialogTitle>
<Box px={3}> <Box px={3}>
@@ -123,7 +110,7 @@ export default function AddNewAppConnection(
)} )}
{!loading && {!loading &&
data?.getApps?.map((app: IApp) => ( data?.getApps?.map((app) => (
<ListItem disablePadding key={app.name} data-test="app-list-item"> <ListItem disablePadding key={app.name} data-test="app-list-item">
<ListItemButton <ListItemButton
component={Link} component={Link}
@@ -151,3 +138,9 @@ export default function AddNewAppConnection(
</Dialog> </Dialog>
); );
} }
AddNewAppConnection.propTypes = {
onClose: PropTypes.func.isRequired,
};
export default AddNewAppConnection;

View File

@@ -1,5 +1,5 @@
import React from 'react'; import * as React from 'react';
import type { IField } from 'types'; import PropTypes from 'prop-types';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
@@ -7,32 +7,16 @@ import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText'; import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { FieldValues, SubmitHandler } from 'react-hook-form'; import { ApolloError } from '@apollo/client';
import type { UseFormProps } from 'react-hook-form';
import type { ApolloError } from '@apollo/client';
import { FieldPropType } from 'propTypes/propTypes';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import InputCreator from 'components/InputCreator'; import InputCreator from 'components/InputCreator';
import Switch from 'components/Switch'; import Switch from 'components/Switch';
import TextField from 'components/TextField'; import TextField from 'components/TextField';
import { Form } from './style'; import { Form } from './style';
type AdminApplicationAuthClientDialogProps = { function AdminApplicationAuthClientDialog(props) {
title: string;
authFields?: IField[];
defaultValues: UseFormProps['defaultValues'];
loading: boolean;
submitting: boolean;
disabled?: boolean;
error?: ApolloError;
submitHandler: SubmitHandler<FieldValues>;
onClose: () => void;
};
export default function AdminApplicationAuthClientDialog(
props: AdminApplicationAuthClientDialogProps
): React.ReactElement {
const { const {
error, error,
onClose, onClose,
@@ -45,7 +29,6 @@ export default function AdminApplicationAuthClientDialog(
disabled = false, disabled = false,
} = props; } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
return ( return (
<Dialog open={true} onClose={onClose}> <Dialog open={true} onClose={onClose}>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
@@ -80,7 +63,7 @@ export default function AdminApplicationAuthClientDialog(
label={formatMessage('authClient.inputName')} label={formatMessage('authClient.inputName')}
fullWidth fullWidth
/> />
{authFields?.map((field: IField) => ( {authFields?.map((field) => (
<InputCreator key={field.key} schema={field} /> <InputCreator key={field.key} schema={field} />
))} ))}
<LoadingButton <LoadingButton
@@ -102,3 +85,17 @@ export default function AdminApplicationAuthClientDialog(
</Dialog> </Dialog>
); );
} }
AdminApplicationAuthClientDialog.propTypes = {
error: PropTypes.instanceOf(ApolloError),
onClose: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
loading: PropTypes.bool.isRequired,
submitHandler: PropTypes.func.isRequired,
authFields: PropTypes.arrayOf(FieldPropType),
submitting: PropTypes.bool.isRequired,
defaultValues: PropTypes.object.isRequired,
disabled: PropTypes.bool,
};
export default AdminApplicationAuthClientDialog;

View File

@@ -1,6 +1,5 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import BaseForm from 'components/Form'; import BaseForm from 'components/Form';
export const Form = styled(BaseForm)(({ theme }) => ({ export const Form = styled(BaseForm)(({ theme }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',

View File

@@ -1,3 +1,4 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
@@ -7,27 +8,17 @@ import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip'; import Chip from '@mui/material/Chip';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useAppAuthClients from 'hooks/useAppAuthClients.ee'; import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import NoResultFound from 'components/NoResultFound'; import NoResultFound from 'components/NoResultFound';
type AdminApplicationAuthClientsProps = { function AdminApplicationAuthClients(props) {
appKey: string;
};
function AdminApplicationAuthClients(
props: AdminApplicationAuthClientsProps
): React.ReactElement {
const { appKey } = props; const { appKey } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { appAuthClients, loading } = useAppAuthClients({ appKey }); const { appAuthClients, loading } = useAppAuthClients({ appKey });
if (loading) if (loading)
return <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />; return <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />;
if (!appAuthClients?.length) { if (!appAuthClients?.length) {
return ( return (
<NoResultFound <NoResultFound
@@ -36,7 +27,6 @@ function AdminApplicationAuthClients(
/> />
); );
} }
const sortedAuthClients = appAuthClients.slice().sort((a, b) => { const sortedAuthClients = appAuthClients.slice().sort((a, b) => {
if (a.id < b.id) { if (a.id < b.id) {
return -1; return -1;
@@ -46,7 +36,6 @@ function AdminApplicationAuthClients(
} }
return 0; return 0;
}); });
return ( return (
<div> <div>
{sortedAuthClients.map((client) => ( {sortedAuthClients.map((client) => (
@@ -67,7 +56,7 @@ function AdminApplicationAuthClients(
label={formatMessage( label={formatMessage(
client?.active client?.active
? 'adminAppsAuthClients.statusActive' ? 'adminAppsAuthClients.statusActive'
: 'adminAppsAuthClients.statusInactive' : 'adminAppsAuthClients.statusInactive',
)} )}
/> />
</Stack> </Stack>
@@ -86,4 +75,8 @@ function AdminApplicationAuthClients(
); );
} }
AdminApplicationAuthClients.propTypes = {
appKey: PropTypes.string.isRequired,
};
export default AdminApplicationAuthClients; export default AdminApplicationAuthClients;

View File

@@ -1,24 +1,15 @@
import PropTypes from 'prop-types';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import type { IApp } from 'types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client'; import { useMutation } from '@apollo/client';
import { AppPropType } from 'propTypes/propTypes';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config'; import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { CREATE_APP_AUTH_CLIENT } from 'graphql/mutations/create-app-auth-client'; import { CREATE_APP_AUTH_CLIENT } from 'graphql/mutations/create-app-auth-client';
import useAppConfig from 'hooks/useAppConfig.ee'; import useAppConfig from 'hooks/useAppConfig.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog'; import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
type AdminApplicationCreateAuthClientProps = { function AdminApplicationCreateAuthClient(props) {
appKey: string;
application: IApp;
onClose: () => void;
};
export default function AdminApplicationCreateAuthClient(
props: AdminApplicationCreateAuthClientProps
): React.ReactElement {
const { appKey, application, onClose } = props; const { appKey, application, onClose } = props;
const { auth } = application; const { auth } = application;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
@@ -37,10 +28,8 @@ export default function AdminApplicationCreateAuthClient(
refetchQueries: ['GetAppAuthClients'], refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false }, context: { autoSnackbar: false },
}); });
const submitHandler = async (values) => {
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
let appConfigId = appConfig?.id; let appConfigId = appConfig?.id;
if (!appConfigId) { if (!appConfigId) {
const { data: appConfigData } = await createAppConfig({ const { data: appConfigData } = await createAppConfig({
variables: { variables: {
@@ -54,9 +43,7 @@ export default function AdminApplicationCreateAuthClient(
}); });
appConfigId = appConfigData.createAppConfig.id; appConfigId = appConfigData.createAppConfig.id;
} }
const { name, active, ...formattedAuthDefaults } = values; const { name, active, ...formattedAuthDefaults } = values;
await createAppAuthClient({ await createAppAuthClient({
variables: { variables: {
input: { input: {
@@ -67,17 +54,13 @@ export default function AdminApplicationCreateAuthClient(
}, },
}, },
}); });
onClose(); onClose();
}; };
const getAuthFieldsDefaultValues = useCallback(() => { const getAuthFieldsDefaultValues = useCallback(() => {
if (!auth?.fields) { if (!auth?.fields) {
return {}; return {};
} }
const defaultValues: { const defaultValues = {};
[key: string]: any;
} = {};
auth.fields.forEach((field) => { auth.fields.forEach((field) => {
if (field.value || field.type !== 'string') { if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value; defaultValues[field.key] = field.value;
@@ -87,16 +70,14 @@ export default function AdminApplicationCreateAuthClient(
}); });
return defaultValues; return defaultValues;
}, [auth?.fields]); }, [auth?.fields]);
const defaultValues = useMemo( const defaultValues = useMemo(
() => ({ () => ({
name: '', name: '',
active: false, active: false,
...getAuthFieldsDefaultValues(), ...getAuthFieldsDefaultValues(),
}), }),
[getAuthFieldsDefaultValues] [getAuthFieldsDefaultValues],
); );
return ( return (
<AdminApplicationAuthClientDialog <AdminApplicationAuthClientDialog
onClose={onClose} onClose={onClose}
@@ -110,3 +91,11 @@ export default function AdminApplicationCreateAuthClient(
/> />
); );
} }
AdminApplicationCreateAuthClient.propTypes = {
appKey: PropTypes.string.isRequired,
application: AppPropType.isRequired,
onClose: PropTypes.func.isRequired,
};
export default AdminApplicationCreateAuthClient;

View File

@@ -1,3 +1,4 @@
import PropTypes from 'prop-types';
import { useMemo } from 'react'; import { useMemo } from 'react';
import useAppConfig from 'hooks/useAppConfig.ee'; import useAppConfig from 'hooks/useAppConfig.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
@@ -6,39 +7,29 @@ import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import { useMutation } from '@apollo/client'; import { useMutation } from '@apollo/client';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config'; import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { UPDATE_APP_CONFIG } from 'graphql/mutations/update-app-config'; import { UPDATE_APP_CONFIG } from 'graphql/mutations/update-app-config';
import Form from 'components/Form'; import Form from 'components/Form';
import { Switch } from './style'; import { Switch } from './style';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
type AdminApplicationSettingsProps = { function AdminApplicationSettings(props) {
appKey: string;
};
function AdminApplicationSettings(
props: AdminApplicationSettingsProps
): React.ReactElement {
const { appConfig, loading } = useAppConfig(props.appKey); const { appConfig, loading } = useAppConfig(props.appKey);
const [createAppConfig, { loading: loadingCreateAppConfig }] = useMutation( const [createAppConfig, { loading: loadingCreateAppConfig }] = useMutation(
CREATE_APP_CONFIG, CREATE_APP_CONFIG,
{ {
refetchQueries: ['GetAppConfig'], refetchQueries: ['GetAppConfig'],
} },
); );
const [updateAppConfig, { loading: loadingUpdateAppConfig }] = useMutation( const [updateAppConfig, { loading: loadingUpdateAppConfig }] = useMutation(
UPDATE_APP_CONFIG, UPDATE_APP_CONFIG,
{ {
refetchQueries: ['GetAppConfig'], refetchQueries: ['GetAppConfig'],
} },
); );
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const handleSubmit = async (values) => {
const handleSubmit = async (values: any) => {
try { try {
if (!appConfig) { if (!appConfig) {
await createAppConfig({ await createAppConfig({
@@ -56,23 +47,21 @@ function AdminApplicationSettings(
enqueueSnackbar(formatMessage('adminAppsSettings.successfullySaved'), { enqueueSnackbar(formatMessage('adminAppsSettings.successfullySaved'), {
variant: 'success', variant: 'success',
SnackbarProps: { SnackbarProps: {
'data-test': 'snackbar-save-admin-apps-settings-success' 'data-test': 'snackbar-save-admin-apps-settings-success',
} },
}); });
} catch (error) { } catch (error) {
throw new Error('Failed while saving!'); throw new Error('Failed while saving!');
} }
}; };
const defaultValues = useMemo( const defaultValues = useMemo(
() => ({ () => ({
allowCustomConnection: appConfig?.allowCustomConnection || false, allowCustomConnection: appConfig?.allowCustomConnection || false,
shared: appConfig?.shared || false, shared: appConfig?.shared || false,
disabled: appConfig?.disabled || false, disabled: appConfig?.disabled || false,
}), }),
[appConfig] [appConfig],
); );
return ( return (
<Form <Form
defaultValues={defaultValues} defaultValues={defaultValues}
@@ -123,4 +112,8 @@ function AdminApplicationSettings(
); );
} }
AdminApplicationSettings.propTypes = {
appKey: PropTypes.string.isRequired,
};
export default AdminApplicationSettings; export default AdminApplicationSettings;

View File

@@ -1,6 +1,5 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import SwitchBase from 'components/Switch'; import SwitchBase from 'components/Switch';
export const Switch = styled(SwitchBase)` export const Switch = styled(SwitchBase)`
justify-content: space-between; justify-content: space-between;
margin: 0; margin: 0;

View File

@@ -1,55 +1,52 @@
import PropTypes from 'prop-types';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import type { IApp } from 'types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client'; import { useMutation } from '@apollo/client';
import { UPDATE_APP_AUTH_CLIENT } from 'graphql/mutations/update-app-auth-client';
import useAppAuthClient from 'hooks/useAppAuthClient.ee'; import { AppPropType } from 'propTypes/propTypes';
import { UPDATE_APP_AUTH_CLIENT } from 'graphql/mutations/update-app-auth-client';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog'; import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
import useAdminAppAuthClient from 'hooks/useAdminAppAuthClient.ee';
type AdminApplicationUpdateAuthClientProps = { function AdminApplicationUpdateAuthClient(props) {
application: IApp;
onClose: () => void;
};
export default function AdminApplicationUpdateAuthClient(
props: AdminApplicationUpdateAuthClientProps
): React.ReactElement {
const { application, onClose } = props; const { application, onClose } = props;
const { auth } = application; const { auth } = application;
const authFields = auth?.fields?.map((field) => ({
...field,
required: false,
}));
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { clientId } = useParams(); const { clientId } = useParams();
const { appAuthClient, loading: loadingAuthClient } =
useAppAuthClient(clientId); const { data: adminAppAuthClient, isLoading: isAdminAuthClientLoading } =
useAdminAppAuthClient(clientId);
const [updateAppAuthClient, { loading: loadingUpdateAppAuthClient, error }] = const [updateAppAuthClient, { loading: loadingUpdateAppAuthClient, error }] =
useMutation(UPDATE_APP_AUTH_CLIENT, { useMutation(UPDATE_APP_AUTH_CLIENT, {
refetchQueries: ['GetAppAuthClients'], refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false }, context: { autoSnackbar: false },
}); });
const submitHandler: SubmitHandler<FieldValues> = async (values) => { const authFields = auth?.fields?.map((field) => ({
if (!appAuthClient) { ...field,
required: false,
}));
const submitHandler = async (values) => {
if (!adminAppAuthClient) {
return; return;
} }
const { name, active, ...formattedAuthDefaults } = values; const { name, active, ...formattedAuthDefaults } = values;
await updateAppAuthClient({ await updateAppAuthClient({
variables: { variables: {
input: { input: {
id: appAuthClient.id, id: adminAppAuthClient.data.id,
name, name,
active, active,
formattedAuthDefaults, formattedAuthDefaults,
}, },
}, },
}); });
onClose(); onClose();
}; };
@@ -57,9 +54,8 @@ export default function AdminApplicationUpdateAuthClient(
if (!authFields) { if (!authFields) {
return {}; return {};
} }
const defaultValues: {
[key: string]: any; const defaultValues = {};
} = {};
authFields.forEach((field) => { authFields.forEach((field) => {
if (field.value || field.type !== 'string') { if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value; defaultValues[field.key] = field.value;
@@ -72,11 +68,11 @@ export default function AdminApplicationUpdateAuthClient(
const defaultValues = useMemo( const defaultValues = useMemo(
() => ({ () => ({
name: appAuthClient?.name || '', name: adminAppAuthClient?.data?.name || '',
active: appAuthClient?.active || false, active: adminAppAuthClient?.data?.active || false,
...getAuthFieldsDefaultValues(), ...getAuthFieldsDefaultValues(),
}), }),
[appAuthClient, getAuthFieldsDefaultValues] [adminAppAuthClient, getAuthFieldsDefaultValues],
); );
return ( return (
@@ -84,12 +80,19 @@ export default function AdminApplicationUpdateAuthClient(
onClose={onClose} onClose={onClose}
error={error} error={error}
title={formatMessage('updateAuthClient.title')} title={formatMessage('updateAuthClient.title')}
loading={loadingAuthClient} loading={isAdminAuthClientLoading}
submitHandler={submitHandler} submitHandler={submitHandler}
authFields={authFields} authFields={authFields}
submitting={loadingUpdateAppAuthClient} submitting={loadingUpdateAppAuthClient}
defaultValues={defaultValues} defaultValues={defaultValues}
disabled={!appAuthClient} disabled={!adminAppAuthClient}
/> />
); );
} }
AdminApplicationUpdateAuthClient.propTypes = {
application: AppPropType.isRequired,
onClose: PropTypes.func.isRequired,
};
export default AdminApplicationUpdateAuthClient;

View File

@@ -4,42 +4,26 @@ import GroupsIcon from '@mui/icons-material/Groups';
import LockIcon from '@mui/icons-material/LockPerson'; import LockIcon from '@mui/icons-material/LockPerson';
import BrushIcon from '@mui/icons-material/Brush'; import BrushIcon from '@mui/icons-material/Brush';
import AppsIcon from '@mui/icons-material/Apps'; import AppsIcon from '@mui/icons-material/Apps';
import { Outlet } from 'react-router-dom';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react'; import * as React from 'react';
import { SvgIconComponent } from '@mui/icons-material';
import AppBar from 'components/AppBar'; import AppBar from 'components/AppBar';
import Drawer from 'components/Drawer'; import Drawer from 'components/Drawer';
import Can from 'components/Can';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
type SettingsLayoutProps = {
children: React.ReactNode;
};
type DrawerLink = {
Icon: SvgIconComponent;
primary: string;
to: string;
};
function createDrawerLinks({ function createDrawerLinks({
canReadRole, canReadRole,
canReadUser, canReadUser,
canUpdateConfig, canUpdateConfig,
canManageSamlAuthProvider, canManageSamlAuthProvider,
canUpdateApp, canUpdateApp,
}: {
canReadRole: boolean;
canReadUser: boolean;
canUpdateConfig: boolean;
canManageSamlAuthProvider: boolean;
canUpdateApp: boolean;
}) { }) {
const items = [ const items = [
canReadUser canReadUser
@@ -82,20 +66,16 @@ function createDrawerLinks({
dataTest: 'apps-drawer-link', dataTest: 'apps-drawer-link',
} }
: null, : null,
].filter(Boolean) as DrawerLink[]; ].filter(Boolean);
return items; return items;
} }
export default function SettingsLayout({ function SettingsLayout() {
children,
}: SettingsLayoutProps): React.ReactElement {
const theme = useTheme(); const theme = useTheme();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const currentUserAbility = useCurrentUserAbility(); const currentUserAbility = useCurrentUserAbility();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'));
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
const openDrawer = () => setDrawerOpen(true); const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false); const closeDrawer = () => setDrawerOpen(false);
const drawerLinks = createDrawerLinks({ const drawerLinks = createDrawerLinks({
@@ -108,7 +88,6 @@ export default function SettingsLayout({
currentUserAbility.can('create', 'SamlAuthProvider'), currentUserAbility.can('create', 'SamlAuthProvider'),
canUpdateApp: currentUserAbility.can('update', 'App'), canUpdateApp: currentUserAbility.can('update', 'App'),
}); });
const drawerBottomLinks = [ const drawerBottomLinks = [
{ {
Icon: ArrowBackIosNewIcon, Icon: ArrowBackIosNewIcon,
@@ -117,15 +96,13 @@ export default function SettingsLayout({
dataTest: 'go-back-drawer-link', dataTest: 'go-back-drawer-link',
}, },
]; ];
return ( return (
<> <Can I="read" a="User">
<AppBar <AppBar
drawerOpen={isDrawerOpen} drawerOpen={isDrawerOpen}
onDrawerOpen={openDrawer} onDrawerOpen={openDrawer}
onDrawerClose={closeDrawer} onDrawerClose={closeDrawer}
/> />
<Box sx={{ display: 'flex' }}> <Box sx={{ display: 'flex' }}>
<Drawer <Drawer
links={drawerLinks} links={drawerLinks}
@@ -134,13 +111,13 @@ export default function SettingsLayout({
onOpen={openDrawer} onOpen={openDrawer}
onClose={closeDrawer} onClose={closeDrawer}
/> />
<Box sx={{ flex: 1 }}> <Box sx={{ flex: 1 }}>
<Toolbar /> <Toolbar />
<Outlet />
{children}
</Box> </Box>
</Box> </Box>
</> </Can>
); );
} }
export default SettingsLayout;

View File

@@ -5,11 +5,7 @@ import { mutateAndGetClient } from 'graphql/client';
import useAuthentication from 'hooks/useAuthentication'; import useAuthentication from 'hooks/useAuthentication';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
type ApolloProviderProps = { const ApolloProvider = (props) => {
children: React.ReactNode;
};
const ApolloProvider = (props: ApolloProviderProps): React.ReactElement => {
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const authentication = useAuthentication(); const authentication = useAuthentication();
@@ -18,11 +14,11 @@ const ApolloProvider = (props: ApolloProviderProps): React.ReactElement => {
enqueueSnackbar(message, { enqueueSnackbar(message, {
variant: 'error', variant: 'error',
SnackbarProps: { SnackbarProps: {
'data-test': 'snackbar-error' 'data-test': 'snackbar-error',
} },
}); });
}, },
[enqueueSnackbar] [enqueueSnackbar],
); );
const client = React.useMemo(() => { const client = React.useMemo(() => {

View File

@@ -1,3 +1,4 @@
import PropTypes from 'prop-types';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import List from '@mui/material/List'; import List from '@mui/material/List';
@@ -5,33 +6,23 @@ import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import * as React from 'react'; import * as React from 'react';
import useAppAuthClients from 'hooks/useAppAuthClients.ee'; import useAppAuthClients from 'hooks/useAppAuthClients.ee';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
type AppAuthClientsDialogProps = { function AppAuthClientsDialog(props) {
appKey: string;
onClientClick: (appAuthClientId: string) => void;
onClose: () => void;
};
export default function AppAuthClientsDialog(props: AppAuthClientsDialogProps) {
const { appKey, onClientClick, onClose } = props; const { appKey, onClientClick, onClose } = props;
const { appAuthClients } = useAppAuthClients({ appKey, active: true }); const { appAuthClients } = useAppAuthClients({ appKey, active: true });
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
React.useEffect( React.useEffect(
function autoAuthenticateSingleClient() { function autoAuthenticateSingleClient() {
if (appAuthClients?.length === 1) { if (appAuthClients?.length === 1) {
onClientClick(appAuthClients[0].id); onClientClick(appAuthClients[0].id);
} }
}, },
[appAuthClients] [appAuthClients],
); );
if (!appAuthClients?.length || appAuthClients?.length === 1) if (!appAuthClients?.length || appAuthClients?.length === 1)
return <React.Fragment />; return <React.Fragment />;
return ( return (
<Dialog onClose={onClose} open={true}> <Dialog onClose={onClose} open={true}>
<DialogTitle>{formatMessage('appAuthClientsDialog.title')}</DialogTitle> <DialogTitle>{formatMessage('appAuthClientsDialog.title')}</DialogTitle>
@@ -48,3 +39,11 @@ export default function AppAuthClientsDialog(props: AppAuthClientsDialogProps) {
</Dialog> </Dialog>
); );
} }
AppAuthClientsDialog.propTypes = {
appKey: PropTypes.string.isRequired,
onClientClick: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
export default AppAuthClientsDialog;

View File

@@ -1,50 +1,35 @@
import PropTypes from 'prop-types';
import AccountCircleIcon from '@mui/icons-material/AccountCircle'; import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import MenuOpenIcon from '@mui/icons-material/MenuOpen'; import MenuOpenIcon from '@mui/icons-material/MenuOpen';
import MuiAppBar from '@mui/material/AppBar'; import MuiAppBar from '@mui/material/AppBar';
import type { ContainerProps } from '@mui/material/Container';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery'; import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react'; import * as React from 'react';
import AccountDropdownMenu from 'components/AccountDropdownMenu'; import AccountDropdownMenu from 'components/AccountDropdownMenu';
import Container from 'components/Container'; import Container from 'components/Container';
import Logo from 'components/Logo/index'; import Logo from 'components/Logo/index';
import TrialStatusBadge from 'components/TrialStatusBadge/index.ee'; import TrialStatusBadge from 'components/TrialStatusBadge/index.ee';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import { Link } from './style'; import { Link } from './style';
type AppBarProps = {
drawerOpen: boolean;
onDrawerOpen: () => void;
onDrawerClose: () => void;
maxWidth?: ContainerProps['maxWidth'];
};
const accountMenuId = 'account-menu'; const accountMenuId = 'account-menu';
export default function AppBar(props: AppBarProps): React.ReactElement { function AppBar(props) {
const { drawerOpen, onDrawerOpen, onDrawerClose, maxWidth = false } = props; const { drawerOpen, onDrawerOpen, onDrawerClose, maxWidth = false } = props;
const theme = useTheme(); const theme = useTheme();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md')); const matchSmallScreens = useMediaQuery(theme.breakpoints.down('md'));
const [accountMenuAnchorElement, setAccountMenuAnchorElement] = const [accountMenuAnchorElement, setAccountMenuAnchorElement] =
React.useState<null | HTMLElement>(null); React.useState(null);
const isMenuOpen = Boolean(accountMenuAnchorElement); const isMenuOpen = Boolean(accountMenuAnchorElement);
const handleAccountMenuOpen = (event) => {
const handleAccountMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAccountMenuAnchorElement(event.currentTarget); setAccountMenuAnchorElement(event.currentTarget);
}; };
const handleAccountMenuClose = () => { const handleAccountMenuClose = () => {
setAccountMenuAnchorElement(null); setAccountMenuAnchorElement(null);
}; };
return ( return (
<MuiAppBar data-test="app-bar"> <MuiAppBar data-test="app-bar">
<Container maxWidth={maxWidth} disableGutters> <Container maxWidth={maxWidth} disableGutters>
@@ -91,3 +76,15 @@ export default function AppBar(props: AppBarProps): React.ReactElement {
</MuiAppBar> </MuiAppBar>
); );
} }
AppBar.propTypes = {
drawerOpen: PropTypes.bool.isRequired,
onDrawerOpen: PropTypes.func.isRequired,
onDrawerClose: PropTypes.func.isRequired,
maxWidth: PropTypes.oneOfType([
PropTypes.oneOf(['xs', 'sm', 'md', 'lg', 'xl', false]),
PropTypes.string,
]),
};
export default AppBar;

View File

@@ -1,6 +1,5 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from 'react-router-dom';
export const Link = styled(RouterLink)(() => ({ export const Link = styled(RouterLink)(() => ({
textDecoration: 'none', textDecoration: 'none',
color: 'inherit', color: 'inherit',

View File

@@ -1,29 +1,14 @@
import PropTypes from 'prop-types';
import * as React from 'react'; import * as React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import type { PopoverProps } from '@mui/material/Popover';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import type { IConnection } from 'types';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { ConnectionPropType } from 'propTypes/propTypes';
type Action = { function ContextMenu(props) {
type: 'test' | 'reconnect' | 'delete' | 'viewFlows';
};
type ContextMenuProps = {
appKey: string;
connection: IConnection;
onClose: () => void;
onMenuItemClick: (event: React.MouseEvent, action: Action) => void;
anchorEl: PopoverProps['anchorEl'];
disableReconnection: boolean;
};
export default function ContextMenu(
props: ContextMenuProps
): React.ReactElement {
const { const {
appKey, appKey,
connection, connection,
@@ -33,18 +18,15 @@ export default function ContextMenu(
disableReconnection, disableReconnection,
} = props; } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const createActionHandler = React.useCallback( const createActionHandler = React.useCallback(
(action: Action) => { (action) => {
return function clickHandler(event: React.MouseEvent) { return function clickHandler(event) {
onMenuItemClick(event, action); onMenuItemClick(event, action);
onClose(); onClose();
}; };
}, },
[onMenuItemClick, onClose] [onMenuItemClick, onClose],
); );
return ( return (
<Menu <Menu
open={true} open={true}
@@ -70,7 +52,7 @@ export default function ContextMenu(
to={URLS.APP_RECONNECT_CONNECTION( to={URLS.APP_RECONNECT_CONNECTION(
appKey, appKey,
connection.id, connection.id,
connection.appAuthClientId connection.appAuthClientId,
)} )}
onClick={createActionHandler({ type: 'reconnect' })} onClick={createActionHandler({ type: 'reconnect' })}
> >
@@ -83,3 +65,17 @@ export default function ContextMenu(
</Menu> </Menu>
); );
} }
ContextMenu.propTypes = {
appKey: PropTypes.string.isRequired,
connection: ConnectionPropType.isRequired,
onClose: PropTypes.func.isRequired,
onMenuItemClick: PropTypes.func.isRequired,
anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
disableReconnection: PropTypes.bool.isRequired,
};
export default ContextMenu;

View File

@@ -10,26 +10,20 @@ import Stack from '@mui/material/Stack';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import * as React from 'react'; import * as React from 'react';
import type { IConnection } from 'types';
import ConnectionContextMenu from 'components/AppConnectionContextMenu'; import ConnectionContextMenu from 'components/AppConnectionContextMenu';
import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection'; import { DELETE_CONNECTION } from 'graphql/mutations/delete-connection';
import { TEST_CONNECTION } from 'graphql/queries/test-connection'; import { TEST_CONNECTION } from 'graphql/queries/test-connection';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import { ConnectionPropType } from 'propTypes/propTypes';
import { CardContent, Typography } from './style'; import { CardContent, Typography } from './style';
type AppConnectionRowProps = { const countTranslation = (value) => (
connection: IConnection;
};
const countTranslation = (value: React.ReactNode) => (
<> <>
<Typography variant="body1">{value}</Typography> <Typography variant="body1">{value}</Typography>
<br /> <br />
</> </>
); );
function AppConnectionRow(props) {
function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
const enqueueSnackbar = useEnqueueSnackbar(); const enqueueSnackbar = useEnqueueSnackbar();
const [verificationVisible, setVerificationVisible] = React.useState(false); const [verificationVisible, setVerificationVisible] = React.useState(false);
const [testConnection, { called: testCalled, loading: testLoading }] = const [testConnection, { called: testCalled, loading: testLoading }] =
@@ -43,7 +37,6 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
}, },
}); });
const [deleteConnection] = useMutation(DELETE_CONNECTION); const [deleteConnection] = useMutation(DELETE_CONNECTION);
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { const {
id, id,
@@ -54,17 +47,14 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
flowCount, flowCount,
reconnectable, reconnectable,
} = props.connection; } = props.connection;
const contextButtonRef = React.useRef(null);
const contextButtonRef = React.useRef<SVGSVGElement | null>(null); const [anchorEl, setAnchorEl] = React.useState(null);
const [anchorEl, setAnchorEl] = React.useState<SVGSVGElement | null>(null);
const handleClose = () => { const handleClose = () => {
setAnchorEl(null); setAnchorEl(null);
}; };
const onContextMenuClick = () => setAnchorEl(contextButtonRef.current); const onContextMenuClick = () => setAnchorEl(contextButtonRef.current);
const onContextMenuAction = React.useCallback( const onContextMenuAction = React.useCallback(
async (event, action: { [key: string]: string }) => { async (event, action) => {
if (action.type === 'delete') { if (action.type === 'delete') {
await deleteConnection({ await deleteConnection({
variables: { input: { id } }, variables: { input: { id } },
@@ -73,13 +63,11 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
__typename: 'Connection', __typename: 'Connection',
id, id,
}); });
cache.evict({ cache.evict({
id: connectionCacheId, id: connectionCacheId,
}); });
}, },
}); });
enqueueSnackbar(formatMessage('connection.deletedMessage'), { enqueueSnackbar(formatMessage('connection.deletedMessage'), {
variant: 'success', variant: 'success',
SnackbarProps: { SnackbarProps: {
@@ -91,13 +79,11 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
testConnection({ variables: { id } }); testConnection({ variables: { id } });
} }
}, },
[deleteConnection, id, testConnection, formatMessage, enqueueSnackbar] [deleteConnection, id, testConnection, formatMessage, enqueueSnackbar],
); );
const relativeCreatedAt = DateTime.fromMillis( const relativeCreatedAt = DateTime.fromMillis(
parseInt(createdAt, 10) parseInt(createdAt, 10),
).toRelative(); ).toRelative();
return ( return (
<> <>
<Card sx={{ my: 2 }} data-test="app-connection-row"> <Card sx={{ my: 2 }} data-test="app-connection-row">
@@ -125,14 +111,17 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
</Typography> </Typography>
</> </>
)} )}
{verificationVisible && testCalled && !testLoading && verified && ( {verificationVisible &&
<> testCalled &&
<CheckCircleIcon fontSize="small" color="success" /> !testLoading &&
<Typography variant="caption"> verified && (
{formatMessage('connection.testSuccessful')} <>
</Typography> <CheckCircleIcon fontSize="small" color="success" />
</> <Typography variant="caption">
)} {formatMessage('connection.testSuccessful')}
</Typography>
</>
)}
{verificationVisible && {verificationVisible &&
testCalled && testCalled &&
!testLoading && !testLoading &&
@@ -180,4 +169,8 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
); );
} }
AppConnectionRow.propTypes = {
connection: ConnectionPropType.isRequired,
};
export default AppConnectionRow; export default AppConnectionRow;

View File

@@ -1,7 +1,6 @@
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import MuiCardContent from '@mui/material/CardContent'; import MuiCardContent from '@mui/material/CardContent';
import MuiTypography from '@mui/material/Typography'; import MuiTypography from '@mui/material/Typography';
export const CardContent = styled(MuiCardContent)(({ theme }) => ({ export const CardContent = styled(MuiCardContent)(({ theme }) => ({
display: 'grid', display: 'grid',
gridTemplateRows: 'auto', gridTemplateRows: 'auto',
@@ -9,7 +8,6 @@ export const CardContent = styled(MuiCardContent)(({ theme }) => ({
gridColumnGap: theme.spacing(2), gridColumnGap: theme.spacing(2),
alignItems: 'center', alignItems: 'center',
})); }));
export const Typography = styled(MuiTypography)(() => ({ export const Typography = styled(MuiTypography)(() => ({
textAlign: 'center', textAlign: 'center',
display: 'inline-block', display: 'inline-block',

View File

@@ -1,29 +1,20 @@
import * as React from 'react'; import * as React from 'react';
import PropTypes from 'prop-types';
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import type { IConnection } from 'types';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections'; import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
import AppConnectionRow from 'components/AppConnectionRow'; import AppConnectionRow from 'components/AppConnectionRow';
import NoResultFound from 'components/NoResultFound'; import NoResultFound from 'components/NoResultFound';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
type AppConnectionsProps = { function AppConnections(props) {
appKey: string;
};
export default function AppConnections(
props: AppConnectionsProps
): React.ReactElement {
const { appKey } = props; const { appKey } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { data } = useQuery(GET_APP_CONNECTIONS, { const { data } = useQuery(GET_APP_CONNECTIONS, {
variables: { key: appKey }, variables: { key: appKey },
}); });
const appConnections: IConnection[] = data?.getApp?.connections || []; const appConnections = data?.getApp?.connections || [];
const hasConnections = appConnections?.length; const hasConnections = appConnections?.length;
if (!hasConnections) { if (!hasConnections) {
return ( return (
<NoResultFound <NoResultFound
@@ -36,9 +27,15 @@ export default function AppConnections(
return ( return (
<> <>
{appConnections.map((appConnection: IConnection) => ( {appConnections.map((appConnection) => (
<AppConnectionRow key={appConnection.id} connection={appConnection} /> <AppConnectionRow key={appConnection.id} connection={appConnection} />
))} ))}
</> </>
); );
} }
AppConnections.propTypes = {
appKey: PropTypes.string.isRequired,
};
export default AppConnections;

View File

@@ -1,27 +1,19 @@
import PropTypes from 'prop-types';
import { useQuery } from '@apollo/client'; import { useQuery } from '@apollo/client';
import { Link, useSearchParams } from 'react-router-dom'; import { Link, useSearchParams } from 'react-router-dom';
import { GET_FLOWS } from 'graphql/queries/get-flows'; import { GET_FLOWS } from 'graphql/queries/get-flows';
import Pagination from '@mui/material/Pagination'; import Pagination from '@mui/material/Pagination';
import PaginationItem from '@mui/material/PaginationItem'; import PaginationItem from '@mui/material/PaginationItem';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import AppFlowRow from 'components/FlowRow'; import AppFlowRow from 'components/FlowRow';
import NoResultFound from 'components/NoResultFound'; import NoResultFound from 'components/NoResultFound';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import type { IFlow } from 'types';
type AppFlowsProps = {
appKey: string;
};
const FLOW_PER_PAGE = 10; const FLOW_PER_PAGE = 10;
const getLimitAndOffset = (page) => ({
const getLimitAndOffset = (page: number) => ({
limit: FLOW_PER_PAGE, limit: FLOW_PER_PAGE,
offset: (page - 1) * FLOW_PER_PAGE, offset: (page - 1) * FLOW_PER_PAGE,
}); });
function AppFlows(props) {
export default function AppFlows(props: AppFlowsProps): React.ReactElement {
const { appKey } = props; const { appKey } = props;
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@@ -36,10 +28,8 @@ export default function AppFlows(props: AppFlowsProps): React.ReactElement {
}); });
const getFlows = data?.getFlows || {}; const getFlows = data?.getFlows || {};
const { pageInfo, edges } = getFlows; const { pageInfo, edges } = getFlows;
const flows = edges?.map(({ node }) => node);
const flows: IFlow[] = edges?.map(({ node }: { node: IFlow }) => node);
const hasFlows = flows?.length; const hasFlows = flows?.length;
if (!hasFlows) { if (!hasFlows) {
return ( return (
<NoResultFound <NoResultFound
@@ -49,10 +39,9 @@ export default function AppFlows(props: AppFlowsProps): React.ReactElement {
/> />
); );
} }
return ( return (
<> <>
{flows?.map((appFlow: IFlow) => ( {flows?.map((appFlow) => (
<AppFlowRow key={appFlow.id} flow={appFlow} /> <AppFlowRow key={appFlow.id} flow={appFlow} />
))} ))}
@@ -74,3 +63,9 @@ export default function AppFlows(props: AppFlowsProps): React.ReactElement {
</> </>
); );
} }
AppFlows.propTypes = {
appKey: PropTypes.string.isRequired,
};
export default AppFlows;

View File

@@ -1,25 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import PropTypes from 'prop-types';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import type { AvatarProps } from '@mui/material/Avatar'; const inlineImgStyle = {
type AppIconProps = {
name?: string;
url?: string;
color?: string;
variant?: AvatarProps['variant'];
};
const inlineImgStyle: React.CSSProperties = {
objectFit: 'contain', objectFit: 'contain',
}; };
function AppIcon(props) {
export default function AppIcon(
props: AppIconProps & AvatarProps
): React.ReactElement {
const { name, url, color, sx = {}, variant = 'square', ...restProps } = props; const { name, url, color, sx = {}, variant = 'square', ...restProps } = props;
const initialLetter = name?.[0]; const initialLetter = name?.[0];
return ( return (
<Avatar <Avatar
component="span" component="span"
@@ -28,8 +15,22 @@ export default function AppIcon(
imgProps={{ style: inlineImgStyle }} imgProps={{ style: inlineImgStyle }}
src={url} src={url}
alt={name} alt={name}
children={initialLetter}
{...restProps} {...restProps}
/> >
{initialLetter}
</Avatar>
); );
} }
AppIcon.propTypes = {
name: PropTypes.string,
url: PropTypes.string,
color: PropTypes.string,
variant: PropTypes.oneOfType([
PropTypes.oneOf(['circular', 'rounded', 'square']),
PropTypes.string,
]),
sx: PropTypes.object,
};
export default AppIcon;

Some files were not shown because too many files have changed in this diff Show More