Compare commits

...

78 Commits

Author SHA1 Message Date
kasia.oczkowska
ec5ef28a7a feat: increase Snackbar z-index so it appears over chatbot icon 2024-03-07 09:54:24 +01: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
Faruk AYDIN
54282ba7e0 feat: Use new API endpoint from Helix 2024-02-28 02:23:50 +01:00
Ömer Faruk Aydın
7f324abd44 Merge pull request #1669 from automatisch/helix-attemtps
fix: Add artificial delay to Helix API attempts
2024-02-27 23:49:05 +01:00
Faruk AYDIN
65a0c3b40a fix: Add artificial delay to Helix API attempts 2024-02-27 23:44:52 +01:00
Ömer Faruk Aydın
2449baac5b Merge pull request #1668 from automatisch/document-datastore
docs: Add datastore app to the integration list
2024-02-27 22:10:52 +01:00
Faruk AYDIN
0ab03e1856 docs: Add datastore app to the integration list 2024-02-27 21:44:51 +01:00
Ömer Faruk Aydın
9a3f85106c Merge pull request #1667 from automatisch/fix-helix-integration
fix: Stop asking to helix server after 50 attempts
2024-02-27 19:17:04 +01:00
Faruk AYDIN
42c495d8ab fix: Stop asking to helix server after 50 attempts 2024-02-27 19:14:19 +01:00
Ömer Faruk Aydın
58def585f1 Merge pull request #1666 from automatisch/datastore-app
feat: Implement datastore built-in app
2024-02-27 19:13:26 +01:00
Faruk AYDIN
047034d831 fix: Remove min length validation from value of datastore 2024-02-27 19:05:27 +01:00
Faruk AYDIN
bdb2f24a81 feat: Implement datastore built-in app 2024-02-27 19:01:46 +01:00
422 changed files with 4616 additions and 5487 deletions

View File

@@ -28,7 +28,7 @@ cd packages/web
rm -rf .env
echo "
PORT=$WEB_PORT
REACT_APP_GRAPHQL_URL=http://localhost:$BACKEND_PORT/graphql
REACT_APP_BACKEND_URL=http://localhost:$BACKEND_PORT
" >> .env
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 workflow is now ready to test your code on the runner."
- run: yarn --frozen-lockfile
- run: yarn lint
- run: cd packages/backend && yarn lint
- run: echo "🍏 This job's status is ${{ job.status }}."
start-backend-server:
runs-on: ubuntu-latest

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Get value',
key: 'getValue',
description: 'Get value from the persistent datastore.',
arguments: [
{
label: 'Key',
key: 'key',
type: 'string',
required: true,
description: 'The key of your value to get.',
variables: true,
},
],
async run($) {
const keyValuePair = await $.datastore.get({
key: $.step.parameters.key,
});
$.setActionItem({
raw: keyValuePair,
});
},
});

View File

@@ -0,0 +1,4 @@
import getValue from './get-value/index.js';
import setValue from './set-value/index.js';
export default [getValue, setValue];

View File

@@ -0,0 +1,36 @@
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
name: 'Set value',
key: 'setValue',
description: 'Set value to the persistent datastore.',
arguments: [
{
label: 'Key',
key: 'key',
type: 'string',
required: true,
description: 'The key of your value to set.',
variables: true,
},
{
label: 'Value',
key: 'value',
type: 'string',
required: true,
description: 'The value to set.',
variables: true,
},
],
async run($) {
const keyValuePair = await $.datastore.set({
key: $.step.parameters.key,
value: $.step.parameters.value,
});
$.setActionItem({
raw: keyValuePair,
});
},
});

View File

@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" id="icon">
<defs>
<style>.cls-1{fill:none;}</style>
</defs>
<title>datastore</title>
<circle cx="23" cy="23" r="1"/>
<rect x="8" y="22" width="12" height="2"/>
<circle cx="23" cy="9" r="1"/>
<rect x="8" y="8" width="12" height="2"/>
<path d="M26,14a2,2,0,0,0,2-2V6a2,2,0,0,0-2-2H6A2,2,0,0,0,4,6v6a2,2,0,0,0,2,2H8v4H6a2,2,0,0,0-2,2v6a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V20a2,2,0,0,0-2-2H24V14ZM6,6H26v6H6ZM26,26H6V20H26Zm-4-8H10V14H22Z"/>
<rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,14 @@
import defineApp from '../../helpers/define-app.js';
import actions from './actions/index.js';
export default defineApp({
name: 'Datastore',
key: 'datastore',
iconUrl: '{BASE_URL}/apps/datastore/assets/favicon.svg',
authDocUrl: 'https://automatisch.io/docs/apps/datastore/connection',
supportsConnections: false,
baseUrl: '',
apiBaseUrl: '',
primaryColor: '001F52',
actions,
});

View File

@@ -1,4 +1,3 @@
import FormData from 'form-data';
import defineAction from '../../../../helpers/define-action.js';
export default defineAction({
@@ -6,45 +5,51 @@ export default defineAction({
key: 'newChat',
description: 'Create a new chat session for Helix AI.',
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',
key: 'input',
type: 'string',
required: true,
description: 'Prompt to start the chat with.',
description: 'User input to start the chat with.',
variables: true,
},
],
async run($) {
const formData = new FormData();
formData.append('input', $.step.parameters.input);
formData.append('mode', 'inference');
formData.append('type', 'text');
const sessionResponse = await $.http.post('/api/v1/sessions', formData, {
headers: {
...formData.getHeaders(),
},
const response = await $.http.post('/api/v1/sessions/chat', {
session_id: $.step.parameters.sessionId,
system: $.step.parameters.systemPrompt,
messages: [
{
role: 'user',
content: {
content_type: 'text',
parts: [$.step.parameters.input],
},
},
],
});
const sessionId = sessionResponse.data.id;
let chatGenerated = false;
while (!chatGenerated) {
const response = await $.http.get(`/api/v1/sessions/${sessionId}`);
const message =
response.data.interactions[response.data.interactions.length - 1];
if (message.creator === 'system' && message.state === 'complete') {
$.setActionItem({
raw: message,
});
chatGenerated = true;
}
}
$.setActionItem({
raw: response.data,
});
},
});

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 app from '../../../../app.js';
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 getCurrentUserMock from '../../../../../test/mocks/rest/api/v1/users/get-current-user';
describe('GET /api/v1/users/me', () => {
let role, currentUser, token;
let role, permissionOne, permissionTwo, currentUser, token;
beforeEach(async () => {
currentUser = await createUser();
role = await currentUser.$relatedQuery('role');
role = await createRole();
permissionOne = await createPermission({
roleId: role.id,
});
permissionTwo = await createPermission({
roleId: role.id,
});
currentUser = await createUser({
roleId: role.id,
});
token = createAuthTokenByUserId(currentUser.id);
});
@@ -20,7 +34,11 @@ describe('GET /api/v1/users/me', () => {
.set('Authorization', token)
.expect(200);
const expectedPayload = getCurrentUserMock(currentUser, role);
const expectedPayload = getCurrentUserMock(currentUser, role, [
permissionOne,
permissionTwo,
]);
expect(response.body).toEqual(expectedPayload);
});
});

View File

@@ -0,0 +1,16 @@
export async function up(knex) {
return knex.schema.createTable('datastore', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('key').notNullable();
table.string('value');
table.string('scope').notNullable();
table.uuid('scope_id').notNullable();
table.index(['key', 'scope', 'scope_id']);
table.timestamps(true, true);
});
}
export async function down(knex) {
return knex.schema.dropTable('datastore');
}

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 getAppConfig from './queries/get-app-config.ee.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 getConfig from './queries/get-config.ee.js';
import getConnectedApps from './queries/get-connected-apps.js';
@@ -39,7 +38,6 @@ const queryResolvers = {
getAppAuthClients,
getAppConfig,
getApps,
getAutomatischInfo,
getBillingAndUsage,
getConfig,
getConnectedApps,

View File

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

View File

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

View File

@@ -11,6 +11,18 @@ const authorizationList = {
action: 'read',
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) => {

View File

@@ -1,5 +1,6 @@
import logger from './logger.js';
import objection from 'objection';
import * as Sentry from './sentry.ee.js';
const { NotFoundError, DataError } = objection;
// 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();
}
logger.error(error.message + '\n' + error.stack);
response.status(error.statusCode || 500).end();
const statusCode = error.statusCode || 500;
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) => {

View File

@@ -1,6 +1,7 @@
import createHttpClient from './http-client/index.js';
import EarlyExitError from '../errors/early-exit.js';
import AlreadyProcessedError from '../errors/already-processed.js';
import Datastore from '../models/datastore.js';
const globalVariable = async (options) => {
const {
@@ -88,6 +89,43 @@ const globalVariable = async (options) => {
setActionItem: (actionItem) => {
$.actionOutput.data = actionItem;
},
datastore: {
get: async ({ key }) => {
const datastore = await Datastore.query().findOne({
key,
scope: 'flow',
scope_id: $.flow.id,
});
return {
key: datastore.key,
value: datastore.value,
[datastore.key]: datastore.value,
};
},
set: async ({ key, value }) => {
let datastore = await Datastore.query()
.where({ key, scope: 'flow', scope_id: $.flow.id })
.first();
if (datastore) {
await datastore.$query().patchAndFetch({ value: value });
} else {
datastore = await Datastore.query().insert({
key,
value,
scope: 'flow',
scopeId: $.flow.id,
});
}
return {
key: datastore.key,
value: datastore.value,
[datastore.key]: datastore.value,
};
},
},
};
if (request) {

View File

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

View File

@@ -39,6 +39,47 @@ class App {
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) {
const app = await this.findOneByKey(appKey);

View File

@@ -0,0 +1,24 @@
import Base from './base.js';
class Datastore extends Base {
static tableName = 'datastore';
static jsonSchema = {
type: 'object',
required: ['key', 'value', 'scope', 'scopeId'],
properties: {
id: { type: 'string', format: 'uuid' },
key: { type: 'string', minLength: 1 },
value: { type: 'string' },
scope: {
type: 'string',
enum: ['flow'],
default: 'flow',
},
scopeId: { type: 'string', format: 'uuid' },
},
};
}
export default Datastore;

View File

@@ -149,6 +149,13 @@ class User extends Base {
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) {
return bcrypt.compare(password, this.password);
}

View File

@@ -2,9 +2,41 @@ import { Router } from 'express';
import asyncHandler from 'express-async-handler';
import { authenticateUser } from '../../../helpers/authentication.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();
router.get('/', authenticateUser, asyncHandler(getAppsAction));
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;

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 flowsRouter from './api/v1/flows.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 rolesRouter from './api/v1/admin/roles.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/flows', flowsRouter);
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/roles', rolesRouter);
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';
describe('appSerializer', () => {
it('should return permission data', async () => {
it('should return app data', async () => {
const app = await App.findOneByKey('deepl');
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,
};
if (flow.steps) {
if (flow.steps?.length > 0) {
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 stepSerializer from './step.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 = {
User: userSerializer,
@@ -16,6 +21,11 @@ const serializers = {
Flow: flowSerializer,
Step: stepSerializer,
App: appSerializer,
Auth: authSerializer,
Trigger: triggerSerializer,
Action: actionSerializer,
Execution: executionSerializer,
ExecutionStep: executionStepSerializer,
};
export default serializers;

View File

@@ -11,7 +11,7 @@ const roleSerializer = (role) => {
isAdmin: role.isAdmin,
};
if (role.permissions) {
if (role.permissions?.length > 0) {
roleData.permissions = role.permissions.map((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);
}
if (user.permissions) {
if (user.permissions?.length > 0) {
userData.permissions = user.permissions.map((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 {
data: {
createdAt: currentUser.createdAt.getTime(),
email: currentUser.email,
fullName: currentUser.fullName,
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: {
createdAt: role.createdAt.getTime(),
description: null,

View File

@@ -41,6 +41,15 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/carbone/connection' },
],
},
{
text: 'Datastore',
collapsible: true,
collapsed: true,
items: [
{ text: 'Actions', link: '/apps/datastore/actions' },
{ text: 'Connection', link: '/apps/datastore/connection' },
],
},
{
text: 'DeepL',
collapsible: true,
@@ -305,7 +314,7 @@ export default defineConfig({
collapsed: true,
items: [
{ text: 'Actions', link: '/apps/removebg/actions' },
{ text: 'Connection', link: '/apps/removebg/connection' }
{ text: 'Connection', link: '/apps/removebg/connection' },
],
},
{

View File

@@ -0,0 +1,14 @@
---
favicon: /favicons/datastore.svg
items:
- name: Get value
desc: Get value from the persistent datastore.
- name: Set value
desc: Set value to the persistent datastore.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -0,0 +1,3 @@
# Datastore
Datastore is a persistent key-value storage system that allows you to store and retrieve data. Currently you can use it within the scope of the flow, meaning you can store and retrieve data within the same flow.

View File

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

View File

@@ -0,0 +1,13 @@
<?xml version="1.0"?>
<svg xmlns="http://www.w3.org/2000/svg" fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" id="icon">
<defs>
<style>.cls-1{fill:none;}</style>
</defs>
<title>datastore</title>
<circle cx="23" cy="23" r="1"/>
<rect x="8" y="22" width="12" height="2"/>
<circle cx="23" cy="9" r="1"/>
<rect x="8" y="8" width="12" height="2"/>
<path d="M26,14a2,2,0,0,0,2-2V6a2,2,0,0,0-2-2H6A2,2,0,0,0,4,6v6a2,2,0,0,0,2,2H8v4H6a2,2,0,0,0-2,2v6a2,2,0,0,0,2,2H26a2,2,0,0,0,2-2V20a2,2,0,0,0-2-2H24V14ZM6,6H26v6H6ZM26,26H6V20H26Zm-4-8H10V14H22Z"/>
<rect id="_Transparent_Rectangle_" data-name="&lt;Transparent Rectangle&gt;" class="cls-1" width="32" height="32"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

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

View File

@@ -28,8 +28,6 @@
"@playwright/test": "^1.36.2"
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^5.9.1",
"@typescript-eslint/parser": "^5.9.1",
"dotenv": "^16.3.1",
"eslint": "^8.13.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.roleInput.click();
await adminCreateUserPage.page
.getByRole('option', { name: 'Delete Role' })
.getByRole('option', { name: 'Delete Role', exact: true })
.click();
await adminCreateUserPage.createButton.click();
await adminUsersPage.snackbar.waitFor({

View File

@@ -1,4 +1,4 @@
PORT=3001
REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql
REACT_APP_BACKEND_URL=http://localhost:3000
# HTTPS=true
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

@@ -7,23 +7,17 @@
"@apollo/client": "^3.6.9",
"@casl/ability": "^6.5.0",
"@casl/react": "^3.1.0",
"@emotion/css": "^11.11.2",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.8",
"@mui/icons-material": "^5.11.9",
"@mui/lab": "^5.0.0-alpha.120",
"@mui/material": "^5.11.10",
"@tanstack/react-query": "^5.24.1",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@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",
"compare-versions": "^4.1.3",
"graphql": "^15.6.0",
@@ -31,8 +25,8 @@
"luxon": "^2.3.1",
"mui-color-input": "^2.0.0",
"notistack": "^3.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.45.2",
"react-intl": "^5.20.12",
"react-json-tree": "^0.16.2",
@@ -42,7 +36,6 @@
"slate": "^0.94.1",
"slate-history": "^0.93.0",
"slate-react": "^0.94.2",
"typescript": "^4.6.3",
"uuid": "^9.0.0",
"web-vitals": "^1.0.1",
"yup": "^0.32.11"
@@ -54,8 +47,8 @@
"build:watch": "yarn nodemon --exec react-scripts build --watch 'src/**/*.ts' --watch 'public/**/*' --ext ts,html",
"test": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint . --ignore-path ../../.eslintignore",
"prepack": "REACT_APP_GRAPHQL_URL=/graphql yarn build"
"lint": "eslint src --ext .js,.jsx",
"prepack": "yarn build"
},
"files": [
"/build"
@@ -87,5 +80,17 @@
},
"publishConfig": {
"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 AdminSettingsLayout from 'components/AdminSettingsLayout';
import Users from 'pages/Users';
import EditUser from 'pages/EditUser';
import CreateUser from 'pages/CreateUser';
@@ -8,12 +8,10 @@ import CreateRole from 'pages/CreateRole/index.ee';
import EditRole from 'pages/EditRole/index.ee';
import Authentication from 'pages/Authentication';
import UserInterface from 'pages/UserInterface';
import * as URLS from 'config/urls';
import Can from 'components/Can';
import AdminApplications from 'pages/AdminApplications';
import AdminApplication from 'pages/AdminApplication';
// TODO: consider introducing redirections to `/` as fallback
export default (
<>
@@ -21,9 +19,7 @@ export default (
path={URLS.USERS}
element={
<Can I="read" a="User">
<AdminSettingsLayout>
<Users />
</AdminSettingsLayout>
<Users />
</Can>
}
/>
@@ -32,9 +28,7 @@ export default (
path={URLS.CREATE_USER}
element={
<Can I="create" a="User">
<AdminSettingsLayout>
<CreateUser />
</AdminSettingsLayout>
<CreateUser />
</Can>
}
/>
@@ -43,9 +37,7 @@ export default (
path={URLS.USER_PATTERN}
element={
<Can I="update" a="User">
<AdminSettingsLayout>
<EditUser />
</AdminSettingsLayout>
<EditUser />
</Can>
}
/>
@@ -54,9 +46,7 @@ export default (
path={URLS.ROLES}
element={
<Can I="read" a="Role">
<AdminSettingsLayout>
<Roles />
</AdminSettingsLayout>
<Roles />
</Can>
}
/>
@@ -65,9 +55,7 @@ export default (
path={URLS.CREATE_ROLE}
element={
<Can I="create" a="Role">
<AdminSettingsLayout>
<CreateRole />
</AdminSettingsLayout>
<CreateRole />
</Can>
}
/>
@@ -76,9 +64,7 @@ export default (
path={URLS.ROLE_PATTERN}
element={
<Can I="update" a="Role">
<AdminSettingsLayout>
<EditRole />
</AdminSettingsLayout>
<EditRole />
</Can>
}
/>
@@ -87,9 +73,7 @@ export default (
path={URLS.USER_INTERFACE}
element={
<Can I="update" a="Config">
<AdminSettingsLayout>
<UserInterface />
</AdminSettingsLayout>
<UserInterface />
</Can>
}
/>
@@ -100,9 +84,7 @@ export default (
<Can I="read" a="SamlAuthProvider">
<Can I="update" a="SamlAuthProvider">
<Can I="create" a="SamlAuthProvider">
<AdminSettingsLayout>
<Authentication />
</AdminSettingsLayout>
<Authentication />
</Can>
</Can>
</Can>
@@ -113,9 +95,7 @@ export default (
path={URLS.ADMIN_APPS}
element={
<Can I="update" a="App">
<AdminSettingsLayout>
<AdminApplications />
</AdminSettingsLayout>
<AdminApplications />
</Can>
}
/>
@@ -124,9 +104,7 @@ export default (
path={`${URLS.ADMIN_APP_PATTERN}/*`}
element={
<Can I="update" a="App">
<AdminSettingsLayout>
<AdminApplication />
</AdminSettingsLayout>
<AdminApplication />
</Can>
}
/>

View File

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

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

View File

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

View File

@@ -1,5 +1,5 @@
import React from 'react';
import type { IField } from 'types';
import * as React from 'react';
import PropTypes from 'prop-types';
import LoadingButton from '@mui/lab/LoadingButton';
import Alert from '@mui/material/Alert';
import Dialog from '@mui/material/Dialog';
@@ -7,32 +7,16 @@ import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import CircularProgress from '@mui/material/CircularProgress';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import type { UseFormProps } from 'react-hook-form';
import type { ApolloError } from '@apollo/client';
import { ApolloError } from '@apollo/client';
import { FieldPropType } from 'propTypes/propTypes';
import useFormatMessage from 'hooks/useFormatMessage';
import InputCreator from 'components/InputCreator';
import Switch from 'components/Switch';
import TextField from 'components/TextField';
import { Form } from './style';
type AdminApplicationAuthClientDialogProps = {
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 {
function AdminApplicationAuthClientDialog(props) {
const {
error,
onClose,
@@ -45,7 +29,6 @@ export default function AdminApplicationAuthClientDialog(
disabled = false,
} = props;
const formatMessage = useFormatMessage();
return (
<Dialog open={true} onClose={onClose}>
<DialogTitle>{title}</DialogTitle>
@@ -80,7 +63,7 @@ export default function AdminApplicationAuthClientDialog(
label={formatMessage('authClient.inputName')}
fullWidth
/>
{authFields?.map((field: IField) => (
{authFields?.map((field) => (
<InputCreator key={field.key} schema={field} />
))}
<LoadingButton
@@ -102,3 +85,17 @@ export default function AdminApplicationAuthClientDialog(
</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 BaseForm from 'components/Form';
export const Form = styled(BaseForm)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',

View File

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

View File

@@ -1,24 +1,15 @@
import PropTypes from 'prop-types';
import React, { useCallback, useMemo } from 'react';
import type { IApp } from 'types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
import { useMutation } from '@apollo/client';
import { AppPropType } from 'propTypes/propTypes';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { CREATE_APP_AUTH_CLIENT } from 'graphql/mutations/create-app-auth-client';
import useAppConfig from 'hooks/useAppConfig.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
type AdminApplicationCreateAuthClientProps = {
appKey: string;
application: IApp;
onClose: () => void;
};
export default function AdminApplicationCreateAuthClient(
props: AdminApplicationCreateAuthClientProps
): React.ReactElement {
function AdminApplicationCreateAuthClient(props) {
const { appKey, application, onClose } = props;
const { auth } = application;
const formatMessage = useFormatMessage();
@@ -37,10 +28,8 @@ export default function AdminApplicationCreateAuthClient(
refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false },
});
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
const submitHandler = async (values) => {
let appConfigId = appConfig?.id;
if (!appConfigId) {
const { data: appConfigData } = await createAppConfig({
variables: {
@@ -54,9 +43,7 @@ export default function AdminApplicationCreateAuthClient(
});
appConfigId = appConfigData.createAppConfig.id;
}
const { name, active, ...formattedAuthDefaults } = values;
await createAppAuthClient({
variables: {
input: {
@@ -67,17 +54,13 @@ export default function AdminApplicationCreateAuthClient(
},
},
});
onClose();
};
const getAuthFieldsDefaultValues = useCallback(() => {
if (!auth?.fields) {
return {};
}
const defaultValues: {
[key: string]: any;
} = {};
const defaultValues = {};
auth.fields.forEach((field) => {
if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value;
@@ -87,16 +70,14 @@ export default function AdminApplicationCreateAuthClient(
});
return defaultValues;
}, [auth?.fields]);
const defaultValues = useMemo(
() => ({
name: '',
active: false,
...getAuthFieldsDefaultValues(),
}),
[getAuthFieldsDefaultValues]
[getAuthFieldsDefaultValues],
);
return (
<AdminApplicationAuthClientDialog
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 useAppConfig from 'hooks/useAppConfig.ee';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -6,39 +7,29 @@ import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import LoadingButton from '@mui/lab/LoadingButton';
import { useMutation } from '@apollo/client';
import { CREATE_APP_CONFIG } from 'graphql/mutations/create-app-config';
import { UPDATE_APP_CONFIG } from 'graphql/mutations/update-app-config';
import Form from 'components/Form';
import { Switch } from './style';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
type AdminApplicationSettingsProps = {
appKey: string;
};
function AdminApplicationSettings(
props: AdminApplicationSettingsProps
): React.ReactElement {
function AdminApplicationSettings(props) {
const { appConfig, loading } = useAppConfig(props.appKey);
const [createAppConfig, { loading: loadingCreateAppConfig }] = useMutation(
CREATE_APP_CONFIG,
{
refetchQueries: ['GetAppConfig'],
}
},
);
const [updateAppConfig, { loading: loadingUpdateAppConfig }] = useMutation(
UPDATE_APP_CONFIG,
{
refetchQueries: ['GetAppConfig'],
}
},
);
const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const handleSubmit = async (values: any) => {
const handleSubmit = async (values) => {
try {
if (!appConfig) {
await createAppConfig({
@@ -56,23 +47,21 @@ function AdminApplicationSettings(
enqueueSnackbar(formatMessage('adminAppsSettings.successfullySaved'), {
variant: 'success',
SnackbarProps: {
'data-test': 'snackbar-save-admin-apps-settings-success'
}
'data-test': 'snackbar-save-admin-apps-settings-success',
},
});
} catch (error) {
throw new Error('Failed while saving!');
}
};
const defaultValues = useMemo(
() => ({
allowCustomConnection: appConfig?.allowCustomConnection || false,
shared: appConfig?.shared || false,
disabled: appConfig?.disabled || false,
}),
[appConfig]
[appConfig],
);
return (
<Form
defaultValues={defaultValues}
@@ -123,4 +112,8 @@ function AdminApplicationSettings(
);
}
AdminApplicationSettings.propTypes = {
appKey: PropTypes.string.isRequired,
};
export default AdminApplicationSettings;

View File

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

View File

@@ -1,55 +1,52 @@
import PropTypes from 'prop-types';
import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import type { IApp } from 'types';
import { FieldValues, SubmitHandler } from 'react-hook-form';
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 AdminApplicationAuthClientDialog from 'components/AdminApplicationAuthClientDialog';
import useAdminAppAuthClient from 'hooks/useAdminAppAuthClient.ee';
type AdminApplicationUpdateAuthClientProps = {
application: IApp;
onClose: () => void;
};
export default function AdminApplicationUpdateAuthClient(
props: AdminApplicationUpdateAuthClientProps
): React.ReactElement {
function AdminApplicationUpdateAuthClient(props) {
const { application, onClose } = props;
const { auth } = application;
const authFields = auth?.fields?.map((field) => ({
...field,
required: false,
}));
const formatMessage = useFormatMessage();
const { clientId } = useParams();
const { appAuthClient, loading: loadingAuthClient } =
useAppAuthClient(clientId);
const { data: adminAppAuthClient, isLoading: isAdminAuthClientLoading } =
useAdminAppAuthClient(clientId);
const [updateAppAuthClient, { loading: loadingUpdateAppAuthClient, error }] =
useMutation(UPDATE_APP_AUTH_CLIENT, {
refetchQueries: ['GetAppAuthClients'],
context: { autoSnackbar: false },
});
const submitHandler: SubmitHandler<FieldValues> = async (values) => {
if (!appAuthClient) {
const authFields = auth?.fields?.map((field) => ({
...field,
required: false,
}));
const submitHandler = async (values) => {
if (!adminAppAuthClient) {
return;
}
const { name, active, ...formattedAuthDefaults } = values;
await updateAppAuthClient({
variables: {
input: {
id: appAuthClient.id,
id: adminAppAuthClient.data.id,
name,
active,
formattedAuthDefaults,
},
},
});
onClose();
};
@@ -57,9 +54,8 @@ export default function AdminApplicationUpdateAuthClient(
if (!authFields) {
return {};
}
const defaultValues: {
[key: string]: any;
} = {};
const defaultValues = {};
authFields.forEach((field) => {
if (field.value || field.type !== 'string') {
defaultValues[field.key] = field.value;
@@ -72,11 +68,11 @@ export default function AdminApplicationUpdateAuthClient(
const defaultValues = useMemo(
() => ({
name: appAuthClient?.name || '',
active: appAuthClient?.active || false,
name: adminAppAuthClient?.data?.name || '',
active: adminAppAuthClient?.data?.active || false,
...getAuthFieldsDefaultValues(),
}),
[appAuthClient, getAuthFieldsDefaultValues]
[adminAppAuthClient, getAuthFieldsDefaultValues],
);
return (
@@ -84,12 +80,19 @@ export default function AdminApplicationUpdateAuthClient(
onClose={onClose}
error={error}
title={formatMessage('updateAuthClient.title')}
loading={loadingAuthClient}
loading={isAdminAuthClientLoading}
submitHandler={submitHandler}
authFields={authFields}
submitting={loadingUpdateAppAuthClient}
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 BrushIcon from '@mui/icons-material/Brush';
import AppsIcon from '@mui/icons-material/Apps';
import { Outlet } from 'react-router-dom';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react';
import { SvgIconComponent } from '@mui/icons-material';
import AppBar from 'components/AppBar';
import Drawer from 'components/Drawer';
import Can from 'components/Can';
import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
type SettingsLayoutProps = {
children: React.ReactNode;
};
type DrawerLink = {
Icon: SvgIconComponent;
primary: string;
to: string;
};
function createDrawerLinks({
canReadRole,
canReadUser,
canUpdateConfig,
canManageSamlAuthProvider,
canUpdateApp,
}: {
canReadRole: boolean;
canReadUser: boolean;
canUpdateConfig: boolean;
canManageSamlAuthProvider: boolean;
canUpdateApp: boolean;
}) {
const items = [
canReadUser
@@ -82,20 +66,16 @@ function createDrawerLinks({
dataTest: 'apps-drawer-link',
}
: null,
].filter(Boolean) as DrawerLink[];
].filter(Boolean);
return items;
}
export default function SettingsLayout({
children,
}: SettingsLayoutProps): React.ReactElement {
function SettingsLayout() {
const theme = useTheme();
const formatMessage = useFormatMessage();
const currentUserAbility = useCurrentUserAbility();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'));
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
const drawerLinks = createDrawerLinks({
@@ -108,7 +88,6 @@ export default function SettingsLayout({
currentUserAbility.can('create', 'SamlAuthProvider'),
canUpdateApp: currentUserAbility.can('update', 'App'),
});
const drawerBottomLinks = [
{
Icon: ArrowBackIosNewIcon,
@@ -117,15 +96,13 @@ export default function SettingsLayout({
dataTest: 'go-back-drawer-link',
},
];
return (
<>
<Can I="read" a="User">
<AppBar
drawerOpen={isDrawerOpen}
onDrawerOpen={openDrawer}
onDrawerClose={closeDrawer}
/>
<Box sx={{ display: 'flex' }}>
<Drawer
links={drawerLinks}
@@ -134,13 +111,13 @@ export default function SettingsLayout({
onOpen={openDrawer}
onClose={closeDrawer}
/>
<Box sx={{ flex: 1 }}>
<Toolbar />
{children}
<Outlet />
</Box>
</Box>
</>
</Can>
);
}
export default SettingsLayout;

View File

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

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