diff --git a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js index d5224ca1..eac75b1a 100644 --- a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js +++ b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js @@ -35,9 +35,6 @@ export default defineTrigger({ }, ], - useSingletonWebhook: true, - singletonWebhookRefValueParameter: 'phoneNumberSid', - async run($) { const dataItem = { raw: $.request.body, diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js index edf0ff9a..5ae08ea4 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.js @@ -10,12 +10,11 @@ export default async (request, response) => { }; const appConfigParams = (request) => { - const { customConnectionAllowed, shared, disabled } = request.body; + const { useOnlyPredefinedAuthClients, disabled } = request.body; return { key: request.params.appKey, - customConnectionAllowed, - shared, + useOnlyPredefinedAuthClients, disabled, }; }; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js index 9d59a699..3ee2bab4 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-config.ee.test.js @@ -23,8 +23,7 @@ describe('POST /api/v1/admin/apps/:appKey/config', () => { it('should return created app config', async () => { const appConfig = { - customConnectionAllowed: true, - shared: true, + useOnlyPredefinedAuthClients: false, disabled: false, }; @@ -38,14 +37,14 @@ describe('POST /api/v1/admin/apps/:appKey/config', () => { ...appConfig, key: 'gitlab', }); + expect(response.body).toMatchObject(expectedPayload); }); it('should return HTTP 422 for already existing app config', async () => { const appConfig = { key: 'gitlab', - customConnectionAllowed: true, - shared: true, + useOnlyPredefinedAuthClients: false, disabled: false, }; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-auth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js similarity index 66% rename from packages/backend/src/controllers/api/v1/admin/apps/create-auth-client.ee.js rename to packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js index 49cbfff2..ffba9257 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/create-auth-client.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.js @@ -6,14 +6,14 @@ export default async (request, response) => { .findOne({ key: request.params.appKey }) .throwIfNotFound(); - const appAuthClient = await appConfig - .$relatedQuery('appAuthClients') - .insert(appAuthClientParams(request)); + const oauthClient = await appConfig + .$relatedQuery('oauthClients') + .insert(oauthClientParams(request)); - renderObject(response, appAuthClient, { status: 201 }); + renderObject(response, oauthClient, { status: 201 }); }; -const appAuthClientParams = (request) => { +const oauthClientParams = (request) => { const { active, appKey, name, formattedAuthDefaults } = request.body; return { diff --git a/packages/backend/src/controllers/api/v1/admin/apps/create-auth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js similarity index 81% rename from packages/backend/src/controllers/api/v1/admin/apps/create-auth-client.ee.test.js rename to packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js index ea658f88..4746a881 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/create-auth-client.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/create-oauth-client.ee.test.js @@ -5,11 +5,11 @@ import app from '../../../../../app.js'; import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../../test/factories/user.js'; import { createRole } from '../../../../../../test/factories/role.js'; -import createAppAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/create-auth-client.js'; +import createOAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/create-oauth-client.js'; import { createAppConfig } from '../../../../../../test/factories/app-config.js'; import * as license from '../../../../../helpers/license.ee.js'; -describe('POST /api/v1/admin/apps/:appKey/auth-clients', () => { +describe('POST /api/v1/admin/apps/:appKey/oauth-clients', () => { let currentUser, adminRole, token; beforeEach(async () => { @@ -26,7 +26,7 @@ describe('POST /api/v1/admin/apps/:appKey/auth-clients', () => { key: 'gitlab', }); - const appAuthClient = { + const oauthClient = { active: true, appKey: 'gitlab', name: 'First auth client', @@ -39,17 +39,17 @@ describe('POST /api/v1/admin/apps/:appKey/auth-clients', () => { }; const response = await request(app) - .post('/api/v1/admin/apps/gitlab/auth-clients') + .post('/api/v1/admin/apps/gitlab/oauth-clients') .set('Authorization', token) - .send(appAuthClient) + .send(oauthClient) .expect(201); - const expectedPayload = createAppAuthClientMock(appAuthClient); + const expectedPayload = createOAuthClientMock(oauthClient); expect(response.body).toMatchObject(expectedPayload); }); it('should return not found response for not existing app config', async () => { - const appAuthClient = { + const oauthClient = { active: true, appKey: 'gitlab', name: 'First auth client', @@ -62,9 +62,9 @@ describe('POST /api/v1/admin/apps/:appKey/auth-clients', () => { }; await request(app) - .post('/api/v1/admin/apps/gitlab/auth-clients') + .post('/api/v1/admin/apps/gitlab/oauth-clients') .set('Authorization', token) - .send(appAuthClient) + .send(oauthClient) .expect(404); }); @@ -73,14 +73,14 @@ describe('POST /api/v1/admin/apps/:appKey/auth-clients', () => { key: 'gitlab', }); - const appAuthClient = { + const oauthClient = { appKey: 'gitlab', }; const response = await request(app) - .post('/api/v1/admin/apps/gitlab/auth-clients') + .post('/api/v1/admin/apps/gitlab/oauth-clients') .set('Authorization', token) - .send(appAuthClient) + .send(oauthClient) .expect(422); expect(response.body.meta.type).toStrictEqual('ModelValidation'); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/get-auth-client.ee.js deleted file mode 100644 index c43ac23e..00000000 --- a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-client.ee.js +++ /dev/null @@ -1,11 +0,0 @@ -import { renderObject } from '../../../../../helpers/renderer.js'; -import AppAuthClient from '../../../../../models/app-auth-client.js'; - -export default async (request, response) => { - const appAuthClient = await AppAuthClient.query() - .findById(request.params.appAuthClientId) - .where({ app_key: request.params.appKey }) - .throwIfNotFound(); - - renderObject(response, appAuthClient); -}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js new file mode 100644 index 00000000..577461f2 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import OAuthClient from '../../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClient = await OAuthClient.query() + .findById(request.params.oauthClientId) + .where({ app_key: request.params.appKey }) + .throwIfNotFound(); + + renderObject(response, oauthClient); +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js similarity index 56% rename from packages/backend/src/controllers/api/v1/admin/apps/get-auth-client.ee.test.js rename to packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js index 2edb0ffe..5b30c289 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-client.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-client.ee.test.js @@ -5,12 +5,12 @@ import app from '../../../../../app.js'; import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../../test/factories/user.js'; import { createRole } from '../../../../../../test/factories/role.js'; -import getAppAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/get-auth-client.js'; -import { createAppAuthClient } from '../../../../../../test/factories/app-auth-client.js'; +import getOAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/get-oauth-client.js'; +import { createOAuthClient } from '../../../../../../test/factories/oauth-client.js'; import * as license from '../../../../../helpers/license.ee.js'; -describe('GET /api/v1/admin/apps/:appKey/auth-clients/:appAuthClientId', () => { - let currentUser, adminRole, currentAppAuthClient, token; +describe('GET /api/v1/admin/apps/:appKey/oauth-clients/:oauthClientId', () => { + let currentUser, adminRole, currentOAuthClient, token; beforeEach(async () => { vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); @@ -18,29 +18,29 @@ describe('GET /api/v1/admin/apps/:appKey/auth-clients/:appAuthClientId', () => { adminRole = await createRole({ name: 'Admin' }); currentUser = await createUser({ roleId: adminRole.id }); - currentAppAuthClient = await createAppAuthClient({ + currentOAuthClient = await createOAuthClient({ appKey: 'deepl', }); token = await createAuthTokenByUserId(currentUser.id); }); - it('should return specified app auth client', async () => { + it('should return specified oauth client', async () => { const response = await request(app) - .get(`/api/v1/admin/apps/deepl/auth-clients/${currentAppAuthClient.id}`) + .get(`/api/v1/admin/apps/deepl/oauth-clients/${currentOAuthClient.id}`) .set('Authorization', token) .expect(200); - const expectedPayload = getAppAuthClientMock(currentAppAuthClient); + const expectedPayload = getOAuthClientMock(currentOAuthClient); expect(response.body).toStrictEqual(expectedPayload); }); - it('should return not found response for not existing app auth client ID', async () => { - const notExistingAppAuthClientUUID = Crypto.randomUUID(); + it('should return not found response for not existing oauth client ID', async () => { + const notExistingOAuthClientUUID = Crypto.randomUUID(); await request(app) .get( - `/api/v1/admin/apps/deepl/auth-clients/${notExistingAppAuthClientUUID}` + `/api/v1/admin/apps/deepl/oauth-clients/${notExistingOAuthClientUUID}` ) .set('Authorization', token) .expect(404); @@ -48,7 +48,7 @@ describe('GET /api/v1/admin/apps/:appKey/auth-clients/:appAuthClientId', () => { it('should return bad request response for invalid UUID', async () => { await request(app) - .get('/api/v1/admin/apps/deepl/auth-clients/invalidAppAuthClientUUID') + .get('/api/v1/admin/apps/deepl/oauth-clients/invalidOAuthClientUUID') .set('Authorization', token) .expect(400); }); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-clients.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js similarity index 54% rename from packages/backend/src/controllers/api/v1/admin/apps/get-auth-clients.ee.js rename to packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js index 257e0dd7..230104a4 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-clients.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.js @@ -1,10 +1,10 @@ import { renderObject } from '../../../../../helpers/renderer.js'; -import AppAuthClient from '../../../../../models/app-auth-client.js'; +import OAuthClient from '../../../../../models/oauth-client.js'; export default async (request, response) => { - const appAuthClients = await AppAuthClient.query() + const oauthClients = await OAuthClient.query() .where({ app_key: request.params.appKey }) .orderBy('created_at', 'desc'); - renderObject(response, appAuthClients); + renderObject(response, oauthClients); }; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-clients.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js similarity index 62% rename from packages/backend/src/controllers/api/v1/admin/apps/get-auth-clients.ee.test.js rename to packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js index 7fbba6e0..69be2bbf 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/get-auth-clients.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/get-oauth-clients.ee.test.js @@ -4,11 +4,11 @@ import app from '../../../../../app.js'; import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../../test/factories/user.js'; import { createRole } from '../../../../../../test/factories/role.js'; -import getAuthClientsMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/get-auth-clients.js'; -import { createAppAuthClient } from '../../../../../../test/factories/app-auth-client.js'; +import getAdminOAuthClientsMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js'; +import { createOAuthClient } from '../../../../../../test/factories/oauth-client.js'; import * as license from '../../../../../helpers/license.ee.js'; -describe('GET /api/v1/admin/apps/:appKey/auth-clients', () => { +describe('GET /api/v1/admin/apps/:appKey/oauth-clients', () => { let currentUser, adminRole, token; beforeEach(async () => { @@ -20,23 +20,23 @@ describe('GET /api/v1/admin/apps/:appKey/auth-clients', () => { token = await createAuthTokenByUserId(currentUser.id); }); - it('should return specified app auth client info', async () => { - const appAuthClientOne = await createAppAuthClient({ + it('should return specified oauth client info', async () => { + const oauthClientOne = await createOAuthClient({ appKey: 'deepl', }); - const appAuthClientTwo = await createAppAuthClient({ + const oauthClientTwo = await createOAuthClient({ appKey: 'deepl', }); const response = await request(app) - .get('/api/v1/admin/apps/deepl/auth-clients') + .get('/api/v1/admin/apps/deepl/oauth-clients') .set('Authorization', token) .expect(200); - const expectedPayload = getAuthClientsMock([ - appAuthClientTwo, - appAuthClientOne, + const expectedPayload = getAdminOAuthClientsMock([ + oauthClientTwo, + oauthClientOne, ]); expect(response.body).toStrictEqual(expectedPayload); diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-auth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/update-auth-client.ee.js deleted file mode 100644 index a34e9a67..00000000 --- a/packages/backend/src/controllers/api/v1/admin/apps/update-auth-client.ee.js +++ /dev/null @@ -1,22 +0,0 @@ -import { renderObject } from '../../../../../helpers/renderer.js'; -import AppAuthClient from '../../../../../models/app-auth-client.js'; - -export default async (request, response) => { - const appAuthClient = await AppAuthClient.query() - .findById(request.params.appAuthClientId) - .throwIfNotFound(); - - await appAuthClient.$query().patchAndFetch(appAuthClientParams(request)); - - renderObject(response, appAuthClient); -}; - -const appAuthClientParams = (request) => { - const { active, name, formattedAuthDefaults } = request.body; - - return { - active, - name, - formattedAuthDefaults, - }; -}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js index 8475a264..c0d5160d 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.js @@ -17,11 +17,10 @@ export default async (request, response) => { }; const appConfigParams = (request) => { - const { customConnectionAllowed, shared, disabled } = request.body; + const { useOnlyPredefinedAuthClients, disabled } = request.body; return { - customConnectionAllowed, - shared, + useOnlyPredefinedAuthClients, disabled, }; }; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js index 3b1fb8ab..5894424d 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-config.ee.test.js @@ -24,17 +24,15 @@ describe('PATCH /api/v1/admin/apps/:appKey/config', () => { it('should return updated app config', async () => { const appConfig = { key: 'gitlab', - customConnectionAllowed: true, - shared: true, + useOnlyPredefinedAuthClients: true, disabled: false, }; await createAppConfig(appConfig); const newAppConfigValues = { - shared: false, disabled: true, - customConnectionAllowed: false, + useOnlyPredefinedAuthClients: false, }; const response = await request(app) @@ -53,9 +51,8 @@ describe('PATCH /api/v1/admin/apps/:appKey/config', () => { it('should return not found response for unexisting app config', async () => { const appConfig = { - shared: false, disabled: true, - customConnectionAllowed: false, + useOnlyPredefinedAuthClients: false, }; await request(app) @@ -68,8 +65,7 @@ describe('PATCH /api/v1/admin/apps/:appKey/config', () => { it('should return HTTP 422 for invalid app config data', async () => { const appConfig = { key: 'gitlab', - customConnectionAllowed: true, - shared: true, + useOnlyPredefinedAuthClients: true, disabled: false, }; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js new file mode 100644 index 00000000..7e9c3f7a --- /dev/null +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.js @@ -0,0 +1,22 @@ +import { renderObject } from '../../../../../helpers/renderer.js'; +import OAuthClient from '../../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClient = await OAuthClient.query() + .findById(request.params.oauthClientId) + .throwIfNotFound(); + + await oauthClient.$query().patchAndFetch(oauthClientParams(request)); + + renderObject(response, oauthClient); +}; + +const oauthClientParams = (request) => { + const { active, name, formattedAuthDefaults } = request.body; + + return { + active, + name, + formattedAuthDefaults, + }; +}; diff --git a/packages/backend/src/controllers/api/v1/admin/apps/update-auth-client.ee.test.js b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js similarity index 65% rename from packages/backend/src/controllers/api/v1/admin/apps/update-auth-client.ee.test.js rename to packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js index f1a7bccd..9d28bb34 100644 --- a/packages/backend/src/controllers/api/v1/admin/apps/update-auth-client.ee.test.js +++ b/packages/backend/src/controllers/api/v1/admin/apps/update-oauth-client.ee.test.js @@ -6,12 +6,12 @@ import app from '../../../../../app.js'; import createAuthTokenByUserId from '../../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../../test/factories/user.js'; import { createRole } from '../../../../../../test/factories/role.js'; -import updateAppAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/update-auth-client.js'; +import updateOAuthClientMock from '../../../../../../test/mocks/rest/api/v1/admin/apps/update-oauth-client.js'; import { createAppConfig } from '../../../../../../test/factories/app-config.js'; -import { createAppAuthClient } from '../../../../../../test/factories/app-auth-client.js'; +import { createOAuthClient } from '../../../../../../test/factories/oauth-client.js'; import * as license from '../../../../../helpers/license.ee.js'; -describe('PATCH /api/v1/admin/apps/:appKey/auth-clients', () => { +describe('PATCH /api/v1/admin/apps/:appKey/oauth-clients', () => { let currentUser, adminRole, token; beforeEach(async () => { @@ -27,8 +27,8 @@ describe('PATCH /api/v1/admin/apps/:appKey/auth-clients', () => { }); }); - it('should return updated entity for valid app auth client', async () => { - const appAuthClient = { + it('should return updated entity for valid oauth client', async () => { + const oauthClient = { active: true, appKey: 'gitlab', formattedAuthDefaults: { @@ -39,33 +39,33 @@ describe('PATCH /api/v1/admin/apps/:appKey/auth-clients', () => { }, }; - const existingAppAuthClient = await createAppAuthClient({ + const existingOAuthClient = await createOAuthClient({ appKey: 'gitlab', name: 'First auth client', }); const response = await request(app) .patch( - `/api/v1/admin/apps/gitlab/auth-clients/${existingAppAuthClient.id}` + `/api/v1/admin/apps/gitlab/oauth-clients/${existingOAuthClient.id}` ) .set('Authorization', token) - .send(appAuthClient) + .send(oauthClient) .expect(200); - const expectedPayload = updateAppAuthClientMock({ - ...existingAppAuthClient, - ...appAuthClient, + const expectedPayload = updateOAuthClientMock({ + ...existingOAuthClient, + ...oauthClient, }); expect(response.body).toMatchObject(expectedPayload); }); - it('should return not found response for not existing app auth client', async () => { - const notExistingAppAuthClientId = Crypto.randomUUID(); + it('should return not found response for not existing oauth client', async () => { + const notExistingOAuthClientId = Crypto.randomUUID(); await request(app) .patch( - `/api/v1/admin/apps/gitlab/auth-clients/${notExistingAppAuthClientId}` + `/api/v1/admin/apps/gitlab/oauth-clients/${notExistingOAuthClientId}` ) .set('Authorization', token) .expect(404); @@ -73,27 +73,27 @@ describe('PATCH /api/v1/admin/apps/:appKey/auth-clients', () => { it('should return bad request response for invalid UUID', async () => { await request(app) - .patch('/api/v1/admin/apps/gitlab/auth-clients/invalidAuthClientUUID') + .patch('/api/v1/admin/apps/gitlab/oauth-clients/invalidAuthClientUUID') .set('Authorization', token) .expect(400); }); it('should return HTTP 422 for invalid payload', async () => { - const appAuthClient = { + const oauthClient = { formattedAuthDefaults: 'invalid input', }; - const existingAppAuthClient = await createAppAuthClient({ + const existingOAuthClient = await createOAuthClient({ appKey: 'gitlab', name: 'First auth client', }); const response = await request(app) .patch( - `/api/v1/admin/apps/gitlab/auth-clients/${existingAppAuthClient.id}` + `/api/v1/admin/apps/gitlab/oauth-clients/${existingOAuthClient.id}` ) .set('Authorization', token) - .send(appAuthClient) + .send(oauthClient) .expect(422); expect(response.body.meta.type).toBe('ModelValidation'); diff --git a/packages/backend/src/controllers/api/v1/apps/create-connection.js b/packages/backend/src/controllers/api/v1/apps/create-connection.js index 40a081b9..35e3a34b 100644 --- a/packages/backend/src/controllers/api/v1/apps/create-connection.js +++ b/packages/backend/src/controllers/api/v1/apps/create-connection.js @@ -9,18 +9,18 @@ export default async (request, response) => { .$query() .withGraphFetched({ appConfig: true, - appAuthClient: true, + oauthClient: true, }); renderObject(response, connectionWithAppConfigAndAuthClient, { status: 201 }); }; const connectionParams = (request) => { - const { appAuthClientId, formattedData } = request.body; + const { oauthClientId, formattedData } = request.body; return { key: request.params.appKey, - appAuthClientId, + oauthClientId, formattedData, verified: false, }; diff --git a/packages/backend/src/controllers/api/v1/apps/create-connection.test.js b/packages/backend/src/controllers/api/v1/apps/create-connection.test.js index 4a12aa99..0465458f 100644 --- a/packages/backend/src/controllers/api/v1/apps/create-connection.test.js +++ b/packages/backend/src/controllers/api/v1/apps/create-connection.test.js @@ -3,7 +3,7 @@ import request from 'supertest'; import app from '../../../../app.js'; import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; import { createAppConfig } from '../../../../../test/factories/app-config.js'; -import { createAppAuthClient } from '../../../../../test/factories/app-auth-client.js'; +import { createOAuthClient } from '../../../../../test/factories/oauth-client.js'; import { createUser } from '../../../../../test/factories/user.js'; import { createPermission } from '../../../../../test/factories/permission.js'; import { createRole } from '../../../../../test/factories/role.js'; @@ -155,7 +155,7 @@ describe('POST /api/v1/apps/:appKey/connections', () => { await createAppConfig({ key: 'gitlab', disabled: false, - customConnectionAllowed: true, + useOnlyPredefinedAuthClients: false, }); }); @@ -218,7 +218,7 @@ describe('POST /api/v1/apps/:appKey/connections', () => { await createAppConfig({ key: 'gitlab', disabled: false, - customConnectionAllowed: false, + useOnlyPredefinedAuthClients: true, }); }); @@ -266,17 +266,17 @@ describe('POST /api/v1/apps/:appKey/connections', () => { }); }); - describe('with auth clients enabled', async () => { - let appAuthClient; + describe('with auth client enabled', async () => { + let oauthClient; beforeEach(async () => { await createAppConfig({ key: 'gitlab', disabled: false, - shared: true, + useOnlyPredefinedAuthClients: false, }); - appAuthClient = await createAppAuthClient({ + oauthClient = await createOAuthClient({ appKey: 'gitlab', active: true, formattedAuthDefaults: { @@ -290,7 +290,7 @@ describe('POST /api/v1/apps/:appKey/connections', () => { it('should return created connection', async () => { const connectionData = { - appAuthClientId: appAuthClient.id, + oauthClientId: oauthClient.id, }; const response = await request(app) @@ -310,19 +310,6 @@ describe('POST /api/v1/apps/:appKey/connections', () => { expect(response.body).toStrictEqual(expectedPayload); }); - it('should return not authorized response for appAuthClientId and formattedData together', async () => { - const connectionData = { - appAuthClientId: appAuthClient.id, - formattedData: {}, - }; - - await request(app) - .post('/api/v1/apps/gitlab/connections') - .set('Authorization', token) - .send(connectionData) - .expect(403); - }); - it('should return not found response for invalid app key', async () => { await request(app) .post('/api/v1/apps/invalid-app-key/connections') @@ -349,31 +336,33 @@ describe('POST /api/v1/apps/:appKey/connections', () => { }); }); }); - describe('with auth clients disabled', async () => { - let appAuthClient; + + describe('with auth client disabled', async () => { + let oauthClient; beforeEach(async () => { await createAppConfig({ key: 'gitlab', disabled: false, - shared: false, + useOnlyPredefinedAuthClients: false, }); - appAuthClient = await createAppAuthClient({ + oauthClient = await createOAuthClient({ appKey: 'gitlab', + active: false, }); }); it('should return with not authorized response', async () => { const connectionData = { - appAuthClientId: appAuthClient.id, + oauthClientId: oauthClient.id, }; await request(app) .post('/api/v1/apps/gitlab/connections') .set('Authorization', token) .send(connectionData) - .expect(403); + .expect(404); }); it('should return not found response for invalid app key', async () => { diff --git a/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js b/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js index bc28ae33..e3b6db03 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js +++ b/packages/backend/src/controllers/api/v1/apps/get-action-substeps.test.js @@ -15,7 +15,7 @@ describe('GET /api/v1/apps/:appKey/actions/:actionKey/substeps', () => { exampleApp = await App.findOneByKey('github'); }); - it('should return the app auth info', async () => { + it('should return the action substeps info', async () => { const actions = await App.findActionsByKey('github'); const exampleAction = actions.find( (action) => action.key === 'createIssue' diff --git a/packages/backend/src/controllers/api/v1/apps/get-auth-client.ee.js b/packages/backend/src/controllers/api/v1/apps/get-auth-client.ee.js deleted file mode 100644 index 5aceb529..00000000 --- a/packages/backend/src/controllers/api/v1/apps/get-auth-client.ee.js +++ /dev/null @@ -1,11 +0,0 @@ -import { renderObject } from '../../../../helpers/renderer.js'; -import AppAuthClient from '../../../../models/app-auth-client.js'; - -export default async (request, response) => { - const appAuthClient = await AppAuthClient.query() - .findById(request.params.appAuthClientId) - .where({ app_key: request.params.appKey, active: true }) - .throwIfNotFound(); - - renderObject(response, appAuthClient); -}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-config.ee.js b/packages/backend/src/controllers/api/v1/apps/get-config.ee.js index d0837e35..229c20d1 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-config.ee.js +++ b/packages/backend/src/controllers/api/v1/apps/get-config.ee.js @@ -4,7 +4,7 @@ import AppConfig from '../../../../models/app-config.js'; export default async (request, response) => { const appConfig = await AppConfig.query() .withGraphFetched({ - appAuthClients: true, + oauthClients: true, }) .findOne({ key: request.params.appKey, diff --git a/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js b/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js index 75c70b25..505e492f 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js +++ b/packages/backend/src/controllers/api/v1/apps/get-config.ee.test.js @@ -17,8 +17,7 @@ describe('GET /api/v1/apps/:appKey/config', () => { appConfig = await createAppConfig({ key: 'deepl', - customConnectionAllowed: true, - shared: true, + useOnlyPredefinedAuthClients: false, disabled: false, }); diff --git a/packages/backend/src/controllers/api/v1/apps/get-connections.js b/packages/backend/src/controllers/api/v1/apps/get-connections.js index 1f5a91ad..0f2fdfcb 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-connections.js +++ b/packages/backend/src/controllers/api/v1/apps/get-connections.js @@ -9,7 +9,7 @@ export default async (request, response) => { .select('connections.*') .withGraphFetched({ appConfig: true, - appAuthClient: true, + oauthClient: true, }) .fullOuterJoinRelated('steps') .where({ diff --git a/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js new file mode 100644 index 00000000..2577f27d --- /dev/null +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; +import OAuthClient from '../../../../models/oauth-client.js'; + +export default async (request, response) => { + const oauthClient = await OAuthClient.query() + .findById(request.params.oauthClientId) + .where({ app_key: request.params.appKey, active: true }) + .throwIfNotFound(); + + renderObject(response, oauthClient); +}; diff --git a/packages/backend/src/controllers/api/v1/apps/get-auth-client.ee.test.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js similarity index 54% rename from packages/backend/src/controllers/api/v1/apps/get-auth-client.ee.test.js rename to packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js index d5bea452..b39367f8 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-auth-client.ee.test.js +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-client.ee.test.js @@ -4,46 +4,46 @@ import Crypto from 'crypto'; import app from '../../../../app.js'; import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../test/factories/user.js'; -import getAppAuthClientMock from '../../../../../test/mocks/rest/api/v1/apps/get-auth-client.js'; -import { createAppAuthClient } from '../../../../../test/factories/app-auth-client.js'; +import getOAuthClientMock from '../../../../../test/mocks/rest/api/v1/apps/get-oauth-client.js'; +import { createOAuthClient } from '../../../../../test/factories/oauth-client.js'; import * as license from '../../../../helpers/license.ee.js'; -describe('GET /api/v1/apps/:appKey/auth-clients/:appAuthClientId', () => { - let currentUser, currentAppAuthClient, token; +describe('GET /api/v1/apps/:appKey/oauth-clients/:oauthClientId', () => { + let currentUser, currentOAuthClient, token; beforeEach(async () => { vi.spyOn(license, 'hasValidLicense').mockResolvedValue(true); currentUser = await createUser(); - currentAppAuthClient = await createAppAuthClient({ + currentOAuthClient = await createOAuthClient({ appKey: 'deepl', }); token = await createAuthTokenByUserId(currentUser.id); }); - it('should return specified app auth client', async () => { + it('should return specified oauth client', async () => { const response = await request(app) - .get(`/api/v1/apps/deepl/auth-clients/${currentAppAuthClient.id}`) + .get(`/api/v1/apps/deepl/oauth-clients/${currentOAuthClient.id}`) .set('Authorization', token) .expect(200); - const expectedPayload = getAppAuthClientMock(currentAppAuthClient); + const expectedPayload = getOAuthClientMock(currentOAuthClient); expect(response.body).toStrictEqual(expectedPayload); }); - it('should return not found response for not existing app auth client ID', async () => { - const notExistingAppAuthClientUUID = Crypto.randomUUID(); + it('should return not found response for not existing oauth client ID', async () => { + const notExistingOAuthClientUUID = Crypto.randomUUID(); await request(app) - .get(`/api/v1/apps/deepl/auth-clients/${notExistingAppAuthClientUUID}`) + .get(`/api/v1/apps/deepl/oauth-clients/${notExistingOAuthClientUUID}`) .set('Authorization', token) .expect(404); }); it('should return bad request response for invalid UUID', async () => { await request(app) - .get('/api/v1/apps/deepl/auth-clients/invalidAppAuthClientUUID') + .get('/api/v1/apps/deepl/oauth-clients/invalidOAuthClientUUID') .set('Authorization', token) .expect(400); }); diff --git a/packages/backend/src/controllers/api/v1/apps/get-auth-clients.ee.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js similarity index 56% rename from packages/backend/src/controllers/api/v1/apps/get-auth-clients.ee.js rename to packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js index 06eceec1..2a68737b 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-auth-clients.ee.js +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.js @@ -1,10 +1,10 @@ import { renderObject } from '../../../../helpers/renderer.js'; -import AppAuthClient from '../../../../models/app-auth-client.js'; +import OAuthClient from '../../../../models/oauth-client.js'; export default async (request, response) => { - const appAuthClients = await AppAuthClient.query() + const oauthClients = await OAuthClient.query() .where({ app_key: request.params.appKey, active: true }) .orderBy('created_at', 'desc'); - renderObject(response, appAuthClients); + renderObject(response, oauthClients); }; diff --git a/packages/backend/src/controllers/api/v1/apps/get-auth-clients.ee.test.js b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js similarity index 59% rename from packages/backend/src/controllers/api/v1/apps/get-auth-clients.ee.test.js rename to packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js index d84bf167..4e4b8508 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-auth-clients.ee.test.js +++ b/packages/backend/src/controllers/api/v1/apps/get-oauth-clients.ee.test.js @@ -3,11 +3,11 @@ import request from 'supertest'; import app from '../../../../app.js'; import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; import { createUser } from '../../../../../test/factories/user.js'; -import getAuthClientsMock from '../../../../../test/mocks/rest/api/v1/apps/get-auth-clients.js'; -import { createAppAuthClient } from '../../../../../test/factories/app-auth-client.js'; +import getOAuthClientsMock from '../../../../../test/mocks/rest/api/v1/apps/get-oauth-clients.js'; +import { createOAuthClient } from '../../../../../test/factories/oauth-client.js'; import * as license from '../../../../helpers/license.ee.js'; -describe('GET /api/v1/apps/:appKey/auth-clients', () => { +describe('GET /api/v1/apps/:appKey/oauth-clients', () => { let currentUser, token; beforeEach(async () => { @@ -18,23 +18,23 @@ describe('GET /api/v1/apps/:appKey/auth-clients', () => { token = await createAuthTokenByUserId(currentUser.id); }); - it('should return specified app auth client info', async () => { - const appAuthClientOne = await createAppAuthClient({ + it('should return specified oauth client info', async () => { + const oauthClientOne = await createOAuthClient({ appKey: 'deepl', }); - const appAuthClientTwo = await createAppAuthClient({ + const oauthClientTwo = await createOAuthClient({ appKey: 'deepl', }); const response = await request(app) - .get('/api/v1/apps/deepl/auth-clients') + .get('/api/v1/apps/deepl/oauth-clients') .set('Authorization', token) .expect(200); - const expectedPayload = getAuthClientsMock([ - appAuthClientTwo, - appAuthClientOne, + const expectedPayload = getOAuthClientsMock([ + oauthClientTwo, + oauthClientOne, ]); expect(response.body).toStrictEqual(expectedPayload); diff --git a/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js b/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js index 0748ee5a..e54b6de2 100644 --- a/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js +++ b/packages/backend/src/controllers/api/v1/apps/get-trigger-substeps.test.js @@ -15,7 +15,7 @@ describe('GET /api/v1/apps/:appKey/triggers/:triggerKey/substeps', () => { exampleApp = await App.findOneByKey('github'); }); - it('should return the app auth info', async () => { + it('should return the trigger substeps info', async () => { const triggers = await App.findTriggersByKey('github'); const exampleTrigger = triggers.find( (trigger) => trigger.key === 'newIssues' diff --git a/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js b/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js index ba4caaf9..2e94c5d6 100644 --- a/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js +++ b/packages/backend/src/controllers/api/v1/connections/reset-connection.test.js @@ -47,7 +47,6 @@ describe('POST /api/v1/connections/:connectionId/reset', () => { const expectedPayload = resetConnectionMock({ ...refetchedCurrentUserConnection, - reconnectable: refetchedCurrentUserConnection.reconnectable, formattedData: { screenName: 'Connection name', }, diff --git a/packages/backend/src/controllers/api/v1/connections/update-connection.js b/packages/backend/src/controllers/api/v1/connections/update-connection.js index 5d84e797..979aa733 100644 --- a/packages/backend/src/controllers/api/v1/connections/update-connection.js +++ b/packages/backend/src/controllers/api/v1/connections/update-connection.js @@ -14,6 +14,6 @@ export default async (request, response) => { }; const connectionParams = (request) => { - const { formattedData, appAuthClientId } = request.body; - return { formattedData, appAuthClientId }; + const { formattedData, oauthClientId } = request.body; + return { formattedData, oauthClientId }; }; diff --git a/packages/backend/src/controllers/api/v1/connections/update-connection.test.js b/packages/backend/src/controllers/api/v1/connections/update-connection.test.js index 988da4fa..5902e361 100644 --- a/packages/backend/src/controllers/api/v1/connections/update-connection.test.js +++ b/packages/backend/src/controllers/api/v1/connections/update-connection.test.js @@ -55,10 +55,9 @@ describe('PATCH /api/v1/connections/:connectionId', () => { const refetchedCurrentUserConnection = await currentUserConnection.$query(); - const expectedPayload = updateConnectionMock({ - ...refetchedCurrentUserConnection, - reconnectable: refetchedCurrentUserConnection.reconnectable, - }); + const expectedPayload = updateConnectionMock( + refetchedCurrentUserConnection + ); expect(response.body).toStrictEqual(expectedPayload); }); diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js new file mode 100644 index 00000000..5a1faac9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const exportedFlow = await flow.export(); + + return renderObject(response, exportedFlow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js new file mode 100644 index 00000000..1c648e64 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -0,0 +1,202 @@ +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.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import exportFlowMock from '../../../../../test/mocks/rest/api/v1/flows/export-flow.js'; + +describe('POST /api/v1/flows/:flowId/export', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should export the flow data of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should export the flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${anotherUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/export') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.js b/packages/backend/src/controllers/api/v1/steps/update-step.js index c707726d..70f0b98f 100644 --- a/packages/backend/src/controllers/api/v1/steps/update-step.js +++ b/packages/backend/src/controllers/api/v1/steps/update-step.js @@ -11,12 +11,13 @@ export default async (request, response) => { }; const stepParams = (request) => { - const { connectionId, appKey, key, parameters } = request.body; + const { connectionId, appKey, key, name, parameters } = request.body; return { connectionId, appKey, key, + name, parameters, }; }; diff --git a/packages/backend/src/controllers/api/v1/steps/update-step.test.js b/packages/backend/src/controllers/api/v1/steps/update-step.test.js index 1a5ae1b9..c219dee0 100644 --- a/packages/backend/src/controllers/api/v1/steps/update-step.test.js +++ b/packages/backend/src/controllers/api/v1/steps/update-step.test.js @@ -35,6 +35,7 @@ describe('PATCH /api/v1/steps/:stepId', () => { connectionId: currentUserConnection.id, appKey: 'deepl', key: 'translateText', + name: 'Translate text', }); await createPermission({ @@ -58,6 +59,7 @@ describe('PATCH /api/v1/steps/:stepId', () => { parameters: { text: 'Hello world!', targetLanguage: 'de', + name: 'Translate text - Updated step name', }, }) .expect(200); diff --git a/packages/backend/src/controllers/api/v1/users/get-apps.js b/packages/backend/src/controllers/api/v1/users/get-apps.js index 801fbc71..94a4ddf6 100644 --- a/packages/backend/src/controllers/api/v1/users/get-apps.js +++ b/packages/backend/src/controllers/api/v1/users/get-apps.js @@ -3,5 +3,5 @@ import { renderObject } from '../../../../helpers/renderer.js'; export default async (request, response) => { const apps = await request.currentUser.getApps(request.query.name); - renderObject(response, apps, { serializer: 'App' }); + renderObject(response, apps, { serializer: 'UserApp' }); }; diff --git a/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js b/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js deleted file mode 100644 index 2f5c611f..00000000 --- a/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js +++ /dev/null @@ -1,38 +0,0 @@ -import path from 'node:path'; - -import Connection from '../../models/connection.js'; -import logger from '../../helpers/logger.js'; -import handler from '../../helpers/webhook-handler.js'; - -export default async (request, response) => { - const computedRequestPayload = { - headers: request.headers, - body: request.body, - query: request.query, - params: request.params, - }; - logger.debug(`Handling incoming webhook request at ${request.originalUrl}.`); - logger.debug(JSON.stringify(computedRequestPayload, null, 2)); - - const { connectionId } = request.params; - - const connection = await Connection.query() - .findById(connectionId) - .throwIfNotFound(); - - if (!(await connection.verifyWebhook(request))) { - return response.sendStatus(401); - } - - const triggerSteps = await connection - .$relatedQuery('triggerSteps') - .where('webhook_path', path.join(request.baseUrl, request.path)); - - if (triggerSteps.length === 0) return response.sendStatus(404); - - for (const triggerStep of triggerSteps) { - await handler(triggerStep.flowId, request, response); - } - - response.sendStatus(204); -}; diff --git a/packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js b/packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js new file mode 100644 index 00000000..1865f05a --- /dev/null +++ b/packages/backend/src/db/migrations/20241204103153_add_use_only_predefined_auth_clients_in_app_config.js @@ -0,0 +1,11 @@ +export async function up(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.boolean('use_only_predefined_auth_clients').defaultTo(false); + }); +} + +export async function down(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.dropColumn('use_only_predefined_auth_clients'); + }); +} diff --git a/packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js b/packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js new file mode 100644 index 00000000..a99bc9e7 --- /dev/null +++ b/packages/backend/src/db/migrations/20241204103355_remove_obsolete_fields_in_app_config.js @@ -0,0 +1,15 @@ +export async function up(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.dropColumn('shared'); + table.dropColumn('connection_allowed'); + table.dropColumn('custom_connection_allowed'); + }); +} + +export async function down(knex) { + return await knex.schema.alterTable('app_configs', (table) => { + table.boolean('shared').defaultTo(false); + table.boolean('connection_allowed').defaultTo(false); + table.boolean('custom_connection_allowed').defaultTo(false); + }); +} diff --git a/packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js b/packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js new file mode 100644 index 00000000..a26ad1f4 --- /dev/null +++ b/packages/backend/src/db/migrations/20241217170447_change_app_auth_clients_as_oauth_clients.js @@ -0,0 +1,31 @@ +export async function up(knex) { + await knex.schema.renameTable('app_auth_clients', 'oauth_clients'); + + await knex.schema.raw( + 'ALTER INDEX app_auth_clients_pkey RENAME TO oauth_clients_pkey' + ); + + await knex.schema.raw( + 'ALTER INDEX app_auth_clients_name_unique RENAME TO oauth_clients_name_unique' + ); + + return await knex.schema.alterTable('connections', (table) => { + table.renameColumn('app_auth_client_id', 'oauth_client_id'); + }); +} + +export async function down(knex) { + await knex.schema.renameTable('oauth_clients', 'app_auth_clients'); + + await knex.schema.raw( + 'ALTER INDEX oauth_clients_pkey RENAME TO app_auth_clients_pkey' + ); + + await knex.schema.raw( + 'ALTER INDEX oauth_clients_name_unique RENAME TO app_auth_clients_name_unique' + ); + + return await knex.schema.alterTable('connections', (table) => { + table.renameColumn('oauth_client_id', 'app_auth_client_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js b/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js new file mode 100644 index 00000000..bde4d6c5 --- /dev/null +++ b/packages/backend/src/db/migrations/20250106114602_add_name_column_to_steps.js @@ -0,0 +1,26 @@ +import toLower from 'lodash/toLower.js'; +import startCase from 'lodash/startCase.js'; +import upperFirst from 'lodash/upperFirst.js'; + +export async function up(knex) { + await knex.schema.table('steps', function (table) { + table.string('name'); + }); + + const rows = await knex('steps').select('id', 'key'); + + const updates = rows.map((row) => { + if (!row.key) return; + + const humanizedKey = upperFirst(toLower(startCase(row.key))); + return knex('steps').where({ id: row.id }).update({ name: humanizedKey }); + }); + + return await Promise.all(updates); +} + +export async function down(knex) { + return knex.schema.table('steps', function (table) { + table.dropColumn('name'); + }); +} diff --git a/packages/backend/src/helpers/add-authentication-steps.js b/packages/backend/src/helpers/add-authentication-steps.js index 5e7a462a..ee1bc85b 100644 --- a/packages/backend/src/helpers/add-authentication-steps.js +++ b/packages/backend/src/helpers/add-authentication-steps.js @@ -88,8 +88,8 @@ const sharedAuthenticationStepsWithAuthUrl = [ value: '{key}', }, { - name: 'appAuthClientId', - value: '{appAuthClientId}', + name: 'oauthClientId', + value: '{oauthClientId}', }, ], }, diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index c9f6329f..13718283 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -113,6 +113,10 @@ const authorizationList = { action: 'create', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/export': { + action: 'update', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js new file mode 100644 index 00000000..05b238dc --- /dev/null +++ b/packages/backend/src/helpers/export-flow.js @@ -0,0 +1,45 @@ +import Crypto from 'crypto'; + +const exportFlow = async (flow) => { + const steps = await flow.$relatedQuery('steps'); + + const newFlowId = Crypto.randomUUID(); + const stepIdMap = Object.fromEntries( + steps.map((step) => [step.id, Crypto.randomUUID()]) + ); + + const exportedFlow = { + id: newFlowId, + name: flow.name, + steps: steps.map((step) => ({ + id: stepIdMap[step.id], + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: updateParameters(step.parameters, stepIdMap), + position: step.position, + webhookPath: step.webhookPath?.replace(flow.id, newFlowId), + })), + }; + + return exportedFlow; +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default exportFlow; diff --git a/packages/backend/src/jobs/delete-user.ee.js b/packages/backend/src/jobs/delete-user.ee.js new file mode 100644 index 00000000..a6d58f33 --- /dev/null +++ b/packages/backend/src/jobs/delete-user.ee.js @@ -0,0 +1,37 @@ +import appConfig from '../config/app.js'; +import User from '../models/user.js'; +import ExecutionStep from '../models/execution-step.js'; + +export const deleteUserJob = async (job) => { + const { id } = job.data; + + const user = await User.query() + .withSoftDeleted() + .findById(id) + .throwIfNotFound(); + + const executionIds = ( + await user + .$relatedQuery('executions') + .withSoftDeleted() + .select('executions.id') + ).map((execution) => execution.id); + + await ExecutionStep.query() + .withSoftDeleted() + .whereIn('execution_id', executionIds) + .hardDelete(); + await user.$relatedQuery('executions').withSoftDeleted().hardDelete(); + await user.$relatedQuery('steps').withSoftDeleted().hardDelete(); + await user.$relatedQuery('flows').withSoftDeleted().hardDelete(); + await user.$relatedQuery('connections').withSoftDeleted().hardDelete(); + await user.$relatedQuery('identities').withSoftDeleted().hardDelete(); + + if (appConfig.isCloud) { + await user.$relatedQuery('subscriptions').withSoftDeleted().hardDelete(); + await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); + } + + await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete(); + await user.$query().withSoftDeleted().hardDelete(); +}; diff --git a/packages/backend/src/jobs/execute-action.js b/packages/backend/src/jobs/execute-action.js new file mode 100644 index 00000000..2d283c11 --- /dev/null +++ b/packages/backend/src/jobs/execute-action.js @@ -0,0 +1,46 @@ +import Step from '../models/step.js'; +import actionQueue from '../queues/action.js'; +import { processAction } from '../services/action.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; +import delayAsMilliseconds from '../helpers/delay-as-milliseconds.js'; + +const DEFAULT_DELAY_DURATION = 0; + +export const executeActionJob = async (job) => { + const { stepId, flowId, executionId, computedParameters, executionStep } = + await processAction(job.data); + + if (executionStep.isFailed) return; + + const step = await Step.query().findById(stepId).throwIfNotFound(); + const nextStep = await step.getNextStep(); + + if (!nextStep) return; + + const jobName = `${executionId}-${nextStep.id}`; + + const jobPayload = { + flowId, + executionId, + stepId: nextStep.id, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + delay: DEFAULT_DELAY_DURATION, + }; + + if (step.appKey === 'delay') { + jobOptions.delay = delayAsMilliseconds(step.key, computedParameters); + } + + if (step.appKey === 'filter' && !executionStep.dataOut) { + return; + } + + await actionQueue.add(jobName, jobPayload, jobOptions); +}; diff --git a/packages/backend/src/jobs/execute-flow.js b/packages/backend/src/jobs/execute-flow.js new file mode 100644 index 00000000..ac6e0634 --- /dev/null +++ b/packages/backend/src/jobs/execute-flow.js @@ -0,0 +1,54 @@ +import triggerQueue from '../queues/trigger.js'; +import { processFlow } from '../services/flow.js'; +import Flow from '../models/flow.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +export const executeFlowJob = async (job) => { + const { flowId } = job.data; + + const flow = await Flow.query().findById(flowId).throwIfNotFound(); + const user = await flow.$relatedQuery('user'); + const allowedToRunFlows = await user.isAllowedToRunFlows(); + + if (!allowedToRunFlows) { + return; + } + + const triggerStep = await flow.getTriggerStep(); + + const { data, error } = await processFlow({ flowId }); + + const reversedData = data.reverse(); + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + for (const triggerItem of reversedData) { + const jobName = `${triggerStep.id}-${triggerItem.meta.internalId}`; + + const jobPayload = { + flowId, + stepId: triggerStep.id, + triggerItem, + }; + + await triggerQueue.add(jobName, jobPayload, jobOptions); + } + + if (error) { + const jobName = `${triggerStep.id}-error`; + + const jobPayload = { + flowId, + stepId: triggerStep.id, + error, + }; + + await triggerQueue.add(jobName, jobPayload, jobOptions); + } +}; diff --git a/packages/backend/src/jobs/execute-trigger.js b/packages/backend/src/jobs/execute-trigger.js new file mode 100644 index 00000000..b81d6ff7 --- /dev/null +++ b/packages/backend/src/jobs/execute-trigger.js @@ -0,0 +1,32 @@ +import actionQueue from '../queues/action.js'; +import Step from '../models/step.js'; +import { processTrigger } from '../services/trigger.js'; +import { + REMOVE_AFTER_30_DAYS_OR_150_JOBS, + REMOVE_AFTER_7_DAYS_OR_50_JOBS, +} from '../helpers/remove-job-configuration.js'; + +export const executeTriggerJob = async (job) => { + const { flowId, executionId, stepId, executionStep } = await processTrigger( + job.data + ); + + if (executionStep.isFailed) return; + + const step = await Step.query().findById(stepId).throwIfNotFound(); + const nextStep = await step.getNextStep(); + const jobName = `${executionId}-${nextStep.id}`; + + const jobPayload = { + flowId, + executionId, + stepId: nextStep.id, + }; + + const jobOptions = { + removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, + removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, + }; + + await actionQueue.add(jobName, jobPayload, jobOptions); +}; diff --git a/packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js b/packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js new file mode 100644 index 00000000..b8d33619 --- /dev/null +++ b/packages/backend/src/jobs/remove-cancelled-subscriptions.ee.js @@ -0,0 +1,15 @@ +import { DateTime } from 'luxon'; +import Subscription from '../models/subscription.ee.js'; + +export const removeCancelledSubscriptionsJob = async () => { + await Subscription.query() + .delete() + .where({ + status: 'deleted', + }) + .andWhere( + 'cancellation_effective_date', + '<=', + DateTime.now().startOf('day').toISODate() + ); +}; diff --git a/packages/backend/src/jobs/send-email.js b/packages/backend/src/jobs/send-email.js new file mode 100644 index 00000000..ed818493 --- /dev/null +++ b/packages/backend/src/jobs/send-email.js @@ -0,0 +1,31 @@ +import logger from '../helpers/logger.js'; +import mailer from '../helpers/mailer.ee.js'; +import compileEmail from '../helpers/compile-email.ee.js'; +import appConfig from '../config/app.js'; + +export const sendEmailJob = async (job) => { + const { email, subject, template, params } = job.data; + + if (isCloudSandbox() && !isAutomatischEmail(email)) { + logger.info( + 'Only Automatisch emails are allowed for non-production environments!' + ); + + return; + } + + await mailer.sendMail({ + to: email, + from: appConfig.fromEmail, + subject: subject, + html: compileEmail(template, params), + }); +}; + +const isCloudSandbox = () => { + return appConfig.isCloud && !appConfig.isProd; +}; + +const isAutomatischEmail = (email) => { + return email.endsWith('@automatisch.io'); +}; diff --git a/packages/backend/src/models/__snapshots__/app-config.test.js.snap b/packages/backend/src/models/__snapshots__/app-config.test.js.snap index aea9fa56..38ca2039 100644 --- a/packages/backend/src/models/__snapshots__/app-config.test.js.snap +++ b/packages/backend/src/models/__snapshots__/app-config.test.js.snap @@ -3,17 +3,9 @@ exports[`AppConfig model > jsonSchema should have correct validations 1`] = ` { "properties": { - "connectionAllowed": { - "default": false, - "type": "boolean", - }, "createdAt": { "type": "string", }, - "customConnectionAllowed": { - "default": false, - "type": "boolean", - }, "disabled": { "default": false, "type": "boolean", @@ -25,13 +17,13 @@ exports[`AppConfig model > jsonSchema should have correct validations 1`] = ` "key": { "type": "string", }, - "shared": { - "default": false, - "type": "boolean", - }, "updatedAt": { "type": "string", }, + "useOnlyPredefinedAuthClients": { + "default": false, + "type": "boolean", + }, }, "required": [ "key", diff --git a/packages/backend/src/models/__snapshots__/connection.test.js.snap b/packages/backend/src/models/__snapshots__/connection.test.js.snap index 9fc77caf..405133b0 100644 --- a/packages/backend/src/models/__snapshots__/connection.test.js.snap +++ b/packages/backend/src/models/__snapshots__/connection.test.js.snap @@ -3,10 +3,6 @@ exports[`Connection model > jsonSchema should have correct validations 1`] = ` { "properties": { - "appAuthClientId": { - "format": "uuid", - "type": "string", - }, "createdAt": { "type": "string", }, @@ -31,6 +27,10 @@ exports[`Connection model > jsonSchema should have correct validations 1`] = ` "minLength": 1, "type": "string", }, + "oauthClientId": { + "format": "uuid", + "type": "string", + }, "updatedAt": { "type": "string", }, diff --git a/packages/backend/src/models/__snapshots__/app-auth-client.test.js.snap b/packages/backend/src/models/__snapshots__/oauth-client.test.js.snap similarity index 87% rename from packages/backend/src/models/__snapshots__/app-auth-client.test.js.snap rename to packages/backend/src/models/__snapshots__/oauth-client.test.js.snap index 87b5cc8c..04b38119 100644 --- a/packages/backend/src/models/__snapshots__/app-auth-client.test.js.snap +++ b/packages/backend/src/models/__snapshots__/oauth-client.test.js.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`AppAuthClient model > jsonSchema should have correct validations 1`] = ` +exports[`OAuthClient model > jsonSchema should have correct validations 1`] = ` { "properties": { "active": { diff --git a/packages/backend/src/models/__snapshots__/step.test.js.snap b/packages/backend/src/models/__snapshots__/step.test.js.snap index aa78645e..d9a45102 100644 --- a/packages/backend/src/models/__snapshots__/step.test.js.snap +++ b/packages/backend/src/models/__snapshots__/step.test.js.snap @@ -38,6 +38,14 @@ exports[`Step model > jsonSchema should have correct validations 1`] = ` "null", ], }, + "name": { + "maxLength": 255, + "minLength": 1, + "type": [ + "string", + "null", + ], + }, "parameters": { "type": "object", }, diff --git a/packages/backend/src/models/app-auth-client.test.js b/packages/backend/src/models/app-auth-client.test.js deleted file mode 100644 index af1fefc2..00000000 --- a/packages/backend/src/models/app-auth-client.test.js +++ /dev/null @@ -1,284 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import AES from 'crypto-js/aes.js'; -import enc from 'crypto-js/enc-utf8.js'; - -import AppConfig from './app-config.js'; -import AppAuthClient from './app-auth-client.js'; -import Base from './base.js'; -import appConfig from '../config/app.js'; -import { createAppAuthClient } from '../../test/factories/app-auth-client.js'; -import { createAppConfig } from '../../test/factories/app-config.js'; - -describe('AppAuthClient model', () => { - it('tableName should return correct name', () => { - expect(AppAuthClient.tableName).toBe('app_auth_clients'); - }); - - it('jsonSchema should have correct validations', () => { - expect(AppAuthClient.jsonSchema).toMatchSnapshot(); - }); - - it('relationMappings should return correct associations', () => { - const relationMappings = AppAuthClient.relationMappings(); - - const expectedRelations = { - appConfig: { - relation: Base.BelongsToOneRelation, - modelClass: AppConfig, - join: { - from: 'app_auth_clients.app_key', - to: 'app_configs.key', - }, - }, - }; - - expect(relationMappings).toStrictEqual(expectedRelations); - }); - - describe('encryptData', () => { - it('should return undefined if eligibleForEncryption is not true', async () => { - vi.spyOn( - AppAuthClient.prototype, - 'eligibleForEncryption' - ).mockReturnValue(false); - - const appAuthClient = new AppAuthClient(); - - expect(appAuthClient.encryptData()).toBeUndefined(); - }); - - it('should encrypt formattedAuthDefaults and set it to authDefaults', async () => { - vi.spyOn( - AppAuthClient.prototype, - 'eligibleForEncryption' - ).mockReturnValue(true); - - const formattedAuthDefaults = { - key: 'value', - }; - - const appAuthClient = new AppAuthClient(); - appAuthClient.formattedAuthDefaults = formattedAuthDefaults; - appAuthClient.encryptData(); - - const expectedDecryptedValue = JSON.parse( - AES.decrypt( - appAuthClient.authDefaults, - appConfig.encryptionKey - ).toString(enc) - ); - - expect(formattedAuthDefaults).toStrictEqual(expectedDecryptedValue); - expect(appAuthClient.authDefaults).not.toStrictEqual( - formattedAuthDefaults - ); - }); - - it('should encrypt formattedAuthDefaults and remove formattedAuthDefaults', async () => { - vi.spyOn( - AppAuthClient.prototype, - 'eligibleForEncryption' - ).mockReturnValue(true); - - const formattedAuthDefaults = { - key: 'value', - }; - - const appAuthClient = new AppAuthClient(); - appAuthClient.formattedAuthDefaults = formattedAuthDefaults; - appAuthClient.encryptData(); - - expect(appAuthClient.formattedAuthDefaults).not.toBeDefined(); - }); - }); - - describe('decryptData', () => { - it('should return undefined if eligibleForDecryption is not true', () => { - vi.spyOn( - AppAuthClient.prototype, - 'eligibleForDecryption' - ).mockReturnValue(false); - - const appAuthClient = new AppAuthClient(); - - expect(appAuthClient.decryptData()).toBeUndefined(); - }); - - it('should decrypt authDefaults and set it to formattedAuthDefaults', async () => { - vi.spyOn( - AppAuthClient.prototype, - 'eligibleForDecryption' - ).mockReturnValue(true); - - const formattedAuthDefaults = { - key: 'value', - }; - - const authDefaults = AES.encrypt( - JSON.stringify(formattedAuthDefaults), - appConfig.encryptionKey - ).toString(); - - const appAuthClient = new AppAuthClient(); - appAuthClient.authDefaults = authDefaults; - appAuthClient.decryptData(); - - expect(appAuthClient.formattedAuthDefaults).toStrictEqual( - formattedAuthDefaults - ); - expect(appAuthClient.authDefaults).not.toStrictEqual( - formattedAuthDefaults - ); - }); - }); - - describe('eligibleForEncryption', () => { - it('should return true when formattedAuthDefaults property exists', async () => { - const appAuthClient = await createAppAuthClient(); - - expect(appAuthClient.eligibleForEncryption()).toBe(true); - }); - - it("should return false when formattedAuthDefaults property doesn't exist", async () => { - const appAuthClient = await createAppAuthClient(); - - delete appAuthClient.formattedAuthDefaults; - - expect(appAuthClient.eligibleForEncryption()).toBe(false); - }); - }); - - describe('eligibleForDecryption', () => { - it('should return true when authDefaults property exists', async () => { - const appAuthClient = await createAppAuthClient(); - - expect(appAuthClient.eligibleForDecryption()).toBe(true); - }); - - it("should return false when authDefaults property doesn't exist", async () => { - const appAuthClient = await createAppAuthClient(); - - delete appAuthClient.authDefaults; - - expect(appAuthClient.eligibleForDecryption()).toBe(false); - }); - }); - - describe('triggerAppConfigUpdate', () => { - it('should trigger an update in related app config', async () => { - await createAppConfig({ key: 'gitlab' }); - - const appAuthClient = await createAppAuthClient({ - appKey: 'gitlab', - }); - - const appConfigBeforeUpdateSpy = vi.spyOn( - AppConfig.prototype, - '$beforeUpdate' - ); - - await appAuthClient.triggerAppConfigUpdate(); - - expect(appConfigBeforeUpdateSpy).toHaveBeenCalledOnce(); - }); - - it('should update related AppConfig after creating an instance', async () => { - const appConfig = await createAppConfig({ - key: 'gitlab', - disabled: false, - shared: true, - }); - - await createAppAuthClient({ - appKey: 'gitlab', - active: true, - }); - - const refetchedAppConfig = await appConfig.$query(); - - expect(refetchedAppConfig.connectionAllowed).toBe(true); - }); - - it('should update related AppConfig after updating an instance', async () => { - const appConfig = await createAppConfig({ - key: 'gitlab', - disabled: false, - shared: true, - }); - - const appAuthClient = await createAppAuthClient({ - appKey: 'gitlab', - active: false, - }); - - let refetchedAppConfig = await appConfig.$query(); - expect(refetchedAppConfig.connectionAllowed).toBe(false); - - await appAuthClient.$query().patchAndFetch({ active: true }); - - refetchedAppConfig = await appConfig.$query(); - expect(refetchedAppConfig.connectionAllowed).toBe(true); - }); - }); - - it('$beforeInsert should call AppAuthClient.encryptData', async () => { - const appAuthClientBeforeInsertSpy = vi.spyOn( - AppAuthClient.prototype, - 'encryptData' - ); - - await createAppAuthClient(); - - expect(appAuthClientBeforeInsertSpy).toHaveBeenCalledOnce(); - }); - - it('$afterInsert should call AppAuthClient.triggerAppConfigUpdate', async () => { - const appAuthClientAfterInsertSpy = vi.spyOn( - AppAuthClient.prototype, - 'triggerAppConfigUpdate' - ); - - await createAppAuthClient(); - - expect(appAuthClientAfterInsertSpy).toHaveBeenCalledOnce(); - }); - - it('$beforeUpdate should call AppAuthClient.encryptData', async () => { - const appAuthClient = await createAppAuthClient(); - - const appAuthClientBeforeUpdateSpy = vi.spyOn( - AppAuthClient.prototype, - 'encryptData' - ); - - await appAuthClient.$query().patchAndFetch({ name: 'sample' }); - - expect(appAuthClientBeforeUpdateSpy).toHaveBeenCalledOnce(); - }); - - it('$afterUpdate should call AppAuthClient.triggerAppConfigUpdate', async () => { - const appAuthClient = await createAppAuthClient(); - - const appAuthClientAfterUpdateSpy = vi.spyOn( - AppAuthClient.prototype, - 'triggerAppConfigUpdate' - ); - - await appAuthClient.$query().patchAndFetch({ name: 'sample' }); - - expect(appAuthClientAfterUpdateSpy).toHaveBeenCalledOnce(); - }); - - it('$afterFind should call AppAuthClient.decryptData', async () => { - const appAuthClient = await createAppAuthClient(); - - const appAuthClientAfterFindSpy = vi.spyOn( - AppAuthClient.prototype, - 'decryptData' - ); - - await appAuthClient.$query(); - - expect(appAuthClientAfterFindSpy).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/backend/src/models/app-config.js b/packages/backend/src/models/app-config.js index 1a9176b9..fe7e2d44 100644 --- a/packages/backend/src/models/app-config.js +++ b/packages/backend/src/models/app-config.js @@ -1,5 +1,5 @@ import App from './app.js'; -import AppAuthClient from './app-auth-client.js'; +import OAuthClient from './oauth-client.js'; import Base from './base.js'; class AppConfig extends Base { @@ -16,9 +16,7 @@ class AppConfig extends Base { properties: { id: { type: 'string', format: 'uuid' }, key: { type: 'string' }, - connectionAllowed: { type: 'boolean', default: false }, - customConnectionAllowed: { type: 'boolean', default: false }, - shared: { type: 'boolean', default: false }, + useOnlyPredefinedAuthClients: { type: 'boolean', default: false }, disabled: { type: 'boolean', default: false }, createdAt: { type: 'string' }, updatedAt: { type: 'string' }, @@ -26,12 +24,12 @@ class AppConfig extends Base { }; static relationMappings = () => ({ - appAuthClients: { + oauthClients: { relation: Base.HasManyRelation, - modelClass: AppAuthClient, + modelClass: OAuthClient, join: { from: 'app_configs.key', - to: 'app_auth_clients.app_key', + to: 'oauth_clients.app_key', }, }, }); @@ -41,39 +39,6 @@ class AppConfig extends Base { return await App.findOneByKey(this.key); } - - async computeAndAssignConnectionAllowedProperty() { - this.connectionAllowed = await this.computeConnectionAllowedProperty(); - } - - async computeConnectionAllowedProperty() { - const appAuthClients = await this.$relatedQuery('appAuthClients'); - - const hasSomeActiveAppAuthClients = - appAuthClients?.some((appAuthClient) => appAuthClient.active) || false; - - const conditions = [ - hasSomeActiveAppAuthClients, - this.shared, - !this.disabled, - ]; - - const connectionAllowed = conditions.every(Boolean); - - return connectionAllowed; - } - - async $beforeInsert(queryContext) { - await super.$beforeInsert(queryContext); - - await this.computeAndAssignConnectionAllowedProperty(); - } - - async $beforeUpdate(opt, queryContext) { - await super.$beforeUpdate(opt, queryContext); - - await this.computeAndAssignConnectionAllowedProperty(); - } } export default AppConfig; diff --git a/packages/backend/src/models/app-config.test.js b/packages/backend/src/models/app-config.test.js index 4945066c..a68b393f 100644 --- a/packages/backend/src/models/app-config.test.js +++ b/packages/backend/src/models/app-config.test.js @@ -1,11 +1,9 @@ -import { vi, describe, it, expect } from 'vitest'; +import { describe, it, expect } from 'vitest'; import Base from './base.js'; import AppConfig from './app-config.js'; import App from './app.js'; -import AppAuthClient from './app-auth-client.js'; -import { createAppConfig } from '../../test/factories/app-config.js'; -import { createAppAuthClient } from '../../test/factories/app-auth-client.js'; +import OAuthClient from './oauth-client.js'; describe('AppConfig model', () => { it('tableName should return correct name', () => { @@ -24,12 +22,12 @@ describe('AppConfig model', () => { const relationMappings = AppConfig.relationMappings(); const expectedRelations = { - appAuthClients: { + oauthClients: { relation: Base.HasManyRelation, - modelClass: AppAuthClient, + modelClass: OAuthClient, join: { from: 'app_configs.key', - to: 'app_auth_clients.app_key', + to: 'oauth_clients.app_key', }, }, }; @@ -55,126 +53,4 @@ describe('AppConfig model', () => { expect(app).toStrictEqual(expectedApp); }); }); - - describe('computeAndAssignConnectionAllowedProperty', () => { - it('should call computeConnectionAllowedProperty and assign the result', async () => { - const appConfig = await createAppConfig(); - - const computeConnectionAllowedPropertySpy = vi - .spyOn(appConfig, 'computeConnectionAllowedProperty') - .mockResolvedValue(true); - - await appConfig.computeAndAssignConnectionAllowedProperty(); - - expect(computeConnectionAllowedPropertySpy).toHaveBeenCalled(); - expect(appConfig.connectionAllowed).toBe(true); - }); - }); - - describe('computeConnectionAllowedProperty', () => { - it('should return true when app is enabled, shared and allows custom connection with an active app auth client', async () => { - await createAppAuthClient({ - appKey: 'deepl', - active: true, - }); - - await createAppAuthClient({ - appKey: 'deepl', - active: false, - }); - - const appConfig = await createAppConfig({ - disabled: false, - customConnectionAllowed: true, - shared: true, - key: 'deepl', - }); - - const connectionAllowed = - await appConfig.computeConnectionAllowedProperty(); - - expect(connectionAllowed).toBe(true); - }); - - it('should return false if there is no active app auth client', async () => { - await createAppAuthClient({ - appKey: 'deepl', - active: false, - }); - - const appConfig = await createAppConfig({ - disabled: false, - customConnectionAllowed: true, - shared: true, - key: 'deepl', - }); - - const connectionAllowed = - await appConfig.computeConnectionAllowedProperty(); - - expect(connectionAllowed).toBe(false); - }); - - it('should return false if there is no app auth clients', async () => { - const appConfig = await createAppConfig({ - disabled: false, - customConnectionAllowed: true, - shared: true, - key: 'deepl', - }); - - const connectionAllowed = - await appConfig.computeConnectionAllowedProperty(); - - expect(connectionAllowed).toBe(false); - }); - - it('should return false when app is disabled', async () => { - const appConfig = await createAppConfig({ - disabled: true, - customConnectionAllowed: true, - }); - - const connectionAllowed = - await appConfig.computeConnectionAllowedProperty(); - - expect(connectionAllowed).toBe(false); - }); - - it(`should return false when app doesn't allow custom connection`, async () => { - const appConfig = await createAppConfig({ - disabled: false, - customConnectionAllowed: false, - }); - - const connectionAllowed = - await appConfig.computeConnectionAllowedProperty(); - - expect(connectionAllowed).toBe(false); - }); - }); - - it('$beforeInsert should call computeAndAssignConnectionAllowedProperty', async () => { - const computeAndAssignConnectionAllowedPropertySpy = vi - .spyOn(AppConfig.prototype, 'computeAndAssignConnectionAllowedProperty') - .mockResolvedValue(true); - - await createAppConfig(); - - expect(computeAndAssignConnectionAllowedPropertySpy).toHaveBeenCalledOnce(); - }); - - it('$beforeUpdate should call computeAndAssignConnectionAllowedProperty', async () => { - const appConfig = await createAppConfig(); - - const computeAndAssignConnectionAllowedPropertySpy = vi - .spyOn(AppConfig.prototype, 'computeAndAssignConnectionAllowedProperty') - .mockResolvedValue(true); - - await appConfig.$query().patch({ - key: 'deepl', - }); - - expect(computeAndAssignConnectionAllowedPropertySpy).toHaveBeenCalledOnce(); - }); }); diff --git a/packages/backend/src/models/connection.js b/packages/backend/src/models/connection.js index 325e1e07..5b4c7c66 100644 --- a/packages/backend/src/models/connection.js +++ b/packages/backend/src/models/connection.js @@ -2,7 +2,7 @@ import AES from 'crypto-js/aes.js'; import enc from 'crypto-js/enc-utf8.js'; import App from './app.js'; import AppConfig from './app-config.js'; -import AppAuthClient from './app-auth-client.js'; +import OAuthClient from './oauth-client.js'; import Base from './base.js'; import User from './user.js'; import Step from './step.js'; @@ -24,7 +24,7 @@ class Connection extends Base { data: { type: 'string' }, formattedData: { type: 'object' }, userId: { type: 'string', format: 'uuid' }, - appAuthClientId: { type: 'string', format: 'uuid' }, + oauthClientId: { type: 'string', format: 'uuid' }, verified: { type: 'boolean', default: false }, draft: { type: 'boolean' }, deletedAt: { type: 'string' }, @@ -33,10 +33,6 @@ class Connection extends Base { }, }; - static get virtualAttributes() { - return ['reconnectable']; - } - static relationMappings = () => ({ user: { relation: Base.BelongsToOneRelation, @@ -73,28 +69,16 @@ class Connection extends Base { to: 'app_configs.key', }, }, - appAuthClient: { + oauthClient: { relation: Base.BelongsToOneRelation, - modelClass: AppAuthClient, + modelClass: OAuthClient, join: { - from: 'connections.app_auth_client_id', - to: 'app_auth_clients.id', + from: 'connections.oauth_client_id', + to: 'oauth_clients.id', }, }, }); - get reconnectable() { - if (this.appAuthClientId) { - return this.appAuthClient.active; - } - - if (this.appConfig) { - return !this.appConfig.disabled && this.appConfig.customConnectionAllowed; - } - - return true; - } - encryptData() { if (!this.eligibleForEncryption()) return; @@ -144,22 +128,16 @@ class Connection extends Base { ); } - if (!appConfig.customConnectionAllowed && this.formattedData) { + if (appConfig.useOnlyPredefinedAuthClients && this.formattedData) { throw new NotAuthorizedError( `New custom connections have been disabled for ${app.name}!` ); } - if (!appConfig.shared && this.appAuthClientId) { - throw new NotAuthorizedError( - 'The connection with the given app auth client is not allowed!' - ); - } - - if (appConfig.shared && !this.formattedData) { + if (!this.formattedData) { const authClient = await appConfig - .$relatedQuery('appAuthClients') - .findById(this.appAuthClientId) + .$relatedQuery('oauthClients') + .findById(this.oauthClientId) .where({ active: true }) .throwIfNotFound(); @@ -237,13 +215,13 @@ class Connection extends Base { return updatedConnection; } - async updateFormattedData({ formattedData, appAuthClientId }) { - if (appAuthClientId) { - const appAuthClient = await AppAuthClient.query() - .findById(appAuthClientId) + async updateFormattedData({ formattedData, oauthClientId }) { + if (oauthClientId) { + const oauthClient = await OAuthClient.query() + .findById(oauthClientId) .throwIfNotFound(); - formattedData = appAuthClient.formattedAuthDefaults; + formattedData = oauthClient.formattedAuthDefaults; } return await this.$query().patchAndFetch({ diff --git a/packages/backend/src/models/connection.test.js b/packages/backend/src/models/connection.test.js index 7c5057bb..58410eef 100644 --- a/packages/backend/src/models/connection.test.js +++ b/packages/backend/src/models/connection.test.js @@ -2,7 +2,7 @@ import { describe, it, expect, vi } from 'vitest'; import AES from 'crypto-js/aes.js'; import enc from 'crypto-js/enc-utf8.js'; import appConfig from '../config/app.js'; -import AppAuthClient from './app-auth-client.js'; +import OAuthClient from './oauth-client.js'; import App from './app.js'; import AppConfig from './app-config.js'; import Base from './base.js'; @@ -12,7 +12,7 @@ import User from './user.js'; import Telemetry from '../helpers/telemetry/index.js'; import { createConnection } from '../../test/factories/connection.js'; import { createAppConfig } from '../../test/factories/app-config.js'; -import { createAppAuthClient } from '../../test/factories/app-auth-client.js'; +import { createOAuthClient } from '../../test/factories/oauth-client.js'; describe('Connection model', () => { it('tableName should return correct name', () => { @@ -23,14 +23,6 @@ describe('Connection model', () => { expect(Connection.jsonSchema).toMatchSnapshot(); }); - it('virtualAttributes should return correct attributes', () => { - const virtualAttributes = Connection.virtualAttributes; - - const expectedAttributes = ['reconnectable']; - - expect(virtualAttributes).toStrictEqual(expectedAttributes); - }); - describe('relationMappings', () => { it('should return correct associations', () => { const relationMappings = Connection.relationMappings(); @@ -69,12 +61,12 @@ describe('Connection model', () => { to: 'app_configs.key', }, }, - appAuthClient: { + oauthClient: { relation: Base.BelongsToOneRelation, - modelClass: AppAuthClient, + modelClass: OAuthClient, join: { - from: 'connections.app_auth_client_id', - to: 'app_auth_clients.id', + from: 'connections.oauth_client_id', + to: 'oauth_clients.id', }, }, }; @@ -92,78 +84,6 @@ describe('Connection model', () => { }); }); - describe('reconnectable', () => { - it('should return active status of app auth client when created via app auth client', async () => { - const appAuthClient = await createAppAuthClient({ - active: true, - formattedAuthDefaults: { - clientId: 'sample-id', - }, - }); - - const connection = await createConnection({ - appAuthClientId: appAuthClient.id, - formattedData: { - token: 'sample-token', - }, - }); - - const connectionWithAppAuthClient = await connection - .$query() - .withGraphFetched({ - appAuthClient: true, - }); - - expect(connectionWithAppAuthClient.reconnectable).toBe(true); - }); - - it('should return true when app config is not disabled and allows custom connection', async () => { - const appConfig = await createAppConfig({ - key: 'gitlab', - disabled: false, - customConnectionAllowed: true, - }); - - const connection = await createConnection({ - key: appConfig.key, - formattedData: { - token: 'sample-token', - }, - }); - - const connectionWithAppAuthClient = await connection - .$query() - .withGraphFetched({ - appConfig: true, - }); - - expect(connectionWithAppAuthClient.reconnectable).toBe(true); - }); - - it('should return false when app config is disabled or does not allow custom connection', async () => { - const connection = await createConnection({ - key: 'gitlab', - formattedData: { - token: 'sample-token', - }, - }); - - await createAppConfig({ - key: 'gitlab', - disabled: true, - customConnectionAllowed: false, - }); - - const connectionWithAppAuthClient = await connection - .$query() - .withGraphFetched({ - appConfig: true, - }); - - expect(connectionWithAppAuthClient.reconnectable).toBe(false); - }); - }); - describe('encryptData', () => { it('should return undefined if eligibleForEncryption is not true', async () => { vi.spyOn(Connection.prototype, 'eligibleForEncryption').mockReturnValue( @@ -366,6 +286,7 @@ describe('Connection model', () => { ); }); + // TODO: update test case name it('should throw an error when app config does not allow custom connection with formatted data', async () => { vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({ name: 'gitlab', @@ -373,7 +294,7 @@ describe('Connection model', () => { vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({ disabled: false, - customConnectionAllowed: false, + useOnlyPredefinedAuthClients: true, }); const connection = new Connection(); @@ -386,35 +307,13 @@ describe('Connection model', () => { ); }); - it('should throw an error when app config is not shared with app auth client', async () => { - vi.spyOn(Connection.prototype, 'getApp').mockResolvedValue({ - name: 'gitlab', - }); - - vi.spyOn(Connection.prototype, 'getAppConfig').mockResolvedValue({ - disabled: false, - shared: false, - }); - - const connection = new Connection(); - connection.appAuthClientId = 'sample-id'; - - await expect(() => - connection.checkEligibilityForCreation() - ).rejects.toThrow( - 'The connection with the given app auth client is not allowed!' - ); - }); - - it('should apply app auth client auth defaults when creating with shared app auth client', async () => { + it('should apply oauth client auth defaults when creating with shared oauth client', async () => { await createAppConfig({ key: 'gitlab', disabled: false, - customConnectionAllowed: true, - shared: true, }); - const appAuthClient = await createAppAuthClient({ + const oauthClient = await createOAuthClient({ appKey: 'gitlab', active: true, formattedAuthDefaults: { @@ -424,7 +323,7 @@ describe('Connection model', () => { const connection = await createConnection({ key: 'gitlab', - appAuthClientId: appAuthClient.id, + oauthClientId: oauthClient.id, formattedData: null, }); @@ -660,22 +559,22 @@ describe('Connection model', () => { }); describe('updateFormattedData', () => { - it('should extend connection data with app auth client auth defaults', async () => { - const appAuthClient = await createAppAuthClient({ + it('should extend connection data with oauth client auth defaults', async () => { + const oauthClient = await createOAuthClient({ formattedAuthDefaults: { clientId: 'sample-id', }, }); const connection = await createConnection({ - appAuthClientId: appAuthClient.id, + oauthClientId: oauthClient.id, formattedData: { token: 'sample-token', }, }); const updatedConnection = await connection.updateFormattedData({ - appAuthClientId: appAuthClient.id, + oauthClientId: oauthClient.id, }); expect(updatedConnection.formattedData).toStrictEqual({ diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 56744396..22be9030 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -7,6 +7,7 @@ import ExecutionStep from './execution-step.js'; import globalVariable from '../helpers/global-variable.js'; import logger from '../helpers/logger.js'; import Telemetry from '../helpers/telemetry/index.js'; +import exportFlow from '../helpers/export-flow.js'; import flowQueue from '../queues/flow.js'; import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, @@ -426,6 +427,10 @@ class Flow extends Base { } } + async export() { + return await exportFlow(this); + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 7faefa17..cbaae474 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -10,6 +10,7 @@ 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 * as exportFlow from '../helpers/export-flow.js'; describe('Flow model', () => { it('tableName should return correct name', () => { @@ -506,6 +507,22 @@ describe('Flow model', () => { }); }); + describe('export', () => { + it('should return exportedFlow', async () => { + const flow = await createFlow(); + + const exportedFlowAsString = { + name: 'My Flow Name', + }; + + vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); + + expect(await flow.export()).toStrictEqual({ + name: 'My Flow Name', + }); + }); + }); + describe('throwIfHavingLessThanTwoSteps', () => { it('should throw validation error with less than two steps', async () => { const flow = await createFlow(); diff --git a/packages/backend/src/models/app-auth-client.js b/packages/backend/src/models/oauth-client.js similarity index 78% rename from packages/backend/src/models/app-auth-client.js rename to packages/backend/src/models/oauth-client.js index 90a9bda3..d4c253a4 100644 --- a/packages/backend/src/models/app-auth-client.js +++ b/packages/backend/src/models/oauth-client.js @@ -4,8 +4,8 @@ import appConfig from '../config/app.js'; import Base from './base.js'; import AppConfig from './app-config.js'; -class AppAuthClient extends Base { - static tableName = 'app_auth_clients'; +class OAuthClient extends Base { + static tableName = 'oauth_clients'; static jsonSchema = { type: 'object', @@ -27,7 +27,7 @@ class AppAuthClient extends Base { relation: Base.BelongsToOneRelation, modelClass: AppConfig, join: { - from: 'app_auth_clients.app_key', + from: 'oauth_clients.app_key', to: 'app_configs.key', }, }, @@ -60,39 +60,26 @@ class AppAuthClient extends Base { return this.authDefaults ? true : false; } - async triggerAppConfigUpdate() { - const appConfig = await this.$relatedQuery('appConfig'); - - // This is a workaround to update connection allowed column for AppConfig - await appConfig?.$query().patch({ - key: appConfig.key, - shared: appConfig.shared, - disabled: appConfig.disabled, - }); - } - // TODO: Make another abstraction like beforeSave instead of using // beforeInsert and beforeUpdate separately for the same operation. async $beforeInsert(queryContext) { await super.$beforeInsert(queryContext); + this.encryptData(); } async $afterInsert(queryContext) { await super.$afterInsert(queryContext); - - await this.triggerAppConfigUpdate(); } async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); + this.encryptData(); } async $afterUpdate(opt, queryContext) { await super.$afterUpdate(opt, queryContext); - - await this.triggerAppConfigUpdate(); } async $afterFind() { @@ -100,4 +87,4 @@ class AppAuthClient extends Base { } } -export default AppAuthClient; +export default OAuthClient; diff --git a/packages/backend/src/models/oauth-client.test.js b/packages/backend/src/models/oauth-client.test.js new file mode 100644 index 00000000..e1d17154 --- /dev/null +++ b/packages/backend/src/models/oauth-client.test.js @@ -0,0 +1,192 @@ +import { describe, it, expect, vi } from 'vitest'; +import AES from 'crypto-js/aes.js'; +import enc from 'crypto-js/enc-utf8.js'; + +import AppConfig from './app-config.js'; +import OAuthClient from './oauth-client.js'; +import Base from './base.js'; +import appConfig from '../config/app.js'; +import { createOAuthClient } from '../../test/factories/oauth-client.js'; + +describe('OAuthClient model', () => { + it('tableName should return correct name', () => { + expect(OAuthClient.tableName).toBe('oauth_clients'); + }); + + it('jsonSchema should have correct validations', () => { + expect(OAuthClient.jsonSchema).toMatchSnapshot(); + }); + + it('relationMappings should return correct associations', () => { + const relationMappings = OAuthClient.relationMappings(); + + const expectedRelations = { + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'oauth_clients.app_key', + to: 'app_configs.key', + }, + }, + }; + + expect(relationMappings).toStrictEqual(expectedRelations); + }); + + describe('encryptData', () => { + it('should return undefined if eligibleForEncryption is not true', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForEncryption').mockReturnValue( + false + ); + + const oauthClient = new OAuthClient(); + + expect(oauthClient.encryptData()).toBeUndefined(); + }); + + it('should encrypt formattedAuthDefaults and set it to authDefaults', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForEncryption').mockReturnValue( + true + ); + + const formattedAuthDefaults = { + key: 'value', + }; + + const oauthClient = new OAuthClient(); + oauthClient.formattedAuthDefaults = formattedAuthDefaults; + oauthClient.encryptData(); + + const expectedDecryptedValue = JSON.parse( + AES.decrypt(oauthClient.authDefaults, appConfig.encryptionKey).toString( + enc + ) + ); + + expect(formattedAuthDefaults).toStrictEqual(expectedDecryptedValue); + expect(oauthClient.authDefaults).not.toStrictEqual(formattedAuthDefaults); + }); + + it('should encrypt formattedAuthDefaults and remove formattedAuthDefaults', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForEncryption').mockReturnValue( + true + ); + + const formattedAuthDefaults = { + key: 'value', + }; + + const oauthClient = new OAuthClient(); + oauthClient.formattedAuthDefaults = formattedAuthDefaults; + oauthClient.encryptData(); + + expect(oauthClient.formattedAuthDefaults).not.toBeDefined(); + }); + }); + + describe('decryptData', () => { + it('should return undefined if eligibleForDecryption is not true', () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForDecryption').mockReturnValue( + false + ); + + const oauthClient = new OAuthClient(); + + expect(oauthClient.decryptData()).toBeUndefined(); + }); + + it('should decrypt authDefaults and set it to formattedAuthDefaults', async () => { + vi.spyOn(OAuthClient.prototype, 'eligibleForDecryption').mockReturnValue( + true + ); + + const formattedAuthDefaults = { + key: 'value', + }; + + const authDefaults = AES.encrypt( + JSON.stringify(formattedAuthDefaults), + appConfig.encryptionKey + ).toString(); + + const oauthClient = new OAuthClient(); + oauthClient.authDefaults = authDefaults; + oauthClient.decryptData(); + + expect(oauthClient.formattedAuthDefaults).toStrictEqual( + formattedAuthDefaults + ); + expect(oauthClient.authDefaults).not.toStrictEqual(formattedAuthDefaults); + }); + }); + + describe('eligibleForEncryption', () => { + it('should return true when formattedAuthDefaults property exists', async () => { + const oauthClient = await createOAuthClient(); + + expect(oauthClient.eligibleForEncryption()).toBe(true); + }); + + it("should return false when formattedAuthDefaults property doesn't exist", async () => { + const oauthClient = await createOAuthClient(); + + delete oauthClient.formattedAuthDefaults; + + expect(oauthClient.eligibleForEncryption()).toBe(false); + }); + }); + + describe('eligibleForDecryption', () => { + it('should return true when authDefaults property exists', async () => { + const oauthClient = await createOAuthClient(); + + expect(oauthClient.eligibleForDecryption()).toBe(true); + }); + + it("should return false when authDefaults property doesn't exist", async () => { + const oauthClient = await createOAuthClient(); + + delete oauthClient.authDefaults; + + expect(oauthClient.eligibleForDecryption()).toBe(false); + }); + }); + + it('$beforeInsert should call OAuthClient.encryptData', async () => { + const oauthClientBeforeInsertSpy = vi.spyOn( + OAuthClient.prototype, + 'encryptData' + ); + + await createOAuthClient(); + + expect(oauthClientBeforeInsertSpy).toHaveBeenCalledOnce(); + }); + + it('$beforeUpdate should call OAuthClient.encryptData', async () => { + const oauthClient = await createOAuthClient(); + + const oauthClientBeforeUpdateSpy = vi.spyOn( + OAuthClient.prototype, + 'encryptData' + ); + + await oauthClient.$query().patchAndFetch({ name: 'sample' }); + + expect(oauthClientBeforeUpdateSpy).toHaveBeenCalledOnce(); + }); + + it('$afterFind should call OAuthClient.decryptData', async () => { + const oauthClient = await createOAuthClient(); + + const oauthClientAfterFindSpy = vi.spyOn( + OAuthClient.prototype, + 'decryptData' + ); + + await oauthClient.$query(); + + expect(oauthClientAfterFindSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js index 9f5f3f70..41c53373 100644 --- a/packages/backend/src/models/step.js +++ b/packages/backend/src/models/step.js @@ -1,5 +1,4 @@ import { URL } from 'node:url'; -import get from 'lodash.get'; import Base from './base.js'; import App from './app.js'; import Flow from './flow.js'; @@ -22,6 +21,7 @@ class Step extends Base { id: { type: 'string', format: 'uuid' }, flowId: { type: 'string', format: 'uuid' }, key: { type: ['string', 'null'] }, + name: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, appKey: { type: ['string', 'null'], minLength: 1, maxLength: 255 }, type: { type: 'string', enum: ['action', 'trigger'] }, connectionId: { type: ['string', 'null'], format: 'uuid' }, @@ -108,25 +108,10 @@ class Step extends Base { if (!triggerCommand) return null; - const { useSingletonWebhook, singletonWebhookRefValueParameter, type } = - triggerCommand; - - const isWebhook = type === 'webhook'; + const isWebhook = triggerCommand.type === 'webhook'; if (!isWebhook) return null; - if (singletonWebhookRefValueParameter) { - const parameterValue = get( - this.parameters, - singletonWebhookRefValueParameter - ); - return `/webhooks/connections/${this.connectionId}/${parameterValue}`; - } - - if (useSingletonWebhook) { - return `/webhooks/connections/${this.connectionId}`; - } - if (this.parameters.workSynchronously) { return `/webhooks/flows/${this.flowId}/sync`; } @@ -314,7 +299,13 @@ class Step extends Base { } async updateFor(user, newStepData) { - const { appKey = this.appKey, connectionId, key, parameters } = newStepData; + const { + appKey = this.appKey, + name, + connectionId, + key, + parameters, + } = newStepData; if (connectionId && appKey) { await user.authorizedConnections @@ -335,6 +326,7 @@ class Step extends Base { const updatedStep = await this.$query().patchAndFetch({ key, + name, appKey, connectionId: connectionId, parameters: parameters, diff --git a/packages/backend/src/models/user.test.js b/packages/backend/src/models/user.test.js index 4b4d0cd3..159af7ee 100644 --- a/packages/backend/src/models/user.test.js +++ b/packages/backend/src/models/user.test.js @@ -376,7 +376,10 @@ describe('User model', () => { const anotherUserConnection = await createConnection(); expect( - await userWithRoleAndPermissions.authorizedConnections + await userWithRoleAndPermissions.authorizedConnections.orderBy( + 'created_at', + 'asc' + ) ).toStrictEqual([userConnection, anotherUserConnection]); }); diff --git a/packages/backend/src/queues/action.js b/packages/backend/src/queues/action.js index 3c413173..dbb0226a 100644 --- a/packages/backend/src/queues/action.js +++ b/packages/backend/src/queues/action.js @@ -1,27 +1,4 @@ -import process from 'process'; -import { Queue } from 'bullmq'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; - -const CONNECTION_REFUSED = 'ECONNREFUSED'; - -const redisConnection = { - connection: redisConfig, -}; - -const actionQueue = new Queue('action', redisConnection); - -actionQueue.on('error', (error) => { - if (error.code === CONNECTION_REFUSED) { - logger.error( - 'Make sure you have installed Redis and it is running.', - error - ); - - process.exit(); - } - - logger.error('Error happened in action queue!', error); -}); +import { generateQueue } from './queue.js'; +const actionQueue = generateQueue('action'); export default actionQueue; diff --git a/packages/backend/src/queues/delete-user.ee.js b/packages/backend/src/queues/delete-user.ee.js index 11794005..8e939523 100644 --- a/packages/backend/src/queues/delete-user.ee.js +++ b/packages/backend/src/queues/delete-user.ee.js @@ -1,27 +1,4 @@ -import process from 'process'; -import { Queue } from 'bullmq'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; - -const CONNECTION_REFUSED = 'ECONNREFUSED'; - -const redisConnection = { - connection: redisConfig, -}; - -const deleteUserQueue = new Queue('delete-user', redisConnection); - -deleteUserQueue.on('error', (error) => { - if (error.code === CONNECTION_REFUSED) { - logger.error( - 'Make sure you have installed Redis and it is running.', - error - ); - - process.exit(); - } - - logger.error('Error happened in delete user queue!', error); -}); +import { generateQueue } from './queue.js'; +const deleteUserQueue = generateQueue('delete-user'); export default deleteUserQueue; diff --git a/packages/backend/src/queues/email.js b/packages/backend/src/queues/email.js index 5755f7c2..31e55bd5 100644 --- a/packages/backend/src/queues/email.js +++ b/packages/backend/src/queues/email.js @@ -1,27 +1,4 @@ -import process from 'process'; -import { Queue } from 'bullmq'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; - -const CONNECTION_REFUSED = 'ECONNREFUSED'; - -const redisConnection = { - connection: redisConfig, -}; - -const emailQueue = new Queue('email', redisConnection); - -emailQueue.on('error', (error) => { - if (error.code === CONNECTION_REFUSED) { - logger.error( - 'Make sure you have installed Redis and it is running.', - error - ); - - process.exit(); - } - - logger.error('Error happened in email queue!', error); -}); +import { generateQueue } from './queue.js'; +const emailQueue = generateQueue('email'); export default emailQueue; diff --git a/packages/backend/src/queues/flow.js b/packages/backend/src/queues/flow.js index 48de083a..b9d335fe 100644 --- a/packages/backend/src/queues/flow.js +++ b/packages/backend/src/queues/flow.js @@ -1,27 +1,4 @@ -import process from 'process'; -import { Queue } from 'bullmq'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; - -const CONNECTION_REFUSED = 'ECONNREFUSED'; - -const redisConnection = { - connection: redisConfig, -}; - -const flowQueue = new Queue('flow', redisConnection); - -flowQueue.on('error', (error) => { - if (error.code === CONNECTION_REFUSED) { - logger.error( - 'Make sure you have installed Redis and it is running.', - error - ); - - process.exit(); - } - - logger.error('Error happened in flow queue!', error); -}); +import { generateQueue } from './queue.js'; +const flowQueue = generateQueue('flow'); export default flowQueue; diff --git a/packages/backend/src/queues/queue.js b/packages/backend/src/queues/queue.js new file mode 100644 index 00000000..f6a5263e --- /dev/null +++ b/packages/backend/src/queues/queue.js @@ -0,0 +1,44 @@ +import process from 'process'; +import { Queue } from 'bullmq'; +import redisConfig from '../config/redis.js'; +import logger from '../helpers/logger.js'; + +const CONNECTION_REFUSED = 'ECONNREFUSED'; + +const redisConnection = { + connection: redisConfig, +}; + +export const generateQueue = (queueName, options) => { + const queue = new Queue(queueName, redisConnection); + + queue.on('error', (error) => queueOnError(error, queueName)); + + if (options?.runDaily) addScheduler(queueName, queue); + + return queue; +}; + +const queueOnError = (error, queueName) => { + if (error.code === CONNECTION_REFUSED) { + const errorMessage = + 'Make sure you have installed Redis and it is running.'; + + logger.error(errorMessage, error); + + process.exit(); + } + + logger.error(`Error happened in ${queueName} queue!`, error); +}; + +const addScheduler = (queueName, queue) => { + const everydayAtOneOclock = '0 1 * * *'; + + queue.add(queueName, null, { + jobId: queueName, + repeat: { + pattern: everydayAtOneOclock, + }, + }); +}; diff --git a/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js b/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js index f5f574a8..bb439722 100644 --- a/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js +++ b/packages/backend/src/queues/remove-cancelled-subscriptions.ee.js @@ -1,40 +1,8 @@ -import process from 'process'; -import { Queue } from 'bullmq'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; +import { generateQueue } from './queue.js'; -const CONNECTION_REFUSED = 'ECONNREFUSED'; - -const redisConnection = { - connection: redisConfig, -}; - -const removeCancelledSubscriptionsQueue = new Queue( +const removeCancelledSubscriptionsQueue = generateQueue( 'remove-cancelled-subscriptions', - redisConnection + { runDaily: true } ); -removeCancelledSubscriptionsQueue.on('error', (error) => { - if (error.code === CONNECTION_REFUSED) { - logger.error( - 'Make sure you have installed Redis and it is running.', - error - ); - - process.exit(); - } - - logger.error( - 'Error happened in remove cancelled subscriptions queue!', - error - ); -}); - -removeCancelledSubscriptionsQueue.add('remove-cancelled-subscriptions', null, { - jobId: 'remove-cancelled-subscriptions', - repeat: { - pattern: '0 1 * * *', - }, -}); - export default removeCancelledSubscriptionsQueue; diff --git a/packages/backend/src/queues/trigger.js b/packages/backend/src/queues/trigger.js index bc0f9b46..e2134e13 100644 --- a/packages/backend/src/queues/trigger.js +++ b/packages/backend/src/queues/trigger.js @@ -1,27 +1,4 @@ -import process from 'process'; -import { Queue } from 'bullmq'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; - -const CONNECTION_REFUSED = 'ECONNREFUSED'; - -const redisConnection = { - connection: redisConfig, -}; - -const triggerQueue = new Queue('trigger', redisConnection); - -triggerQueue.on('error', (error) => { - if (error.code === CONNECTION_REFUSED) { - logger.error( - 'Make sure you have installed Redis and it is running.', - error - ); - - process.exit(); - } - - logger.error('Error happened in trigger queue!', error); -}); +import { generateQueue } from './queue.js'; +const triggerQueue = generateQueue('trigger'); export default triggerQueue; diff --git a/packages/backend/src/routes/api/v1/admin/apps.ee.js b/packages/backend/src/routes/api/v1/admin/apps.ee.js index c476d2ff..6a0eb9a6 100644 --- a/packages/backend/src/routes/api/v1/admin/apps.ee.js +++ b/packages/backend/src/routes/api/v1/admin/apps.ee.js @@ -4,10 +4,10 @@ import { authorizeAdmin } from '../../../../helpers/authorization.js'; import { checkIsEnterprise } from '../../../../helpers/check-is-enterprise.js'; import createConfigAction from '../../../../controllers/api/v1/admin/apps/create-config.ee.js'; import updateConfigAction from '../../../../controllers/api/v1/admin/apps/update-config.ee.js'; -import getAuthClientsAction from '../../../../controllers/api/v1/admin/apps/get-auth-clients.ee.js'; -import getAuthClientAction from '../../../../controllers/api/v1/admin/apps/get-auth-client.ee.js'; -import createAuthClientAction from '../../../../controllers/api/v1/admin/apps/create-auth-client.ee.js'; -import updateAuthClientAction from '../../../../controllers/api/v1/admin/apps/update-auth-client.ee.js'; +import getOAuthClientsAction from '../../../../controllers/api/v1/admin/apps/get-oauth-clients.ee.js'; +import getOAuthClientAction from '../../../../controllers/api/v1/admin/apps/get-oauth-client.ee.js'; +import createOAuthClientAction from '../../../../controllers/api/v1/admin/apps/create-oauth-client.ee.js'; +import updateOAuthClientAction from '../../../../controllers/api/v1/admin/apps/update-oauth-client.ee.js'; const router = Router(); @@ -28,35 +28,35 @@ router.patch( ); router.get( - '/:appKey/auth-clients', + '/:appKey/oauth-clients', authenticateUser, authorizeAdmin, checkIsEnterprise, - getAuthClientsAction + getOAuthClientsAction ); router.post( - '/:appKey/auth-clients', + '/:appKey/oauth-clients', authenticateUser, authorizeAdmin, checkIsEnterprise, - createAuthClientAction + createOAuthClientAction ); router.get( - '/:appKey/auth-clients/:appAuthClientId', + '/:appKey/oauth-clients/:oauthClientId', authenticateUser, authorizeAdmin, checkIsEnterprise, - getAuthClientAction + getOAuthClientAction ); router.patch( - '/:appKey/auth-clients/:appAuthClientId', + '/:appKey/oauth-clients/:oauthClientId', authenticateUser, authorizeAdmin, checkIsEnterprise, - updateAuthClientAction + updateOAuthClientAction ); export default router; diff --git a/packages/backend/src/routes/api/v1/apps.js b/packages/backend/src/routes/api/v1/apps.js index 5bdc27f1..c92fc552 100644 --- a/packages/backend/src/routes/api/v1/apps.js +++ b/packages/backend/src/routes/api/v1/apps.js @@ -7,8 +7,8 @@ import getAppsAction from '../../../controllers/api/v1/apps/get-apps.js'; import getAuthAction from '../../../controllers/api/v1/apps/get-auth.js'; import getConnectionsAction from '../../../controllers/api/v1/apps/get-connections.js'; import getConfigAction from '../../../controllers/api/v1/apps/get-config.ee.js'; -import getAuthClientsAction from '../../../controllers/api/v1/apps/get-auth-clients.ee.js'; -import getAuthClientAction from '../../../controllers/api/v1/apps/get-auth-client.ee.js'; +import getOAuthClientsAction from '../../../controllers/api/v1/apps/get-oauth-clients.ee.js'; +import getOAuthClientAction from '../../../controllers/api/v1/apps/get-oauth-client.ee.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'; @@ -44,17 +44,17 @@ router.get( ); router.get( - '/:appKey/auth-clients', + '/:appKey/oauth-clients', authenticateUser, checkIsEnterprise, - getAuthClientsAction + getOAuthClientsAction ); router.get( - '/:appKey/auth-clients/:appAuthClientId', + '/:appKey/oauth-clients/:oauthClientId', authenticateUser, checkIsEnterprise, - getAuthClientAction + getOAuthClientAction ); router.get('/:appKey/triggers', authenticateUser, getTriggersAction); diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 8b507b82..10b19e74 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -9,6 +9,7 @@ import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; +import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js'; const router = Router(); @@ -17,6 +18,13 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); +router.post( + '/:flowId/export', + authenticateUser, + authorizeUser, + exportFlowAction +); + router.patch( '/:flowId/status', authenticateUser, diff --git a/packages/backend/src/routes/webhooks.js b/packages/backend/src/routes/webhooks.js index 98cadef0..cd2f359b 100644 --- a/packages/backend/src/routes/webhooks.js +++ b/packages/backend/src/routes/webhooks.js @@ -4,7 +4,6 @@ import multer from 'multer'; import appConfig from '../config/app.js'; import webhookHandlerByFlowId from '../controllers/webhooks/handler-by-flow-id.js'; import webhookHandlerSyncByFlowId from '../controllers/webhooks/handler-sync-by-flow-id.js'; -import webhookHandlerByConnectionIdAndRefValue from '../controllers/webhooks/handler-by-connection-id-and-ref-value.js'; const router = Router(); const upload = multer(); @@ -39,14 +38,6 @@ function createRouteHandler(path, handler) { .post(wrappedHandler); } -createRouteHandler( - '/connections/:connectionId/:refValue', - webhookHandlerByConnectionIdAndRefValue -); -createRouteHandler( - '/connections/:connectionId', - webhookHandlerByConnectionIdAndRefValue -); createRouteHandler('/flows/:flowId/sync', webhookHandlerSyncByFlowId); createRouteHandler('/flows/:flowId', webhookHandlerByFlowId); createRouteHandler('/:flowId', webhookHandlerByFlowId); diff --git a/packages/backend/src/serializers/app-auth-client.js b/packages/backend/src/serializers/app-auth-client.js deleted file mode 100644 index 88af3dab..00000000 --- a/packages/backend/src/serializers/app-auth-client.js +++ /dev/null @@ -1,10 +0,0 @@ -const appAuthClientSerializer = (appAuthClient) => { - return { - id: appAuthClient.id, - appConfigId: appAuthClient.appConfigId, - name: appAuthClient.name, - active: appAuthClient.active, - }; -}; - -export default appAuthClientSerializer; diff --git a/packages/backend/src/serializers/app-auth-client.test.js b/packages/backend/src/serializers/app-auth-client.test.js deleted file mode 100644 index d4ed178e..00000000 --- a/packages/backend/src/serializers/app-auth-client.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { createAppAuthClient } from '../../test/factories/app-auth-client'; -import appAuthClientSerializer from './app-auth-client'; - -describe('appAuthClient serializer', () => { - let appAuthClient; - - beforeEach(async () => { - appAuthClient = await createAppAuthClient(); - }); - - it('should return app auth client data', async () => { - const expectedPayload = { - id: appAuthClient.id, - appConfigId: appAuthClient.appConfigId, - name: appAuthClient.name, - active: appAuthClient.active, - }; - - expect(appAuthClientSerializer(appAuthClient)).toStrictEqual( - expectedPayload - ); - }); -}); diff --git a/packages/backend/src/serializers/app-config.js b/packages/backend/src/serializers/app-config.js index d5f17ef2..82888815 100644 --- a/packages/backend/src/serializers/app-config.js +++ b/packages/backend/src/serializers/app-config.js @@ -1,10 +1,8 @@ const appConfigSerializer = (appConfig) => { return { key: appConfig.key, - customConnectionAllowed: appConfig.customConnectionAllowed, - shared: appConfig.shared, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, disabled: appConfig.disabled, - connectionAllowed: appConfig.connectionAllowed, createdAt: appConfig.createdAt.getTime(), updatedAt: appConfig.updatedAt.getTime(), }; diff --git a/packages/backend/src/serializers/app-config.test.js b/packages/backend/src/serializers/app-config.test.js index 61a46a1c..5ccdd026 100644 --- a/packages/backend/src/serializers/app-config.test.js +++ b/packages/backend/src/serializers/app-config.test.js @@ -12,10 +12,8 @@ describe('appConfig serializer', () => { it('should return app config data', async () => { const expectedPayload = { key: appConfig.key, - customConnectionAllowed: appConfig.customConnectionAllowed, - shared: appConfig.shared, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, disabled: appConfig.disabled, - connectionAllowed: appConfig.connectionAllowed, createdAt: appConfig.createdAt.getTime(), updatedAt: appConfig.updatedAt.getTime(), }; diff --git a/packages/backend/src/serializers/app.js b/packages/backend/src/serializers/app.js index 57e0306d..2c8adb9f 100644 --- a/packages/backend/src/serializers/app.js +++ b/packages/backend/src/serializers/app.js @@ -6,6 +6,7 @@ const appSerializer = (app) => { primaryColor: app.primaryColor, authDocUrl: app.authDocUrl, supportsConnections: app.supportsConnections, + supportsOauthClients: app?.auth?.generateAuthUrl ? true : false, }; if (app.connectionCount) { diff --git a/packages/backend/src/serializers/app.test.js b/packages/backend/src/serializers/app.test.js index ec5716a9..513792e7 100644 --- a/packages/backend/src/serializers/app.test.js +++ b/packages/backend/src/serializers/app.test.js @@ -12,6 +12,7 @@ describe('appSerializer', () => { iconUrl: app.iconUrl, authDocUrl: app.authDocUrl, supportsConnections: app.supportsConnections, + supportsOauthClients: app.auth.generateAuthUrl ? true : false, primaryColor: app.primaryColor, }; diff --git a/packages/backend/src/serializers/auth.js b/packages/backend/src/serializers/auth.js index c5d60a4e..da942e6f 100644 --- a/packages/backend/src/serializers/auth.js +++ b/packages/backend/src/serializers/auth.js @@ -2,7 +2,9 @@ const authSerializer = (auth) => { return { fields: auth.fields, authenticationSteps: auth.authenticationSteps, + sharedAuthenticationSteps: auth.sharedAuthenticationSteps, reconnectionSteps: auth.reconnectionSteps, + sharedReconnectionSteps: auth.sharedReconnectionSteps, }; }; diff --git a/packages/backend/src/serializers/auth.test.js b/packages/backend/src/serializers/auth.test.js index e9adb259..ef2d1bd6 100644 --- a/packages/backend/src/serializers/auth.test.js +++ b/packages/backend/src/serializers/auth.test.js @@ -10,6 +10,8 @@ describe('authSerializer', () => { fields: auth.fields, authenticationSteps: auth.authenticationSteps, reconnectionSteps: auth.reconnectionSteps, + sharedAuthenticationSteps: auth.sharedAuthenticationSteps, + sharedReconnectionSteps: auth.sharedReconnectionSteps, }; expect(authSerializer(auth)).toStrictEqual(expectedPayload); diff --git a/packages/backend/src/serializers/connection.js b/packages/backend/src/serializers/connection.js index e285f1e2..70224476 100644 --- a/packages/backend/src/serializers/connection.js +++ b/packages/backend/src/serializers/connection.js @@ -2,8 +2,7 @@ const connectionSerializer = (connection) => { return { id: connection.id, key: connection.key, - reconnectable: connection.reconnectable, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: { screenName: connection.formattedData.screenName, }, diff --git a/packages/backend/src/serializers/connection.test.js b/packages/backend/src/serializers/connection.test.js index c322af6b..bb9db58a 100644 --- a/packages/backend/src/serializers/connection.test.js +++ b/packages/backend/src/serializers/connection.test.js @@ -13,8 +13,7 @@ describe('connectionSerializer', () => { const expectedPayload = { id: connection.id, key: connection.key, - reconnectable: connection.reconnectable, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: { screenName: connection.formattedData.screenName, }, diff --git a/packages/backend/src/serializers/index.js b/packages/backend/src/serializers/index.js index 8fdeb888..4525b5ae 100644 --- a/packages/backend/src/serializers/index.js +++ b/packages/backend/src/serializers/index.js @@ -4,12 +4,13 @@ import permissionSerializer from './permission.js'; import adminSamlAuthProviderSerializer from './admin-saml-auth-provider.ee.js'; import samlAuthProviderSerializer from './saml-auth-provider.ee.js'; import samlAuthProviderRoleMappingSerializer from './role-mapping.ee.js'; -import appAuthClientSerializer from './app-auth-client.js'; +import oauthClientSerializer from './oauth-client.js'; import appConfigSerializer from './app-config.js'; import flowSerializer from './flow.js'; import stepSerializer from './step.js'; import connectionSerializer from './connection.js'; import appSerializer from './app.js'; +import userAppSerializer from './user-app.js'; import authSerializer from './auth.js'; import triggerSerializer from './trigger.js'; import actionSerializer from './action.js'; @@ -27,12 +28,13 @@ const serializers = { AdminSamlAuthProvider: adminSamlAuthProviderSerializer, SamlAuthProvider: samlAuthProviderSerializer, RoleMapping: samlAuthProviderRoleMappingSerializer, - AppAuthClient: appAuthClientSerializer, + OAuthClient: oauthClientSerializer, AppConfig: appConfigSerializer, Flow: flowSerializer, Step: stepSerializer, Connection: connectionSerializer, App: appSerializer, + UserApp: userAppSerializer, Auth: authSerializer, Trigger: triggerSerializer, Action: actionSerializer, diff --git a/packages/backend/src/serializers/oauth-client.js b/packages/backend/src/serializers/oauth-client.js new file mode 100644 index 00000000..bacebafc --- /dev/null +++ b/packages/backend/src/serializers/oauth-client.js @@ -0,0 +1,10 @@ +const oauthClientSerializer = (oauthClient) => { + return { + id: oauthClient.id, + appConfigId: oauthClient.appConfigId, + name: oauthClient.name, + active: oauthClient.active, + }; +}; + +export default oauthClientSerializer; diff --git a/packages/backend/src/serializers/oauth-client.test.js b/packages/backend/src/serializers/oauth-client.test.js new file mode 100644 index 00000000..d5ab8d70 --- /dev/null +++ b/packages/backend/src/serializers/oauth-client.test.js @@ -0,0 +1,22 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createOAuthClient } from '../../test/factories/oauth-client'; +import oauthClientSerializer from './oauth-client'; + +describe('oauthClient serializer', () => { + let oauthClient; + + beforeEach(async () => { + oauthClient = await createOAuthClient(); + }); + + it('should return oauth client data', async () => { + const expectedPayload = { + id: oauthClient.id, + appConfigId: oauthClient.appConfigId, + name: oauthClient.name, + active: oauthClient.active, + }; + + expect(oauthClientSerializer(oauthClient)).toStrictEqual(expectedPayload); + }); +}); diff --git a/packages/backend/src/serializers/step.js b/packages/backend/src/serializers/step.js index f5ae1c26..5c1e0d38 100644 --- a/packages/backend/src/serializers/step.js +++ b/packages/backend/src/serializers/step.js @@ -5,6 +5,7 @@ const stepSerializer = (step) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/src/serializers/step.test.js b/packages/backend/src/serializers/step.test.js index dfeffbeb..2b26cfcf 100644 --- a/packages/backend/src/serializers/step.test.js +++ b/packages/backend/src/serializers/step.test.js @@ -16,6 +16,7 @@ describe('stepSerializer', () => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/src/serializers/user-app.js b/packages/backend/src/serializers/user-app.js new file mode 100644 index 00000000..0d16865b --- /dev/null +++ b/packages/backend/src/serializers/user-app.js @@ -0,0 +1,22 @@ +const userAppSerializer = (userApp) => { + let appData = { + key: userApp.key, + name: userApp.name, + iconUrl: userApp.iconUrl, + primaryColor: userApp.primaryColor, + authDocUrl: userApp.authDocUrl, + supportsConnections: userApp.supportsConnections, + }; + + if (userApp.connectionCount) { + appData.connectionCount = userApp.connectionCount; + } + + if (userApp.flowCount) { + appData.flowCount = userApp.flowCount; + } + + return appData; +}; + +export default userAppSerializer; diff --git a/packages/backend/src/workers/action.js b/packages/backend/src/workers/action.js index 3159a7d6..b0728059 100644 --- a/packages/backend/src/workers/action.js +++ b/packages/backend/src/workers/action.js @@ -1,76 +1,6 @@ -import { Worker } from 'bullmq'; +import { generateWorker } from './worker.js'; +import { executeActionJob } from '../jobs/execute-action.js'; -import * as Sentry from '../helpers/sentry.ee.js'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; -import Step from '../models/step.js'; -import actionQueue from '../queues/action.js'; -import { processAction } from '../services/action.js'; -import { - REMOVE_AFTER_30_DAYS_OR_150_JOBS, - REMOVE_AFTER_7_DAYS_OR_50_JOBS, -} from '../helpers/remove-job-configuration.js'; -import delayAsMilliseconds from '../helpers/delay-as-milliseconds.js'; - -const DEFAULT_DELAY_DURATION = 0; - -const actionWorker = new Worker( - 'action', - async (job) => { - const { stepId, flowId, executionId, computedParameters, executionStep } = - await processAction(job.data); - - if (executionStep.isFailed) return; - - const step = await Step.query().findById(stepId).throwIfNotFound(); - const nextStep = await step.getNextStep(); - - if (!nextStep) return; - - const jobName = `${executionId}-${nextStep.id}`; - - const jobPayload = { - flowId, - executionId, - stepId: nextStep.id, - }; - - const jobOptions = { - removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, - removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, - delay: DEFAULT_DELAY_DURATION, - }; - - if (step.appKey === 'delay') { - jobOptions.delay = delayAsMilliseconds(step.key, computedParameters); - } - - if (step.appKey === 'filter' && !executionStep.dataOut) { - return; - } - - await actionQueue.add(jobName, jobPayload, jobOptions); - }, - { connection: redisConfig } -); - -actionWorker.on('completed', (job) => { - logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`); -}); - -actionWorker.on('failed', (job, err) => { - const errorMessage = ` - JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message} - \n ${err.stack} - `; - - logger.error(errorMessage); - - Sentry.captureException(err, { - extra: { - jobId: job.id, - }, - }); -}); +const actionWorker = generateWorker('action', executeActionJob); export default actionWorker; diff --git a/packages/backend/src/workers/delete-user.ee.js b/packages/backend/src/workers/delete-user.ee.js index 9081df20..c47093b9 100644 --- a/packages/backend/src/workers/delete-user.ee.js +++ b/packages/backend/src/workers/delete-user.ee.js @@ -1,69 +1,6 @@ -import { Worker } from 'bullmq'; +import { generateWorker } from './worker.js'; +import { deleteUserJob } from '../jobs/delete-user.ee.js'; -import * as Sentry from '../helpers/sentry.ee.js'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; -import appConfig from '../config/app.js'; -import User from '../models/user.js'; -import ExecutionStep from '../models/execution-step.js'; - -const deleteUserWorker = new Worker( - 'delete-user', - async (job) => { - const { id } = job.data; - - const user = await User.query() - .withSoftDeleted() - .findById(id) - .throwIfNotFound(); - - const executionIds = ( - await user - .$relatedQuery('executions') - .withSoftDeleted() - .select('executions.id') - ).map((execution) => execution.id); - - await ExecutionStep.query() - .withSoftDeleted() - .whereIn('execution_id', executionIds) - .hardDelete(); - await user.$relatedQuery('executions').withSoftDeleted().hardDelete(); - await user.$relatedQuery('steps').withSoftDeleted().hardDelete(); - await user.$relatedQuery('flows').withSoftDeleted().hardDelete(); - await user.$relatedQuery('connections').withSoftDeleted().hardDelete(); - await user.$relatedQuery('identities').withSoftDeleted().hardDelete(); - - if (appConfig.isCloud) { - await user.$relatedQuery('subscriptions').withSoftDeleted().hardDelete(); - await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); - } - - await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete(); - await user.$query().withSoftDeleted().hardDelete(); - }, - { connection: redisConfig } -); - -deleteUserWorker.on('completed', (job) => { - logger.info( - `JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has been deleted!` - ); -}); - -deleteUserWorker.on('failed', (job, err) => { - const errorMessage = ` - JOB ID: ${job.id} - The user with the ID of '${job.data.id}' has failed to be deleted! ${err.message} - \n ${err.stack} - `; - - logger.error(errorMessage); - - Sentry.captureException(err, { - extra: { - jobId: job.id, - }, - }); -}); +const deleteUserWorker = generateWorker('delete-user', deleteUserJob); export default deleteUserWorker; diff --git a/packages/backend/src/workers/email.js b/packages/backend/src/workers/email.js index 92bf0367..4cd2c1bb 100644 --- a/packages/backend/src/workers/email.js +++ b/packages/backend/src/workers/email.js @@ -1,62 +1,6 @@ -import { Worker } from 'bullmq'; +import { generateWorker } from './worker.js'; +import { sendEmailJob } from '../jobs/send-email.js'; -import * as Sentry from '../helpers/sentry.ee.js'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; -import mailer from '../helpers/mailer.ee.js'; -import compileEmail from '../helpers/compile-email.ee.js'; -import appConfig from '../config/app.js'; - -const isCloudSandbox = () => { - return appConfig.isCloud && !appConfig.isProd; -}; - -const isAutomatischEmail = (email) => { - return email.endsWith('@automatisch.io'); -}; - -const emailWorker = new Worker( - 'email', - async (job) => { - const { email, subject, template, params } = job.data; - - if (isCloudSandbox() && !isAutomatischEmail(email)) { - logger.info( - 'Only Automatisch emails are allowed for non-production environments!' - ); - - return; - } - - await mailer.sendMail({ - to: email, - from: appConfig.fromEmail, - subject: subject, - html: compileEmail(template, params), - }); - }, - { connection: redisConfig } -); - -emailWorker.on('completed', (job) => { - logger.info( - `JOB ID: ${job.id} - ${job.data.subject} email sent to ${job.data.email}!` - ); -}); - -emailWorker.on('failed', (job, err) => { - const errorMessage = ` - JOB ID: ${job.id} - ${job.data.subject} email to ${job.data.email} has failed to send with ${err.message} - \n ${err.stack} - `; - - logger.error(errorMessage); - - Sentry.captureException(err, { - extra: { - jobId: job.id, - }, - }); -}); +const emailWorker = generateWorker('email', sendEmailJob); export default emailWorker; diff --git a/packages/backend/src/workers/flow.js b/packages/backend/src/workers/flow.js index 8c08d5e1..7b04bef2 100644 --- a/packages/backend/src/workers/flow.js +++ b/packages/backend/src/workers/flow.js @@ -1,97 +1,6 @@ -import { Worker } from 'bullmq'; +import { generateWorker } from './worker.js'; +import { executeFlowJob } from '../jobs/execute-flow.js'; -import * as Sentry from '../helpers/sentry.ee.js'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; -import flowQueue from '../queues/flow.js'; -import triggerQueue from '../queues/trigger.js'; -import { processFlow } from '../services/flow.js'; -import Flow from '../models/flow.js'; -import { - REMOVE_AFTER_30_DAYS_OR_150_JOBS, - REMOVE_AFTER_7_DAYS_OR_50_JOBS, -} from '../helpers/remove-job-configuration.js'; - -const flowWorker = new Worker( - 'flow', - async (job) => { - const { flowId } = job.data; - - const flow = await Flow.query().findById(flowId).throwIfNotFound(); - const user = await flow.$relatedQuery('user'); - const allowedToRunFlows = await user.isAllowedToRunFlows(); - - if (!allowedToRunFlows) { - return; - } - - const triggerStep = await flow.getTriggerStep(); - - const { data, error } = await processFlow({ flowId }); - - const reversedData = data.reverse(); - - const jobOptions = { - removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, - removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, - }; - - for (const triggerItem of reversedData) { - const jobName = `${triggerStep.id}-${triggerItem.meta.internalId}`; - - const jobPayload = { - flowId, - stepId: triggerStep.id, - triggerItem, - }; - - await triggerQueue.add(jobName, jobPayload, jobOptions); - } - - if (error) { - const jobName = `${triggerStep.id}-error`; - - const jobPayload = { - flowId, - stepId: triggerStep.id, - error, - }; - - await triggerQueue.add(jobName, jobPayload, jobOptions); - } - }, - { connection: redisConfig } -); - -flowWorker.on('completed', (job) => { - logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`); -}); - -flowWorker.on('failed', async (job, err) => { - const errorMessage = ` - JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message} - \n ${err.stack} - `; - - logger.error(errorMessage); - - const flow = await Flow.query().findById(job.data.flowId); - - if (!flow) { - await flowQueue.removeRepeatableByKey(job.repeatJobKey); - - const flowNotFoundErrorMessage = ` - JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has been deleted from Redis because flow was not found! - `; - - logger.error(flowNotFoundErrorMessage); - } - - Sentry.captureException(err, { - extra: { - jobId: job.id, - }, - }); -}); +const flowWorker = generateWorker('flow', executeFlowJob); export default flowWorker; diff --git a/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js b/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js index 6ee0ae17..83df0865 100644 --- a/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js +++ b/packages/backend/src/workers/remove-cancelled-subscriptions.ee.js @@ -1,44 +1,9 @@ -import { Worker } from 'bullmq'; -import { DateTime } from 'luxon'; -import * as Sentry from '../helpers/sentry.ee.js'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; -import Subscription from '../models/subscription.ee.js'; +import { generateWorker } from './worker.js'; +import { removeCancelledSubscriptionsJob } from '../jobs/remove-cancelled-subscriptions.ee.js'; -const removeCancelledSubscriptionsWorker = new Worker( +const removeCancelledSubscriptionsWorker = generateWorker( 'remove-cancelled-subscriptions', - async () => { - await Subscription.query() - .delete() - .where({ - status: 'deleted', - }) - .andWhere( - 'cancellation_effective_date', - '<=', - DateTime.now().startOf('day').toISODate() - ); - }, - { connection: redisConfig } + removeCancelledSubscriptionsJob ); -removeCancelledSubscriptionsWorker.on('completed', (job) => { - logger.info( - `JOB ID: ${job.id} - The cancelled subscriptions have been removed!` - ); -}); - -removeCancelledSubscriptionsWorker.on('failed', (job, err) => { - const errorMessage = ` - JOB ID: ${job.id} - ERROR: The cancelled subscriptions can not be removed! ${err.message} - \n ${err.stack} - `; - logger.error(errorMessage); - Sentry.captureException(err, { - extra: { - jobId: job.id, - }, - }); -}); - export default removeCancelledSubscriptionsWorker; diff --git a/packages/backend/src/workers/trigger.js b/packages/backend/src/workers/trigger.js index 64056dd9..e25915fc 100644 --- a/packages/backend/src/workers/trigger.js +++ b/packages/backend/src/workers/trigger.js @@ -1,62 +1,6 @@ -import { Worker } from 'bullmq'; +import { generateWorker } from './worker.js'; +import { executeTriggerJob } from '../jobs/execute-trigger.js'; -import * as Sentry from '../helpers/sentry.ee.js'; -import redisConfig from '../config/redis.js'; -import logger from '../helpers/logger.js'; -import actionQueue from '../queues/action.js'; -import Step from '../models/step.js'; -import { processTrigger } from '../services/trigger.js'; -import { - REMOVE_AFTER_30_DAYS_OR_150_JOBS, - REMOVE_AFTER_7_DAYS_OR_50_JOBS, -} from '../helpers/remove-job-configuration.js'; - -const triggerWorker = new Worker( - 'trigger', - async (job) => { - const { flowId, executionId, stepId, executionStep } = await processTrigger( - job.data - ); - - if (executionStep.isFailed) return; - - const step = await Step.query().findById(stepId).throwIfNotFound(); - const nextStep = await step.getNextStep(); - const jobName = `${executionId}-${nextStep.id}`; - - const jobPayload = { - flowId, - executionId, - stepId: nextStep.id, - }; - - const jobOptions = { - removeOnComplete: REMOVE_AFTER_7_DAYS_OR_50_JOBS, - removeOnFail: REMOVE_AFTER_30_DAYS_OR_150_JOBS, - }; - - await actionQueue.add(jobName, jobPayload, jobOptions); - }, - { connection: redisConfig } -); - -triggerWorker.on('completed', (job) => { - logger.info(`JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has started!`); -}); - -triggerWorker.on('failed', (job, err) => { - const errorMessage = ` - JOB ID: ${job.id} - FLOW ID: ${job.data.flowId} has failed to start with ${err.message} - \n ${err.stack} - `; - - logger.error(errorMessage); - - Sentry.captureException(err, { - extra: { - jobId: job.id, - }, - }); -}); +const triggerWorker = generateWorker('flow', executeTriggerJob); export default triggerWorker; diff --git a/packages/backend/src/workers/worker.js b/packages/backend/src/workers/worker.js new file mode 100644 index 00000000..5528a24a --- /dev/null +++ b/packages/backend/src/workers/worker.js @@ -0,0 +1,28 @@ +import { Worker } from 'bullmq'; + +import * as Sentry from '../helpers/sentry.ee.js'; +import redisConfig from '../config/redis.js'; +import logger from '../helpers/logger.js'; + +export const generateWorker = (workerName, job) => { + const worker = new Worker(workerName, job, { connection: redisConfig }); + + worker.on('completed', (job) => { + logger.info(`JOB ID: ${job.id} - has been successfully completed!`); + }); + + worker.on('failed', (job, err) => { + logger.error(` + JOB ID: ${job.id} - has failed to be completed! ${err.message} + \n ${err.stack} + `); + + Sentry.captureException(err, { + extra: { + jobId: job.id, + }, + }); + }); + + return worker; +}; diff --git a/packages/backend/test/factories/app-auth-client.js b/packages/backend/test/factories/oauth-client.js similarity index 67% rename from packages/backend/test/factories/app-auth-client.js rename to packages/backend/test/factories/oauth-client.js index 831d4c14..0b0f6b9b 100644 --- a/packages/backend/test/factories/app-auth-client.js +++ b/packages/backend/test/factories/oauth-client.js @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import AppAuthClient from '../../src/models/app-auth-client'; +import OAuthClient from '../../src/models/oauth-client'; const formattedAuthDefaults = { oAuthRedirectUrl: faker.internet.url(), @@ -8,14 +8,14 @@ const formattedAuthDefaults = { clientSecret: faker.string.uuid(), }; -export const createAppAuthClient = async (params = {}) => { +export const createOAuthClient = async (params = {}) => { params.name = params?.name || faker.person.fullName(); params.appKey = params?.appKey || 'deepl'; params.active = params?.active ?? true; params.formattedAuthDefaults = params?.formattedAuthDefaults || formattedAuthDefaults; - const appAuthClient = await AppAuthClient.query().insertAndFetch(params); + const oauthClient = await OAuthClient.query().insertAndFetch(params); - return appAuthClient; + return oauthClient; }; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-auth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-auth-client.js deleted file mode 100644 index f91c8500..00000000 --- a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-auth-client.js +++ /dev/null @@ -1,17 +0,0 @@ -const createAppAuthClientMock = (appAuthClient) => { - return { - data: { - name: appAuthClient.name, - active: appAuthClient.active, - }, - meta: { - count: 1, - currentPage: null, - isArray: false, - totalPages: null, - type: 'AppAuthClient', - }, - }; -}; - -export default createAppAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js index 52e425ab..8fb199d7 100644 --- a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-config.js @@ -2,8 +2,7 @@ const createAppConfigMock = (appConfig) => { return { data: { key: appConfig.key, - customConnectionAllowed: appConfig.customConnectionAllowed, - shared: appConfig.shared, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, disabled: appConfig.disabled, }, meta: { diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js new file mode 100644 index 00000000..10e4e9b7 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/create-oauth-client.js @@ -0,0 +1,17 @@ +const createOAuthClientMock = (oauthClient) => { + return { + data: { + name: oauthClient.name, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default createOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-auth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-auth-client.js deleted file mode 100644 index 4d437eca..00000000 --- a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-auth-client.js +++ /dev/null @@ -1,18 +0,0 @@ -const getAppAuthClientMock = (appAuthClient) => { - return { - data: { - name: appAuthClient.name, - id: appAuthClient.id, - active: appAuthClient.active, - }, - meta: { - count: 1, - currentPage: null, - isArray: false, - totalPages: null, - type: 'AppAuthClient', - }, - }; -}; - -export default getAppAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-auth-clients.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-auth-clients.js deleted file mode 100644 index dd0cc5ee..00000000 --- a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-auth-clients.js +++ /dev/null @@ -1,18 +0,0 @@ -const getAdminAppAuthClientsMock = (appAuthClients) => { - return { - data: appAuthClients.map((appAuthClient) => ({ - name: appAuthClient.name, - id: appAuthClient.id, - active: appAuthClient.active, - })), - meta: { - count: appAuthClients.length, - currentPage: null, - isArray: true, - totalPages: null, - type: 'AppAuthClient', - }, - }; -}; - -export default getAdminAppAuthClientsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js new file mode 100644 index 00000000..1431b968 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-client.js @@ -0,0 +1,18 @@ +const getOAuthClientMock = (oauthClient) => { + return { + data: { + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js new file mode 100644 index 00000000..c0bd5d54 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/get-oauth-clients.js @@ -0,0 +1,18 @@ +const getAdminOAuthClientsMock = (oauthClients) => { + return { + data: oauthClients.map((oauthClient) => ({ + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + })), + meta: { + count: oauthClients.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getAdminOAuthClientsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/update-auth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/update-auth-client.js deleted file mode 100644 index 9d4dea24..00000000 --- a/packages/backend/test/mocks/rest/api/v1/admin/apps/update-auth-client.js +++ /dev/null @@ -1,18 +0,0 @@ -const updateAppAuthClientMock = (appAuthClient) => { - return { - data: { - id: appAuthClient.id, - name: appAuthClient.name, - active: appAuthClient.active, - }, - meta: { - count: 1, - currentPage: null, - isArray: false, - totalPages: null, - type: 'AppAuthClient', - }, - }; -}; - -export default updateAppAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js new file mode 100644 index 00000000..bdb5294d --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/admin/apps/update-oauth-client.js @@ -0,0 +1,18 @@ +const updateOAuthClientMock = (oauthClient) => { + return { + data: { + id: oauthClient.id, + name: oauthClient.name, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default updateOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js b/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js index 9e993a4c..ccbeba23 100644 --- a/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js +++ b/packages/backend/test/mocks/rest/api/v1/apps/create-connection.js @@ -2,8 +2,7 @@ const createConnection = (connection) => { const connectionData = { id: connection.id, key: connection.key, - reconnectable: connection.reconnectable || true, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: connection.formattedData, verified: connection.verified || false, createdAt: connection.createdAt.getTime(), diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-app.js b/packages/backend/test/mocks/rest/api/v1/apps/get-app.js index e5b96c38..25061912 100644 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-app.js +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-app.js @@ -7,6 +7,7 @@ const getAppMock = (app) => { name: app.name, primaryColor: app.primaryColor, supportsConnections: app.supportsConnections, + supportsOauthClients: app.auth.generateAuthUrl ? true : false, }, meta: { count: 1, diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js b/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js index a097d1f2..e1892d42 100644 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-apps.js @@ -6,6 +6,7 @@ const getAppsMock = (apps) => { name: app.name, primaryColor: app.primaryColor, supportsConnections: app.supportsConnections, + supportsOauthClients: app?.auth?.generateAuthUrl ? true : false, })); return { diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-auth-client.js b/packages/backend/test/mocks/rest/api/v1/apps/get-auth-client.js deleted file mode 100644 index 4d437eca..00000000 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-auth-client.js +++ /dev/null @@ -1,18 +0,0 @@ -const getAppAuthClientMock = (appAuthClient) => { - return { - data: { - name: appAuthClient.name, - id: appAuthClient.id, - active: appAuthClient.active, - }, - meta: { - count: 1, - currentPage: null, - isArray: false, - totalPages: null, - type: 'AppAuthClient', - }, - }; -}; - -export default getAppAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-auth-clients.js b/packages/backend/test/mocks/rest/api/v1/apps/get-auth-clients.js deleted file mode 100644 index 0a697dec..00000000 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-auth-clients.js +++ /dev/null @@ -1,18 +0,0 @@ -const getAppAuthClientsMock = (appAuthClients) => { - return { - data: appAuthClients.map((appAuthClient) => ({ - name: appAuthClient.name, - id: appAuthClient.id, - active: appAuthClient.active, - })), - meta: { - count: appAuthClients.length, - currentPage: null, - isArray: true, - totalPages: null, - type: 'AppAuthClient', - }, - }; -}; - -export default getAppAuthClientsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js b/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js index 68ea18cd..d42b9724 100644 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-auth.js @@ -4,6 +4,8 @@ const getAuthMock = (auth) => { fields: auth.fields, authenticationSteps: auth.authenticationSteps, reconnectionSteps: auth.reconnectionSteps, + sharedReconnectionSteps: auth.sharedReconnectionSteps, + sharedAuthenticationSteps: auth.sharedAuthenticationSteps, }, meta: { count: 1, diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-config.js b/packages/backend/test/mocks/rest/api/v1/apps/get-config.js index 3cb4ab11..97827f59 100644 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-config.js +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-config.js @@ -2,10 +2,8 @@ const getAppConfigMock = (appConfig) => { return { data: { key: appConfig.key, - customConnectionAllowed: appConfig.customConnectionAllowed, - shared: appConfig.shared, + useOnlyPredefinedAuthClients: appConfig.useOnlyPredefinedAuthClients, disabled: appConfig.disabled, - connectionAllowed: appConfig.connectionAllowed, createdAt: appConfig.createdAt.getTime(), updatedAt: appConfig.updatedAt.getTime(), }, diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js b/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js index a6242e80..d7b9f0e9 100644 --- a/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-connections.js @@ -3,9 +3,8 @@ const getConnectionsMock = (connections) => { data: connections.map((connection) => ({ id: connection.id, key: connection.key, - reconnectable: connection.reconnectable, verified: connection.verified, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: { screenName: connection.formattedData.screenName, }, diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js new file mode 100644 index 00000000..1431b968 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-client.js @@ -0,0 +1,18 @@ +const getOAuthClientMock = (oauthClient) => { + return { + data: { + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + }, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getOAuthClientMock; diff --git a/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js new file mode 100644 index 00000000..549544b0 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/apps/get-oauth-clients.js @@ -0,0 +1,18 @@ +const getOAuthClientsMock = (oauthClients) => { + return { + data: oauthClients.map((oauthClient) => ({ + name: oauthClient.name, + id: oauthClient.id, + active: oauthClient.active, + })), + meta: { + count: oauthClients.length, + currentPage: null, + isArray: true, + totalPages: null, + type: 'OAuthClient', + }, + }; +}; + +export default getOAuthClientsMock; diff --git a/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js b/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js index 7d95fa10..f618a641 100644 --- a/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js +++ b/packages/backend/test/mocks/rest/api/v1/connections/reset-connection.js @@ -3,8 +3,7 @@ const resetConnectionMock = (connection) => { id: connection.id, key: connection.key, verified: connection.verified, - reconnectable: connection.reconnectable, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: { screenName: connection.formattedData.screenName, }, diff --git a/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js b/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js index b059d27e..306f7726 100644 --- a/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js +++ b/packages/backend/test/mocks/rest/api/v1/connections/update-connection.js @@ -3,8 +3,7 @@ const updateConnectionMock = (connection) => { id: connection.id, key: connection.key, verified: connection.verified, - reconnectable: connection.reconnectable, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: { screenName: connection.formattedData.screenName, }, diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js index f7b50194..f694f70a 100644 --- a/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution-steps.js @@ -14,6 +14,7 @@ const getExecutionStepsMock = async (executionSteps, steps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js index 3957e9d8..61feddd8 100644 --- a/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-execution.js @@ -15,6 +15,7 @@ const getExecutionMock = async (execution, flow, steps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js index 21d36376..b194bee2 100644 --- a/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js +++ b/packages/backend/test/mocks/rest/api/v1/executions/get-executions.js @@ -16,6 +16,7 @@ const getExecutionsMock = async (executions, flow, steps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js index 67684191..36426774 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/duplicate-flow.js @@ -14,6 +14,7 @@ const duplicateFlowMock = async (flow, steps = []) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js new file mode 100644 index 00000000..c7a1ef6e --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -0,0 +1,41 @@ +import { expect } from 'vitest'; + +const exportFlowMock = async (flow, steps = []) => { + const data = { + id: expect.any(String), + name: flow.name, + }; + + if (steps.length) { + data.steps = steps.map((step) => { + const computedStep = { + id: expect.any(String), + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: expect.any(Object), + position: step.position, + }; + + if (step.type === 'trigger') { + computedStep.webhookPath = expect.stringContaining('/webhooks/flows/'); + } + + return computedStep; + }); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default exportFlowMock; diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js index db1e4a47..49efe83c 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flow.js @@ -14,6 +14,7 @@ const getFlowMock = async (flow, steps = []) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js index 0509aec3..6012a6f6 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/get-flows.js @@ -14,6 +14,7 @@ const getFlowsMock = async (flows, steps) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js index f303f295..f7c32b3b 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/update-flow-status.js @@ -14,6 +14,7 @@ const updateFlowStatusMock = async (flow, steps = []) => { iconUrl: step.iconUrl, id: step.id, key: step.key, + name: step.name, parameters: step.parameters, position: step.position, status: step.status, diff --git a/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js b/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js index 3f6c8abb..18731302 100644 --- a/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js +++ b/packages/backend/test/mocks/rest/api/v1/steps/get-connection.js @@ -3,8 +3,7 @@ const getConnectionMock = async (connection) => { id: connection.id, key: connection.key, verified: connection.verified, - reconnectable: connection.reconnectable, - appAuthClientId: connection.appAuthClientId, + oauthClientId: connection.oauthClientId, formattedData: { screenName: connection.formattedData.screenName, }, diff --git a/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js b/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js index 7b5515ed..4ae477d6 100644 --- a/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js +++ b/packages/backend/test/mocks/rest/api/v1/steps/get-previous-steps.js @@ -8,6 +8,7 @@ const getPreviousStepsMock = async (steps, executionSteps) => { id: step.id, type: step.type, key: step.key, + name: step.name, appKey: step.appKey, iconUrl: step.iconUrl, webhookUrl: step.webhookUrl, diff --git a/packages/backend/test/mocks/rest/api/v1/steps/update-step.js b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js index 87514ef9..a7ad0dea 100644 --- a/packages/backend/test/mocks/rest/api/v1/steps/update-step.js +++ b/packages/backend/test/mocks/rest/api/v1/steps/update-step.js @@ -3,6 +3,7 @@ const updateStepMock = (step) => { id: step.id, type: step.type || 'action', key: step.key || null, + name: step.name || null, appKey: step.appKey || null, iconUrl: step.iconUrl || null, webhookUrl: step.webhookUrl || null, diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js index a62dfa53..9c2ef104 100644 --- a/packages/backend/vitest.config.js +++ b/packages/backend/vitest.config.js @@ -13,13 +13,25 @@ export default defineConfig({ reportsDirectory: './coverage', reporter: ['text', 'lcov'], all: true, - include: ['**/src/models/**', '**/src/controllers/**'], + include: [ + '**/src/controllers/**', + '**/src/helpers/authentication.test.js', + '**/src/helpers/axios-with-proxy.test.js', + '**/src/helpers/compute-parameters.test.js', + '**/src/helpers/user-ability.test.js', + '**/src/models/**', + '**/src/serializers/**', + ], + exclude: [ + '**/src/controllers/webhooks/**', + '**/src/controllers/paddle/**', + ], thresholds: { autoUpdate: true, - statements: 93.41, - branches: 93.46, - functions: 95.95, - lines: 93.41, + statements: 99.4, + branches: 97.77, + functions: 99.16, + lines: 99.4, }, }, }, diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index bf899f12..718eff58 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -4261,16 +4261,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4302,14 +4293,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== diff --git a/packages/docs/pages/advanced/configuration.md b/packages/docs/pages/advanced/configuration.md index a6635034..aa461568 100644 --- a/packages/docs/pages/advanced/configuration.md +++ b/packages/docs/pages/advanced/configuration.md @@ -35,6 +35,7 @@ Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment | `APP_SECRET_KEY` | string | | Secret Key to authenticate the user | | `REDIS_HOST` | string | `redis` | Redis Host | | `REDIS_PORT` | number | `6379` | Redis Port | +| `REDIS_DB` | number | | Redis Database | | `REDIS_USERNAME` | string | | Redis Username | | `REDIS_PASSWORD` | string | | Redis Password | | `REDIS_TLS` | boolean | `false` | Redis TLS | diff --git a/packages/e2e-tests/fixtures/admin-setup-page.js b/packages/e2e-tests/fixtures/admin-setup-page.js index 704a9caf..6d5b85c2 100644 --- a/packages/e2e-tests/fixtures/admin-setup-page.js +++ b/packages/e2e-tests/fixtures/admin-setup-page.js @@ -1,4 +1,4 @@ -import { BasePage } from "./base-page"; +import { BasePage } from './base-page'; const { faker } = require('@faker-js/faker'); const { expect } = require('@playwright/test'); @@ -6,16 +6,18 @@ export class AdminSetupPage extends BasePage { path = '/installation'; /** - * @param {import('@playwright/test').Page} page - */ + * @param {import('@playwright/test').Page} page + */ constructor(page) { super(page); this.fullNameTextField = this.page.getByTestId('fullName-text-field'); this.emailTextField = this.page.getByTestId('email-text-field'); this.passwordTextField = this.page.getByTestId('password-text-field'); - this.repeatPasswordTextField = this.page.getByTestId('repeat-password-text-field'); - this.createAdminButton = this.page.getByTestId('signUp-button'); + this.repeatPasswordTextField = this.page.getByTestId( + 'repeat-password-text-field' + ); + this.createAdminButton = this.page.getByTestId('installation-button'); this.invalidFields = this.page.locator('p.Mui-error'); this.successAlert = this.page.getByTestId('success-alert'); } @@ -46,7 +48,7 @@ export class AdminSetupPage extends BasePage { await this.repeatPasswordTextField.fill(testUser.wronglyRepeatedPassword); } - async submitAdminForm() { + async submitAdminForm() { await this.createAdminButton.click(); } @@ -59,7 +61,10 @@ export class AdminSetupPage extends BasePage { } async expectSuccessMessageToContainLoginLink() { - await expect(await this.successAlert.locator('a')).toHaveAttribute('href', '/login'); + await expect(await this.successAlert.locator('a')).toHaveAttribute( + 'href', + '/login' + ); } generateUser() { @@ -69,7 +74,7 @@ export class AdminSetupPage extends BasePage { fullName: faker.person.fullName(), email: faker.internet.email(), password: faker.internet.password(), - wronglyRepeatedPassword: faker.internet.password() + wronglyRepeatedPassword: faker.internet.password(), }; } -}; +} diff --git a/packages/e2e-tests/fixtures/admin/application-auth-clients-page.js b/packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js similarity index 59% rename from packages/e2e-tests/fixtures/admin/application-auth-clients-page.js rename to packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js index bedddbf4..e0258eeb 100644 --- a/packages/e2e-tests/fixtures/admin/application-auth-clients-page.js +++ b/packages/e2e-tests/fixtures/admin/application-oauth-clients-page.js @@ -2,19 +2,25 @@ import { expect } from '@playwright/test'; const { AuthenticatedPage } = require('../authenticated-page'); -export class AdminApplicationAuthClientsPage extends AuthenticatedPage { +export class AdminApplicationOAuthClientsPage extends AuthenticatedPage { /** * @param {import('@playwright/test').Page} page */ constructor(page) { super(page); - this.authClientsTab = this.page.getByText('AUTH CLIENTS'); + this.authClientsTab = this.page.getByTestId('oauth-clients-tab'); this.saveButton = this.page.getByTestId('submitButton'); - this.successSnackbar = this.page.getByTestId('snackbar-save-admin-apps-settings-success'); + this.successSnackbar = this.page.getByTestId( + 'snackbar-save-admin-apps-settings-success' + ); this.createFirstAuthClientButton = this.page.getByTestId('no-results'); - this.createAuthClientButton = this.page.getByTestId('create-auth-client-button'); - this.submitAuthClientFormButton = this.page.getByTestId('submit-auth-client-form'); + this.createAuthClientButton = this.page.getByTestId( + 'create-auth-client-button' + ); + this.submitAuthClientFormButton = this.page.getByTestId( + 'submit-auth-client-form' + ); this.authClientEntry = this.page.getByTestId('auth-client'); } @@ -35,6 +41,8 @@ export class AdminApplicationAuthClientsPage extends AuthenticatedPage { } async authClientShouldBeVisible(authClientName) { - await expect(this.authClientEntry.filter({ hasText: authClientName })).toBeVisible(); + await expect( + this.authClientEntry.filter({ hasText: authClientName }) + ).toBeVisible(); } } diff --git a/packages/e2e-tests/fixtures/admin/application-settings-page.js b/packages/e2e-tests/fixtures/admin/application-settings-page.js index 57858ccb..2e756d28 100644 --- a/packages/e2e-tests/fixtures/admin/application-settings-page.js +++ b/packages/e2e-tests/fixtures/admin/application-settings-page.js @@ -8,56 +8,45 @@ export class AdminApplicationSettingsPage extends AuthenticatedPage { constructor(page) { super(page); - this.allowCustomConnectionsSwitch = this.page.locator( - '[name="customConnectionAllowed"]' + this.useOnlyPredefinedAuthClients = page.locator( + '[name="useOnlyPredefinedAuthClients"]' ); - this.allowSharedConnectionsSwitch = this.page.locator('[name="shared"]'); - this.disableConnectionsSwitch = this.page.locator('[name="disabled"]'); - this.saveButton = this.page.getByTestId('submit-button'); - this.successSnackbar = this.page.getByTestId( + this.disableConnectionsSwitch = page.locator('[name="disabled"]'); + this.saveButton = page.getByTestId('submit-button'); + this.successSnackbar = page.getByTestId( 'snackbar-save-admin-apps-settings-success' ); } - async allowCustomConnections() { - await expect(this.disableConnectionsSwitch).not.toBeChecked(); - await this.allowCustomConnectionsSwitch.check(); - await this.saveButton.click(); + async allowUseOnlyPredefinedAuthClients() { + await expect(this.useOnlyPredefinedAuthClients).not.toBeChecked(); + await this.useOnlyPredefinedAuthClients.check(); } - async allowSharedConnections() { - await expect(this.disableConnectionsSwitch).not.toBeChecked(); - await this.allowSharedConnectionsSwitch.check(); - await this.saveButton.click(); + async disallowUseOnlyPredefinedAuthClients() { + await expect(this.useOnlyPredefinedAuthClients).toBeChecked(); + await this.useOnlyPredefinedAuthClients.uncheck(); + await expect(this.useOnlyPredefinedAuthClients).not.toBeChecked(); } async disallowConnections() { await expect(this.disableConnectionsSwitch).not.toBeChecked(); await this.disableConnectionsSwitch.check(); - await this.saveButton.click(); - } - - async disallowCustomConnections() { - await expect(this.disableConnectionsSwitch).toBeChecked(); - await this.allowCustomConnectionsSwitch.uncheck(); - await this.saveButton.click(); - } - - async disallowSharedConnections() { - await expect(this.disableConnectionsSwitch).toBeChecked(); - await this.allowSharedConnectionsSwitch.uncheck(); - await this.saveButton.click(); } async allowConnections() { await expect(this.disableConnectionsSwitch).toBeChecked(); await this.disableConnectionsSwitch.uncheck(); + } + + async saveSettings() { await this.saveButton.click(); } async expectSuccessSnackbarToBeVisible() { - await expect(this.successSnackbar).toHaveCount(1); - await this.successSnackbar.click(); - await expect(this.successSnackbar).toHaveCount(0); + const snackbars = await this.successSnackbar.all(); + for (const snackbar of snackbars) { + await expect(snackbar).toBeVisible(); + } } } diff --git a/packages/e2e-tests/fixtures/admin/create-user-page.js b/packages/e2e-tests/fixtures/admin/create-user-page.js index 135b38fb..ddf0f6e6 100644 --- a/packages/e2e-tests/fixtures/admin/create-user-page.js +++ b/packages/e2e-tests/fixtures/admin/create-user-page.js @@ -1,3 +1,5 @@ +const { expect } = require('@playwright/test'); + const { faker } = require('@faker-js/faker'); const { AuthenticatedPage } = require('../authenticated-page'); @@ -11,11 +13,17 @@ export class AdminCreateUserPage extends AuthenticatedPage { super(page); this.fullNameInput = page.getByTestId('full-name-input'); this.emailInput = page.getByTestId('email-input'); - this.roleInput = page.getByTestId('role.id-autocomplete'); + this.roleInput = page.getByTestId('roleId-autocomplete'); this.createButton = page.getByTestId('create-button'); this.pageTitle = page.getByTestId('create-user-title'); - this.invitationEmailInfoAlert = page.getByTestId('invitation-email-info-alert'); - this.acceptInvitationLink = page.getByTestId('invitation-email-info-alert').getByRole('link'); + this.invitationEmailInfoAlert = page.getByTestId( + 'invitation-email-info-alert' + ); + this.acceptInvitationLink = page + .getByTestId('invitation-email-info-alert') + .getByRole('link'); + this.createUserSuccessAlert = page.getByTestId('create-user-success-alert'); + this.fieldError = page.locator('p[id$="-helper-text"]'); } seed(seed) { @@ -28,4 +36,8 @@ export class AdminCreateUserPage extends AuthenticatedPage { email: faker.internet.email().toLowerCase(), }; } + + async expectCreateUserSuccessAlertToBeVisible() { + await expect(this.createUserSuccessAlert).toBeVisible(); + } } diff --git a/packages/e2e-tests/fixtures/admin/index.js b/packages/e2e-tests/fixtures/admin/index.js index 746c85dd..db99cf35 100644 --- a/packages/e2e-tests/fixtures/admin/index.js +++ b/packages/e2e-tests/fixtures/admin/index.js @@ -8,7 +8,9 @@ const { AdminEditRolePage } = require('./edit-role-page'); const { AdminApplicationsPage } = require('./applications-page'); const { AdminApplicationSettingsPage } = require('./application-settings-page'); -const { AdminApplicationAuthClientsPage } = require('./application-auth-clients-page'); +const { + AdminApplicationOAuthClientsPage, +} = require('./application-oauth-clients-page'); export const adminFixtures = { adminUsersPage: async ({ page }, use) => { @@ -35,8 +37,7 @@ export const adminFixtures = { adminApplicationSettingsPage: async ({ page }, use) => { await use(new AdminApplicationSettingsPage(page)); }, - adminApplicationAuthClientsPage: async ({ page }, use) => { - await use(new AdminApplicationAuthClientsPage(page)); - } + adminApplicationOAuthClientsPage: async ({ page }, use) => { + await use(new AdminApplicationOAuthClientsPage(page)); + }, }; - diff --git a/packages/e2e-tests/helpers/db-helpers.js b/packages/e2e-tests/helpers/db-helpers.js new file mode 100644 index 00000000..6ba0bb6f --- /dev/null +++ b/packages/e2e-tests/helpers/db-helpers.js @@ -0,0 +1,32 @@ +const { expect } = require('../fixtures/index'); +const { pgPool } = require('../fixtures/postgres-config'); + +export const insertAppConnection = async (appName) => { + const queryUser = { + text: 'SELECT * FROM users WHERE email = $1', + values: [process.env.LOGIN_EMAIL], + }; + + try { + const queryUserResult = await pgPool.query(queryUser); + expect(queryUserResult.rowCount).toEqual(1); + + const createConnection = { + text: 'INSERT INTO connections (key, data, user_id, verified, draft) VALUES ($1, $2, $3, $4, $5)', + values: [ + appName, + 'U2FsdGVkX1+cAtdHwLiuRL4DaK/T1aljeeKyPMmtWK0AmAIsKhYwQiuyQCYJO3mdZ31z73hqF2Y+yj2Kn2/IIpLRqCxB2sC0rCDCZyolzOZ290YcBXSzYRzRUxhoOcZEtwYDKsy8AHygKK/tkj9uv9k6wOe1LjipNik4VmRhKjEYizzjLrJpbeU1oY+qW0GBpPYomFTeNf+MejSSmsUYyYJ8+E/4GeEfaonvsTSwMT7AId98Lck6Vy4wrfgpm7sZZ8xU15/HqXZNc8UCo2iTdw45xj/Oov9+brX4WUASFPG8aYrK8dl/EdaOvr89P8uIofbSNZ25GjJvVF5ymarrPkTZ7djjJXchzpwBY+7GTJfs3funR/vIk0Hq95jgOFFP1liZyqTXSa49ojG3hzojRQ==', + queryUserResult.rows[0].id, + 'true', + 'false', + ], + }; + + const createConnectionResult = await pgPool.query(createConnection); + expect(createConnectionResult.rowCount).toBe(1); + expect(createConnectionResult.command).toBe('INSERT'); + } catch (err) { + console.error(err.message); + throw err; + } +}; diff --git a/packages/e2e-tests/tests/admin/applications.spec.js b/packages/e2e-tests/tests/admin/applications.spec.js index 2fad49b9..c487ae3f 100644 --- a/packages/e2e-tests/tests/admin/applications.spec.js +++ b/packages/e2e-tests/tests/admin/applications.spec.js @@ -1,21 +1,36 @@ const { test, expect } = require('../../fixtures/index'); const { pgPool } = require('../../fixtures/postgres-config'); +const { insertAppConnection } = require('../../helpers/db-helpers'); test.describe('Admin Applications', () => { test.beforeAll(async () => { - const deleteAppAuthClients = { - text: 'DELETE FROM app_auth_clients WHERE app_key in ($1, $2, $3, $4, $5)', - values: ['carbone', 'spotify', 'deepl', 'mailchimp', 'reddit'] + const deleteOAuthClients = { + text: 'DELETE FROM oauth_clients WHERE app_key in ($1, $2, $3, $4, $5, $6)', + values: [ + 'carbone', + 'spotify', + 'clickup', + 'mailchimp', + 'reddit', + 'google-drive', + ], }; const deleteAppConfigs = { - text: 'DELETE FROM app_configs WHERE key in ($1, $2, $3, $4, $5)', - values: ['carbone', 'spotify', 'deepl', 'mailchimp', 'reddit'] + text: 'DELETE FROM app_configs WHERE key in ($1, $2, $3, $4, $5, $6)', + values: [ + 'carbone', + 'spotify', + 'clickup', + 'mailchimp', + 'reddit', + 'google-drive', + ], }; try { - const deleteAppAuthClientsResult = await pgPool.query(deleteAppAuthClients); - expect(deleteAppAuthClientsResult.command).toBe('DELETE'); + const deleteOAuthClientsResult = await pgPool.query(deleteOAuthClients); + expect(deleteOAuthClientsResult.command).toBe('DELETE'); const deleteAppConfigsResult = await pgPool.query(deleteAppConfigs); expect(deleteAppConfigsResult.command).toBe('DELETE'); } catch (err) { @@ -31,40 +46,131 @@ test.describe('Admin Applications', () => { test('Admin should be able to toggle Application settings', async ({ adminApplicationsPage, adminApplicationSettingsPage, - page + page, }) => { await adminApplicationsPage.openApplication('Carbone'); await expect(page.url()).toContain('/admin-settings/apps/carbone/settings'); - await adminApplicationSettingsPage.allowCustomConnections(); - await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); - await adminApplicationSettingsPage.allowSharedConnections(); + await adminApplicationSettingsPage.allowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); await adminApplicationSettingsPage.disallowConnections(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); await page.reload(); - await adminApplicationSettingsPage.disallowCustomConnections(); - await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); - await adminApplicationSettingsPage.disallowSharedConnections(); + await adminApplicationSettingsPage.disallowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); await adminApplicationSettingsPage.allowConnections(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); }); test('should allow only custom connections', async ({ adminApplicationsPage, adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, flowEditorPage, - page + page, }) => { + await insertAppConnection('google-drive'); + + // TODO use openApplication method after fix + // await adminApplicationsPage.openApplication('Google-Drive'); + await adminApplicationsPage.searchInput.fill('Google-Drive'); + await adminApplicationsPage.appRow + .locator(page.getByText('Google Drive')) + .click(); + + await expect(page.url()).toContain( + '/admin-settings/apps/google-drive/settings' + ); + + await expect( + adminApplicationSettingsPage.useOnlyPredefinedAuthClients + ).not.toBeChecked(); + await expect( + adminApplicationSettingsPage.disableConnectionsSwitch + ).not.toBeChecked(); + + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await expect( + adminApplicationOAuthClientsPage.createFirstAuthClientButton + ).toHaveCount(1); + + await page.goto('/'); + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + + await expect(flowEditorPage.flowStep).toHaveCount(2); + + await flowEditorPage.chooseAppAndEvent( + 'Google Drive', + 'New files in folder' + ); + await flowEditorPage.connectionAutocomplete.click(); + + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toBeEnabled(); + await expect(newConnectionOption).toHaveCount(1); + await expect(newOAuthConnectionOption).toHaveCount(0); + }); + + test('should allow only predefined connections and existing custom', async ({ + adminApplicationsPage, + adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, + flowEditorPage, + page, + }) => { + await insertAppConnection('spotify'); + await adminApplicationsPage.openApplication('Spotify'); await expect(page.url()).toContain('/admin-settings/apps/spotify/settings'); - await adminApplicationSettingsPage.allowCustomConnections(); + await expect( + adminApplicationSettingsPage.useOnlyPredefinedAuthClients + ).not.toBeChecked(); + await expect( + adminApplicationSettingsPage.disableConnectionsSwitch + ).not.toBeChecked(); + + await adminApplicationSettingsPage.allowUseOnlyPredefinedAuthClients(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('spotifyAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('spotifyClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('spotifyClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'spotifyAuthClient' + ); + await page.goto('/'); await page.getByTestId('create-flow-button').click(); await page.waitForURL( @@ -75,39 +181,61 @@ test.describe('Admin Applications', () => { const triggerStep = flowEditorPage.flowStep.last(); await triggerStep.click(); - await flowEditorPage.chooseAppAndEvent("Spotify", "Create Playlist"); + await flowEditorPage.chooseAppAndEvent('Spotify', 'Create playlist'); await flowEditorPage.connectionAutocomplete.click(); - const newConnectionOption = page.getByRole('option').filter({ hasText: 'Add new connection' }); - const newSharedConnectionOption = page.getByRole('option').filter({ hasText: 'Add new shared connection' }); + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); - await expect(newConnectionOption).toBeEnabled(); - await expect(newConnectionOption).toHaveCount(1); - await expect(newSharedConnectionOption).toHaveCount(0); + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toHaveCount(0); + await expect(newOAuthConnectionOption).toBeEnabled(); + await expect(newOAuthConnectionOption).toHaveCount(1); }); - test('should allow only shared connections', async ({ + test('should allow all connections', async ({ adminApplicationsPage, adminApplicationSettingsPage, - adminApplicationAuthClientsPage, + adminApplicationOAuthClientsPage, flowEditorPage, - page + page, }) => { + await insertAppConnection('reddit'); + await adminApplicationsPage.openApplication('Reddit'); await expect(page.url()).toContain('/admin-settings/apps/reddit/settings'); - await adminApplicationSettingsPage.allowSharedConnections(); - await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + await expect( + adminApplicationSettingsPage.useOnlyPredefinedAuthClients + ).not.toBeChecked(); + await expect( + adminApplicationSettingsPage.disableConnectionsSwitch + ).not.toBeChecked(); - await adminApplicationAuthClientsPage.openAuthClientsTab(); - await adminApplicationAuthClientsPage.openFirstAuthClientCreateForm(); - const authClientForm = page.getByTestId("auth-client-form"); + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + const authClientForm = page.getByTestId('auth-client-form'); await authClientForm.locator(page.getByTestId('switch')).check(); - await authClientForm.locator(page.locator('[name="name"]')).fill('redditAuthClient'); - await authClientForm.locator(page.locator('[name="clientId"]')).fill('redditClientId'); - await authClientForm.locator(page.locator('[name="clientSecret"]')).fill('redditClientSecret'); - await adminApplicationAuthClientsPage.submitAuthClientForm(); - await adminApplicationAuthClientsPage.authClientShouldBeVisible('redditAuthClient'); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('redditAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('redditClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('redditClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'redditAuthClient' + ); await page.goto('/'); await page.getByTestId('create-flow-button').click(); @@ -119,29 +247,60 @@ test.describe('Admin Applications', () => { const triggerStep = flowEditorPage.flowStep.last(); await triggerStep.click(); - await flowEditorPage.chooseAppAndEvent("Reddit", "Create link post"); + await flowEditorPage.chooseAppAndEvent('Reddit', 'Create link post'); await flowEditorPage.connectionAutocomplete.click(); - const newConnectionOption = page.getByRole('option').filter({ hasText: 'Add new connection' }); - const newSharedConnectionOption = page.getByRole('option').filter({ hasText: 'Add new shared connection' }); + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); - await expect(newConnectionOption).toHaveCount(0); - await expect(newSharedConnectionOption).toBeEnabled(); - await expect(newSharedConnectionOption).toHaveCount(1); + await expect(await existingConnection.count()).toBeGreaterThan(0); + await expect(newConnectionOption).toHaveCount(1); + await expect(newOAuthConnectionOption).toBeEnabled(); + await expect(newOAuthConnectionOption).toHaveCount(1); }); - test('should not allow any connections', async ({ + test('should not allow new connections but existing custom', async ({ adminApplicationsPage, adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, flowEditorPage, - page + page, }) => { - await adminApplicationsPage.openApplication('DeepL'); - await expect(page.url()).toContain('/admin-settings/apps/deepl/settings'); + await insertAppConnection('clickup'); + + await adminApplicationsPage.openApplication('ClickUp'); + await expect(page.url()).toContain('/admin-settings/apps/clickup/settings'); await adminApplicationSettingsPage.disallowConnections(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('clickupAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('clickupClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('clickupClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'clickupAuthClient' + ); + await page.goto('/'); await page.getByTestId('create-flow-button').click(); await page.waitForURL( @@ -152,58 +311,62 @@ test.describe('Admin Applications', () => { const triggerStep = flowEditorPage.flowStep.last(); await triggerStep.click(); - await flowEditorPage.chooseAppAndEvent("DeepL", "Translate text"); + await flowEditorPage.chooseAppAndEvent('ClickUp', 'Create folder'); await flowEditorPage.connectionAutocomplete.click(); - const newConnectionOption = page.getByRole('option').filter({ hasText: 'Add new connection' }); - const newSharedConnectionOption = page.getByRole('option').filter({ hasText: 'Add new shared connection' }); - const noConnectionsOption = page.locator('.MuiAutocomplete-noOptions').filter({ hasText: 'No options' }); + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); - await expect(noConnectionsOption).toHaveCount(1); + await expect(await existingConnection.count()).toBeGreaterThan(0); await expect(newConnectionOption).toHaveCount(0); - await expect(newSharedConnectionOption).toHaveCount(0); + await expect(newOAuthConnectionOption).toHaveCount(0); }); - test('should not allow new connections but only already created', async ({ + test('should not allow new connections but existing custom even if predefined OAuth clients are enabled', async ({ adminApplicationsPage, adminApplicationSettingsPage, + adminApplicationOAuthClientsPage, flowEditorPage, - page + page, }) => { - const queryUser = { - text: 'SELECT * FROM users WHERE email = $1', - values: [process.env.LOGIN_EMAIL] - }; - - try { - const queryUserResult = await pgPool.query(queryUser); - expect(queryUserResult.rowCount).toEqual(1); - - const createMailchimpConnection = { - text: 'INSERT INTO connections (key, data, user_id, verified, draft) VALUES ($1, $2, $3, $4, $5)', - values: [ - 'mailchimp', - "U2FsdGVkX1+cAtdHwLiuRL4DaK/T1aljeeKyPMmtWK0AmAIsKhYwQiuyQCYJO3mdZ31z73hqF2Y+yj2Kn2/IIpLRqCxB2sC0rCDCZyolzOZ290YcBXSzYRzRUxhoOcZEtwYDKsy8AHygKK/tkj9uv9k6wOe1LjipNik4VmRhKjEYizzjLrJpbeU1oY+qW0GBpPYomFTeNf+MejSSmsUYyYJ8+E/4GeEfaonvsTSwMT7AId98Lck6Vy4wrfgpm7sZZ8xU15/HqXZNc8UCo2iTdw45xj/Oov9+brX4WUASFPG8aYrK8dl/EdaOvr89P8uIofbSNZ25GjJvVF5ymarrPkTZ7djjJXchzpwBY+7GTJfs3funR/vIk0Hq95jgOFFP1liZyqTXSa49ojG3hzojRQ==", - queryUserResult.rows[0].id, - 'true', - 'false' - ], - }; - - const createMailchimpConnectionResult = await pgPool.query(createMailchimpConnection); - expect(createMailchimpConnectionResult.rowCount).toBe(1); - expect(createMailchimpConnectionResult.command).toBe('INSERT'); - } catch (err) { - console.error(err.message); - throw err; - } + await insertAppConnection('mailchimp'); await adminApplicationsPage.openApplication('Mailchimp'); - await expect(page.url()).toContain('/admin-settings/apps/mailchimp/settings'); + await expect(page.url()).toContain( + '/admin-settings/apps/mailchimp/settings' + ); + await adminApplicationSettingsPage.allowUseOnlyPredefinedAuthClients(); await adminApplicationSettingsPage.disallowConnections(); + await adminApplicationSettingsPage.saveSettings(); await adminApplicationSettingsPage.expectSuccessSnackbarToBeVisible(); + await adminApplicationOAuthClientsPage.openAuthClientsTab(); + await adminApplicationOAuthClientsPage.openFirstAuthClientCreateForm(); + + const authClientForm = page.getByTestId('auth-client-form'); + await authClientForm.locator(page.getByTestId('switch')).check(); + await authClientForm + .locator(page.locator('[name="name"]')) + .fill('mailchimpAuthClient'); + await authClientForm + .locator(page.locator('[name="clientId"]')) + .fill('mailchimpClientId'); + await authClientForm + .locator(page.locator('[name="clientSecret"]')) + .fill('mailchimpClientSecret'); + await adminApplicationOAuthClientsPage.submitAuthClientForm(); + await adminApplicationOAuthClientsPage.authClientShouldBeVisible( + 'mailchimpAuthClient' + ); + await page.goto('/'); await page.getByTestId('create-flow-button').click(); await page.waitForURL( @@ -214,18 +377,26 @@ test.describe('Admin Applications', () => { const triggerStep = flowEditorPage.flowStep.last(); await triggerStep.click(); - await flowEditorPage.chooseAppAndEvent("Mailchimp", "Create campaign"); + await flowEditorPage.chooseAppAndEvent('Mailchimp', 'Create campaign'); await flowEditorPage.connectionAutocomplete.click(); await expect(page.getByRole('option').first()).toHaveText('Unnamed'); - const existingConnection = page.getByRole('option').filter({ hasText: 'Unnamed' }); - const newConnectionOption = page.getByRole('option').filter({ hasText: 'Add new connection' }); - const newSharedConnectionOption = page.getByRole('option').filter({ hasText: 'Add new shared connection' }); - const noConnectionsOption = page.locator('.MuiAutocomplete-noOptions').filter({ hasText: 'No options' }); + const existingConnection = page + .getByRole('option') + .filter({ hasText: 'Unnamed' }); + const newConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add new connection' }); + const newOAuthConnectionOption = page + .getByRole('option') + .filter({ hasText: 'Add connection with OAuth client' }); + const noConnectionsOption = page + .locator('.MuiAutocomplete-noOptions') + .filter({ hasText: 'No options' }); await expect(await existingConnection.count()).toBeGreaterThan(0); await expect(noConnectionsOption).toHaveCount(0); await expect(newConnectionOption).toHaveCount(0); - await expect(newSharedConnectionOption).toHaveCount(0); + await expect(newOAuthConnectionOption).toHaveCount(0); }); }); diff --git a/packages/e2e-tests/tests/admin/manage-roles.spec.js b/packages/e2e-tests/tests/admin/manage-roles.spec.js index 3a8f60fa..9198db3e 100644 --- a/packages/e2e-tests/tests/admin/manage-roles.spec.js +++ b/packages/e2e-tests/tests/admin/manage-roles.spec.js @@ -35,9 +35,8 @@ test.describe('Role management page', () => { await adminCreateRolePage.closeSnackbar(); }); - let roleRow = await test.step( - 'Make sure role data is correct', - async () => { + let roleRow = + await test.step('Make sure role data is correct', async () => { const roleRow = await adminRolesPage.getRoleRowByName( 'Create Edit Test' ); @@ -48,8 +47,7 @@ test.describe('Role management page', () => { await expect(roleData.canEdit).toBe(true); await expect(roleData.canDelete).toBe(true); return roleRow; - } - ); + }); await test.step('Edit the role', async () => { await adminRolesPage.clickEditRole(roleRow); @@ -67,9 +65,8 @@ test.describe('Role management page', () => { await adminEditRolePage.closeSnackbar(); }); - roleRow = await test.step( - 'Make sure changes reflected on roles page', - async () => { + roleRow = + await test.step('Make sure changes reflected on roles page', async () => { await adminRolesPage.isMounted(); const roleRow = await adminRolesPage.getRoleRowByName( 'Create Update Test' @@ -81,8 +78,7 @@ test.describe('Role management page', () => { await expect(roleData.canEdit).toBe(true); await expect(roleData.canDelete).toBe(true); return roleRow; - } - ); + }); await test.step('Delete the role', async () => { await adminRolesPage.clickDeleteRole(roleRow); @@ -184,44 +180,34 @@ test.describe('Role management page', () => { await expect(snackbar.variant).toBe('success'); await adminCreateRolePage.closeSnackbar(); }); - await test.step( - 'Create a new user with the "Delete Role" role', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill('User Role Test'); - await adminCreateUserPage.emailInput.fill( - 'user-role-test@automatisch.io' - ); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page - .getByRole('option', { name: 'Delete Role', exact: true }) - .click(); - await adminCreateUserPage.createButton.click(); - await adminCreateUserPage.snackbar.waitFor({ - state: 'attached', - }); - await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ - state: 'attached', - }); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - } - ); - await test.step( - 'Try to delete "Delete Role" role when new user has it', - async () => { - await adminRolesPage.navigateTo(); - const row = await adminRolesPage.getRoleRowByName('Delete Role'); - const modal = await adminRolesPage.clickDeleteRole(row); - await modal.deleteButton.click(); - await expect(modal.deleteAlert).toHaveCount(1); - await modal.close(); - } - ); + + await test.step('Create a new user with the "Delete Role" role', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill('User Role Test'); + await adminCreateUserPage.emailInput.fill( + 'user-role-test@automatisch.io' + ); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Delete Role', exact: true }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', + }); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); + + await test.step('Try to delete "Delete Role" role when new user has it', async () => { + await adminRolesPage.navigateTo(); + const row = await adminRolesPage.getRoleRowByName('Delete Role'); + const modal = await adminRolesPage.clickDeleteRole(row); + await modal.deleteButton.click(); + await expect(modal.deleteAlert).toHaveCount(1); + await modal.close(); + }); + await test.step('Change the role the user has', async () => { await adminUsersPage.navigateTo(); await adminUsersPage.usersLoader.waitFor({ @@ -296,17 +282,10 @@ test.describe('Role management page', () => { .getByRole('option', { name: 'Cannot Delete Role' }) .click(); await adminCreateUserPage.createButton.click(); - await adminCreateUserPage.snackbar.waitFor({ - state: 'attached', - }); await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); - const snackbar = await adminCreateUserPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminCreateUserPage.closeSnackbar(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Delete this user', async () => { await adminUsersPage.navigateTo(); @@ -371,17 +350,10 @@ test('Accessibility of role management page', async ({ .getByRole('option', { name: 'Basic Test' }) .click(); await adminCreateUserPage.createButton.click(); - await adminCreateUserPage.snackbar.waitFor({ - state: 'attached', - }); await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ state: 'attached', }); - const snackbar = await adminCreateUserPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminCreateUserPage.closeSnackbar(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await test.step('Logout and login to the basic role user', async () => { @@ -395,42 +367,35 @@ test('Accessibility of role management page', async ({ await page.getByTestId('logout-item').click(); const acceptInvitationPage = new AcceptInvitation(page); - await acceptInvitationPage.open(acceptInvitatonToken); - await acceptInvitationPage.acceptInvitation('sample'); const loginPage = new LoginPage(page); - - // await loginPage.isMounted(); await loginPage.login('basic-role-test@automatisch.io', 'sample'); await expect(loginPage.loginButton).not.toBeVisible(); await expect(page).toHaveURL('/flows'); }); - await test.step( - 'Navigate to the admin settings page and make sure it is blank', - async () => { - const pageUrl = new URL(page.url()); - const url = `${pageUrl.origin}/admin-settings/users`; - await page.goto(url); - await page.waitForTimeout(750); - const isUnmounted = await page.evaluate(() => { - // eslint-disable-next-line no-undef - const root = document.querySelector('#root'); + await test.step('Navigate to the admin settings page and make sure it is blank', async () => { + const pageUrl = new URL(page.url()); + const url = `${pageUrl.origin}/admin-settings/users`; + await page.goto(url); + await page.waitForTimeout(750); + const isUnmounted = await page.evaluate(() => { + // eslint-disable-next-line no-undef + const root = document.querySelector('#root'); - if (root) { - // We have react query devtools only in dev env. - // In production, there is nothing in root. - // That's why `<= 1`. - return root.children.length <= 1; - } + if (root) { + // We have react query devtools only in dev env. + // In production, there is nothing in root. + // That's why `<= 1`. + return root.children.length <= 1; + } - return false; - }); - await expect(isUnmounted).toBe(true); - } - ); + return false; + }); + await expect(isUnmounted).toBe(true); + }); await test.step('Log back into the admin account', async () => { await page.goto('/'); diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js index d6fc1507..af7f7083 100644 --- a/packages/e2e-tests/tests/admin/manage-users.spec.js +++ b/packages/e2e-tests/tests/admin/manage-users.spec.js @@ -5,281 +5,221 @@ const { test, expect } = require('../../fixtures/index'); * otherwise tests will fail since users are only *soft*-deleted */ test.describe('User management page', () => { - test.beforeEach(async ({ adminUsersPage }) => { await adminUsersPage.navigateTo(); await adminUsersPage.closeSnackbar(); }); - test( - 'User creation and deletion process', - async ({ adminCreateUserPage, adminEditUserPage, adminUsersPage }) => { - adminCreateUserPage.seed(9000); - const user = adminCreateUserPage.generateUser(); - await adminUsersPage.usersLoader.waitFor({ - state: 'detached' /* Note: state: 'visible' introduces flakiness + test('User creation and deletion process', async ({ + adminCreateUserPage, + adminEditUserPage, + adminUsersPage, + }) => { + adminCreateUserPage.seed(9000); + const user = adminCreateUserPage.generateUser(); + await adminUsersPage.usersLoader.waitFor({ + state: 'detached' /* Note: state: 'visible' introduces flakiness because visibility: hidden is used as part of the state transition in notistack, see https://github.com/iamhosseindhv/notistack/blob/122f47057eb7ce5a1abfe923316cf8475303e99a/src/transitions/Collapse/Collapse.tsx#L110 - */ + */, + }); + await test.step('Create a user', async () => { + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(user.fullName); + await adminCreateUserPage.emailInput.fill(user.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ + state: 'attached', }); - await test.step( - 'Create a user', - async () => { - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(user.fullName); - await adminCreateUserPage.emailInput.fill(user.email); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); - await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ - state: 'attached' - }); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.navigateTo(); - await adminUsersPage.closeSnackbar(); - } + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + await adminUsersPage.navigateTo(); + }); + await test.step('Check the user exists with the expected properties', async () => { + await adminUsersPage.findUserPageWithEmail(user.email); + const userRow = await adminUsersPage.getUserRowByEmail(user.email); + const data = await adminUsersPage.getRowData(userRow); + await expect(data.email).toBe(user.email); + await expect(data.fullName).toBe(user.fullName); + await expect(data.role).toBe('Admin'); + }); + await test.step('Edit user info and make sure the edit works correctly', async () => { + await adminUsersPage.findUserPageWithEmail(user.email); + + let userRow = await adminUsersPage.getUserRowByEmail(user.email); + await adminUsersPage.clickEditUser(userRow); + await adminEditUserPage.waitForLoad(user.fullName); + const newUserInfo = adminEditUserPage.generateUser(); + await adminEditUserPage.fullNameInput.fill(newUserInfo.fullName); + await adminEditUserPage.updateButton.click(); + + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-edit-user-success' ); - await test.step( - 'Check the user exists with the expected properties', - async () => { - await adminUsersPage.findUserPageWithEmail(user.email); - const userRow = await adminUsersPage.getUserRowByEmail(user.email); - const data = await adminUsersPage.getRowData(userRow); - await expect(data.email).toBe(user.email); - await expect(data.fullName).toBe(user.fullName); - await expect(data.role).toBe('Admin'); - } + await expect(snackbar.variant).toBe('success'); + await adminUsersPage.closeSnackbar(); + + await adminUsersPage.findUserPageWithEmail(user.email); + userRow = await adminUsersPage.getUserRowByEmail(user.email); + const rowData = await adminUsersPage.getRowData(userRow); + await expect(rowData.fullName).toBe(newUserInfo.fullName); + }); + await test.step('Delete user and check the page confirms this deletion', async () => { + await adminUsersPage.findUserPageWithEmail(user.email); + const userRow = await adminUsersPage.getUserRowByEmail(user.email); + await adminUsersPage.clickDeleteUser(userRow); + const modal = adminUsersPage.deleteUserModal; + await modal.deleteButton.click(); + + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-delete-user-success' ); - await test.step( - 'Edit user info and make sure the edit works correctly', - async () => { - await adminUsersPage.findUserPageWithEmail(user.email); + await expect(snackbar.variant).toBe('success'); + await adminUsersPage.closeSnackbar(); + await expect(userRow).not.toBeVisible(false); + }); + }); - let userRow = await adminUsersPage.getUserRowByEmail(user.email); - await adminUsersPage.clickEditUser(userRow); - await adminEditUserPage.waitForLoad(user.fullName); - const newUserInfo = adminEditUserPage.generateUser(); - await adminEditUserPage.fullNameInput.fill(newUserInfo.fullName); - await adminEditUserPage.updateButton.click(); + test('Creating a user which has been deleted', async ({ + adminCreateUserPage, + adminUsersPage, + }) => { + adminCreateUserPage.seed(9100); + const testUser = adminCreateUserPage.generateUser(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-edit-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - - await adminUsersPage.findUserPageWithEmail(user.email); - userRow = await adminUsersPage.getUserRowByEmail(user.email); - const rowData = await adminUsersPage.getRowData(userRow); - await expect(rowData.fullName).toBe(newUserInfo.fullName); - } - ); - await test.step( - 'Delete user and check the page confirms this deletion', - async () => { - await adminUsersPage.findUserPageWithEmail(user.email); - const userRow = await adminUsersPage.getUserRowByEmail(user.email); - await adminUsersPage.clickDeleteUser(userRow); - const modal = adminUsersPage.deleteUserModal; - await modal.deleteButton.click(); - - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-delete-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - await expect(userRow).not.toBeVisible(false); - } - ); + await test.step('Create the test user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); - test( - 'Creating a user which has been deleted', - async ({ adminCreateUserPage, adminUsersPage }) => { - adminCreateUserPage.seed(9100); - const testUser = adminCreateUserPage.generateUser(); - - await test.step( - 'Create the test user', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(testUser.fullName); - await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - } + await test.step('Delete the created user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.findUserPageWithEmail(testUser.email); + const userRow = await adminUsersPage.getUserRowByEmail(testUser.email); + await adminUsersPage.clickDeleteUser(userRow); + const modal = adminUsersPage.deleteUserModal; + await modal.deleteButton.click(); + const snackbar = await adminUsersPage.getSnackbarData( + 'snackbar-delete-user-success' ); + await expect(snackbar).not.toBeNull(); + await expect(snackbar.variant).toBe('success'); + await adminUsersPage.closeSnackbar(); + await expect(userRow).not.toBeVisible(false); + }); - await test.step( - 'Delete the created user', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.findUserPageWithEmail(testUser.email); - const userRow = await adminUsersPage.getUserRowByEmail(testUser.email); - await adminUsersPage.clickDeleteUser(userRow); - const modal = adminUsersPage.deleteUserModal; - await modal.deleteButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-delete-user-success' - ); - await expect(snackbar).not.toBeNull(); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - await expect(userRow).not.toBeVisible(false); - } - ); + await test.step('Create the user again', async () => { + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await expect(adminCreateUserPage.fieldError).toHaveCount(1); + }); + }); - await test.step( - 'Create the user again', - async () => { - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(testUser.fullName); - await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); - await expect(snackbar.variant).toBe('error'); - await adminUsersPage.closeSnackbar(); - } - ); - } - ); + test('Creating a user which already exists', async ({ + adminCreateUserPage, + adminUsersPage, + page, + }) => { + adminCreateUserPage.seed(9200); + const testUser = adminCreateUserPage.generateUser(); - test( - 'Creating a user which already exists', - async ({ adminCreateUserPage, adminUsersPage, page }) => { - adminCreateUserPage.seed(9200); - const testUser = adminCreateUserPage.generateUser(); + await test.step('Create the test user', async () => { + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); - await test.step( - 'Create the test user', - async () => { - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(testUser.fullName); - await adminCreateUserPage.emailInput.fill(testUser.email); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - } - ); + await test.step('Create the user again', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(testUser.fullName); + await adminCreateUserPage.emailInput.fill(testUser.email); + const createUserPageUrl = page.url(); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); - await test.step( - 'Create the user again', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(testUser.fullName); - await adminCreateUserPage.emailInput.fill(testUser.email); - const createUserPageUrl = page.url(); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); + await expect(page.url()).toBe(createUserPageUrl); + await expect(adminCreateUserPage.fieldError).toHaveCount(1); + }); + }); - await expect(page.url()).toBe(createUserPageUrl); - const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); - await expect(snackbar.variant).toBe('error'); - await adminUsersPage.closeSnackbar(); - } - ); - } - ); + test('Editing a user to have the same email as another user should not be allowed', async ({ + adminCreateUserPage, + adminEditUserPage, + adminUsersPage, + page, + }) => { + adminCreateUserPage.seed(9300); + const user1 = adminCreateUserPage.generateUser(); + const user2 = adminCreateUserPage.generateUser(); + await test.step('Create the first user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(user1.fullName); + await adminCreateUserPage.emailInput.fill(user1.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); - test( - 'Editing a user to have the same email as another user should not be allowed', - async ({ - adminCreateUserPage, adminEditUserPage, adminUsersPage, page - }) => { - adminCreateUserPage.seed(9300); - const user1 = adminCreateUserPage.generateUser(); - const user2 = adminCreateUserPage.generateUser(); - await test.step( - 'Create the first user', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(user1.fullName); - await adminCreateUserPage.emailInput.fill(user1.email); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - } - ); + await test.step('Create the second user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.createUserButton.click(); + await adminCreateUserPage.fullNameInput.fill(user2.fullName); + await adminCreateUserPage.emailInput.fill(user2.email); + await adminCreateUserPage.roleInput.click(); + await adminCreateUserPage.page + .getByRole('option', { name: 'Admin' }) + .click(); + await adminCreateUserPage.createButton.click(); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); + }); - await test.step( - 'Create the second user', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.createUserButton.click(); - await adminCreateUserPage.fullNameInput.fill(user2.fullName); - await adminCreateUserPage.emailInput.fill(user2.email); - await adminCreateUserPage.roleInput.click(); - await adminCreateUserPage.page.getByRole( - 'option', { name: 'Admin' } - ).click(); - await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); - await adminUsersPage.closeSnackbar(); - } - ); + await test.step('Try editing the second user to have the email of the first user', async () => { + await adminUsersPage.navigateTo(); + await adminUsersPage.findUserPageWithEmail(user2.email); + let userRow = await adminUsersPage.getUserRowByEmail(user2.email); + await adminUsersPage.clickEditUser(userRow); + await adminEditUserPage.waitForLoad(user2.fullName); + await adminEditUserPage.emailInput.fill(user1.email); + const editPageUrl = page.url(); + await adminEditUserPage.updateButton.click(); - await test.step( - 'Try editing the second user to have the email of the first user', - async () => { - await adminUsersPage.navigateTo(); - await adminUsersPage.findUserPageWithEmail(user2.email); - let userRow = await adminUsersPage.getUserRowByEmail(user2.email); - await adminUsersPage.clickEditUser(userRow); - await adminEditUserPage.waitForLoad(user2.fullName); - await adminEditUserPage.emailInput.fill(user1.email); - const editPageUrl = page.url(); - await adminEditUserPage.updateButton.click(); - - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-error' - ); - await expect(snackbar.variant).toBe('error'); - await adminUsersPage.closeSnackbar(); - await expect(page.url()).toBe(editPageUrl); - } - ); - } - ); + const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); + await expect(snackbar.variant).toBe('error'); + await adminUsersPage.closeSnackbar(); + await expect(page.url()).toBe(editPageUrl); + }); + }); }); diff --git a/packages/e2e-tests/tests/apps/list-apps.spec.js b/packages/e2e-tests/tests/apps/list-apps.spec.js index 42e56880..b382782a 100644 --- a/packages/e2e-tests/tests/apps/list-apps.spec.js +++ b/packages/e2e-tests/tests/apps/list-apps.spec.js @@ -55,7 +55,7 @@ test.describe('Apps page', () => { test('goes to app page to create a connection', async ({ applicationsPage, }) => { - // loading app, app config, app auth clients take time + // loading app, app config, app oauth clients take time test.setTimeout(60000); await applicationsPage.page.getByTestId('app-list-item').first().click(); diff --git a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js index 98114c27..6f46454a 100644 --- a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js +++ b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js @@ -7,198 +7,191 @@ test('Ensure creating a new flow works', async ({ page }) => { ); }); -test( - 'Create a new flow with a Scheduler step then an Ntfy step', - async ({ flowEditorPage, page }) => { - await test.step('create flow', async () => { - await test.step('navigate to new flow page', async () => { - await page.getByTestId('create-flow-button').click(); - await page.waitForURL( - /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ - ); +test('Create a new flow with a Scheduler step then an Ntfy step', async ({ + flowEditorPage, + page, +}) => { + await test.step('create flow', async () => { + await test.step('navigate to new flow page', async () => { + await page.getByTestId('create-flow-button').click(); + await page.waitForURL( + /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ + ); + }); + + await test.step('has two steps by default', async () => { + await expect(page.getByTestId('flow-step')).toHaveCount(2); + }); + }); + + await test.step('setup Scheduler trigger', async () => { + await test.step('choose app and event substep', async () => { + await test.step('choose application', async () => { + await flowEditorPage.appAutocomplete.click(); + await page.getByRole('option', { name: 'Scheduler' }).click(); }); - - await test.step('has two steps by default', async () => { - await expect(page.getByTestId('flow-step')).toHaveCount(2); + + await test.step('choose and event', async () => { + await expect(flowEditorPage.eventAutocomplete).toBeVisible(); + await flowEditorPage.eventAutocomplete.click(); + await page.getByRole('option', { name: 'Every hour' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); + await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); }); }); - await test.step('setup Scheduler trigger', async () => { - await test.step('choose app and event substep', async () => { - await test.step('choose application', async () => { - await flowEditorPage.appAutocomplete.click(); - await page - .getByRole('option', { name: 'Scheduler' }) - .click(); - }); - - await test.step('choose and event', async () => { - await expect(flowEditorPage.eventAutocomplete).toBeVisible(); - await flowEditorPage.eventAutocomplete.click(); - await page - .getByRole('option', { name: 'Every hour' }) - .click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); - await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); - }); + await test.step('set up a trigger', async () => { + await test.step('choose "yes" in "trigger on weekends?"', async () => { + await expect(flowEditorPage.trigger).toBeVisible(); + await flowEditorPage.trigger.click(); + await page.getByRole('option', { name: 'Yes' }).click(); }); - await test.step('set up a trigger', async () => { - await test.step('choose "yes" in "trigger on weekends?"', async () => { - await expect(flowEditorPage.trigger).toBeVisible(); - await flowEditorPage.trigger.click(); - await page.getByRole('option', { name: 'Yes' }).click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.trigger).not.toBeVisible(); - }); + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); }); - await test.step('test trigger', async () => { - await test.step('show sample output', async () => { - await expect(flowEditorPage.testOutput).not.toBeVisible(); - await flowEditorPage.continueButton.click(); - await expect(flowEditorPage.testOutput).toBeVisible(); - await flowEditorPage.screenshot({ - path: 'Scheduler trigger test output.png', - }); - await flowEditorPage.continueButton.click(); - }); + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.trigger).not.toBeVisible(); }); }); - await test.step('arrange Ntfy action', async () => { - await test.step('choose app and event substep', async () => { - await test.step('choose application', async () => { - await flowEditorPage.appAutocomplete.click(); - await page.getByRole('option', { name: 'Ntfy' }).click(); - }); - - await test.step('choose an event', async () => { - await expect(flowEditorPage.eventAutocomplete).toBeVisible(); - await flowEditorPage.eventAutocomplete.click(); - await page - .getByRole('option', { name: 'Send message' }) - .click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); - await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); - }); - }); - - await test.step('choose connection substep', async () => { - await test.step('choose connection list item', async () => { - await flowEditorPage.connectionAutocomplete.click(); - await page.getByRole('option').first().click(); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); - }); - }); - - await test.step('set up action substep', async () => { - await test.step('fill topic and message body', async () => { - await page - .getByTestId('parameters.topic-power-input') - .locator('[contenteditable]') - .fill('Topic'); - await page - .getByTestId('parameters.message-power-input') - .locator('[contenteditable]') - .fill('Message body'); - }); - - await test.step('continue to next step', async () => { - await flowEditorPage.continueButton.click(); - }); - - await test.step('collapses the substep', async () => { - await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); - }); - }); - - await test.step('test trigger substep', async () => { - await test.step('show sample output', async () => { - await expect(flowEditorPage.testOutput).not.toBeVisible(); - await page - .getByTestId('flow-substep-continue-button') - .first() - .click(); - await expect(flowEditorPage.testOutput).toBeVisible(); - await flowEditorPage.screenshot({ - path: 'Ntfy action test output.png', - }); - await flowEditorPage.continueButton.click(); - }); - }); - }); - - await test.step('publish and unpublish', async () => { - await test.step('publish flow', async () => { - await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); - await expect(flowEditorPage.publishFlowButton).toBeVisible(); - await flowEditorPage.publishFlowButton.click(); - await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); - }); - - await test.step('shows read-only sticky snackbar', async () => { - await expect(flowEditorPage.infoSnackbar).toBeVisible(); + await test.step('test trigger', async () => { + await test.step('show sample output', async () => { + await expect(flowEditorPage.testOutput).not.toBeVisible(); + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.testOutput).toBeVisible(); await flowEditorPage.screenshot({ - path: 'Published flow.png', + path: 'Scheduler trigger test output.png', }); + await flowEditorPage.continueButton.click(); }); - - await test.step('unpublish from snackbar', async () => { + }); + }); + + await test.step('arrange Ntfy action', async () => { + await test.step('choose app and event substep', async () => { + await test.step('choose application', async () => { + await flowEditorPage.appAutocomplete.click(); + await page.getByRole('option', { name: 'Ntfy' }).click(); + }); + + await test.step('choose an event', async () => { + await expect(flowEditorPage.eventAutocomplete).toBeVisible(); + await flowEditorPage.eventAutocomplete.click(); + await page.getByRole('option', { name: 'Send message' }).click(); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.appAutocomplete).not.toBeVisible(); + await expect(flowEditorPage.eventAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('choose connection substep', async () => { + await test.step('choose connection list item', async () => { + await flowEditorPage.connectionAutocomplete.click(); await page - .getByTestId('unpublish-flow-from-snackbar') + .getByRole('option') + .filter({ hasText: 'Add new connection' }) .click(); - await expect(flowEditorPage.infoSnackbar).not.toBeVisible(); }); - - await test.step('publish once again', async () => { - await expect(flowEditorPage.publishFlowButton).toBeVisible(); - await flowEditorPage.publishFlowButton.click(); - await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + + await test.step('continue to next step', async () => { + await page.getByTestId('create-connection-button').click(); }); - - await test.step('unpublish from layout top bar', async () => { - await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); - await flowEditorPage.unpublishFlowButton.click(); - await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + + await test.step('collapses the substep', async () => { + await flowEditorPage.continueButton.click(); + await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('set up action substep', async () => { + await test.step('fill topic and message body', async () => { + await page + .getByTestId('parameters.topic-power-input') + .locator('[contenteditable]') + .fill('Topic'); + await page + .getByTestId('parameters.message-power-input') + .locator('[contenteditable]') + .fill('Message body'); + }); + + await test.step('continue to next step', async () => { + await flowEditorPage.continueButton.click(); + }); + + await test.step('collapses the substep', async () => { + await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible(); + }); + }); + + await test.step('test trigger substep', async () => { + await test.step('show sample output', async () => { + await expect(flowEditorPage.testOutput).not.toBeVisible(); + await page.getByTestId('flow-substep-continue-button').first().click(); + await expect(flowEditorPage.testOutput).toBeVisible(); await flowEditorPage.screenshot({ - path: 'Unpublished flow.png', + path: 'Ntfy action test output.png', }); + await flowEditorPage.continueButton.click(); }); }); - - await test.step('in layout', async () => { - await test.step('can go back to flows page', async () => { - await page.getByTestId('editor-go-back-button').click(); - await expect(page).toHaveURL('/flows'); + }); + + await test.step('publish and unpublish', async () => { + await test.step('publish flow', async () => { + await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + await expect(flowEditorPage.publishFlowButton).toBeVisible(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + }); + + await test.step('shows read-only sticky snackbar', async () => { + await expect(flowEditorPage.infoSnackbar).toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Published flow.png', }); }); - } -); \ No newline at end of file + + await test.step('unpublish from snackbar', async () => { + await page.getByTestId('unpublish-flow-from-snackbar').click(); + await expect(flowEditorPage.infoSnackbar).not.toBeVisible(); + }); + + await test.step('publish once again', async () => { + await expect(flowEditorPage.publishFlowButton).toBeVisible(); + await flowEditorPage.publishFlowButton.click(); + await expect(flowEditorPage.publishFlowButton).not.toBeVisible(); + }); + + await test.step('unpublish from layout top bar', async () => { + await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); + await flowEditorPage.unpublishFlowButton.click(); + await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); + await flowEditorPage.screenshot({ + path: 'Unpublished flow.png', + }); + }); + }); + + await test.step('in layout', async () => { + await test.step('can go back to flows page', async () => { + await page.getByTestId('editor-go-back-button').click(); + await expect(page).toHaveURL('/flows'); + }); + }); +}); diff --git a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js index d77962e4..fc0ce7d0 100644 --- a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js +++ b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js @@ -33,10 +33,7 @@ publicTest.describe('My Profile', () => { .getByRole('option', { name: 'Admin' }) .click(); await adminCreateUserPage.createButton.click(); - const snackbar = await adminUsersPage.getSnackbarData( - 'snackbar-create-user-success' - ); - await expect(snackbar.variant).toBe('success'); + await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible(); }); await publicTest.step('copy invitation link', async () => { diff --git a/packages/web/package.json b/packages/web/package.json index 501d1ccc..cd34f29c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -37,6 +37,7 @@ "slate": "^0.94.1", "slate-history": "^0.93.0", "slate-react": "^0.94.2", + "slugify": "^1.6.6", "uuid": "^9.0.0", "web-vitals": "^1.0.1", "yup": "^0.32.11" diff --git a/packages/web/src/components/AddAppConnection/index.jsx b/packages/web/src/components/AddAppConnection/index.jsx index dc14ad09..9fef4c77 100644 --- a/packages/web/src/components/AddAppConnection/index.jsx +++ b/packages/web/src/components/AddAppConnection/index.jsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { AppPropType } from 'propTypes/propTypes'; -import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee'; +import AppOAuthClientsDialog from 'components/OAuthClientsDialog/index.ee'; import InputCreator from 'components/InputCreator'; import * as URLS from 'config/urls'; import useAuthenticateApp from 'hooks/useAuthenticateApp.ee'; @@ -31,12 +31,12 @@ function AddAppConnection(props) { const [inProgress, setInProgress] = React.useState(false); const hasConnection = Boolean(connectionId); const useShared = searchParams.get('shared') === 'true'; - const appAuthClientId = searchParams.get('appAuthClientId') || undefined; + const oauthClientId = searchParams.get('oauthClientId') || undefined; const { authenticate } = useAuthenticateApp({ appKey: key, connectionId, - appAuthClientId, - useShared: !!appAuthClientId, + oauthClientId, + useShared: !!oauthClientId, }); const queryClient = useQueryClient(); @@ -52,8 +52,8 @@ function AddAppConnection(props) { }, []); React.useEffect( - function initiateSharedAuthenticationForGivenAuthClient() { - if (!appAuthClientId) return; + function initiateSharedAuthenticationForGivenOAuthClient() { + if (!oauthClientId) return; if (!authenticate) return; @@ -64,13 +64,13 @@ function AddAppConnection(props) { asyncAuthenticate(); }, - [appAuthClientId, authenticate], + [oauthClientId, authenticate, key, navigate], ); - const handleClientClick = (appAuthClientId) => - navigate(URLS.APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID(key, appAuthClientId)); + const handleClientClick = (oauthClientId) => + navigate(URLS.APP_ADD_CONNECTION_WITH_OAUTH_CLIENT_ID(key, oauthClientId)); - const handleAuthClientsDialogClose = () => + const handleOAuthClientsDialogClose = () => navigate(URLS.APP_CONNECTIONS(key)); const submitHandler = React.useCallback( @@ -104,14 +104,14 @@ function AddAppConnection(props) { if (useShared) return ( - ); - if (appAuthClientId) return ; + if (oauthClientId) return ; return ( { let appConfigKey = appConfig?.data?.key; if (!appConfigKey) { const { data: appConfigData } = await createAppConfig({ - customConnectionAllowed: true, - shared: false, + useOnlyPredefinedAuthClients: false, disabled: false, }); + appConfigKey = appConfigData.key; } const { name, active, ...formattedAuthDefaults } = values; - await createAppAuthClient({ + await createOAuthClient({ appKey, name, active, @@ -81,23 +81,23 @@ function AdminApplicationCreateAuthClient(props) { ); return ( - ); } -AdminApplicationCreateAuthClient.propTypes = { +AdminApplicationCreateOAuthClient.propTypes = { appKey: PropTypes.string.isRequired, application: AppPropType.isRequired, onClose: PropTypes.func.isRequired, }; -export default AdminApplicationCreateAuthClient; +export default AdminApplicationCreateOAuthClient; diff --git a/packages/web/src/components/AdminApplicationAuthClientDialog/index.jsx b/packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx similarity index 89% rename from packages/web/src/components/AdminApplicationAuthClientDialog/index.jsx rename to packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx index 6c328c11..9bb38959 100644 --- a/packages/web/src/components/AdminApplicationAuthClientDialog/index.jsx +++ b/packages/web/src/components/AdminApplicationOAuthClientDialog/index.jsx @@ -15,7 +15,7 @@ import Switch from 'components/Switch'; import TextField from 'components/TextField'; import { Form } from './style'; -function AdminApplicationAuthClientDialog(props) { +function AdminApplicationOAuthClientDialog(props) { const { error, onClose, @@ -52,12 +52,12 @@ function AdminApplicationAuthClientDialog(props) { <> {authFields?.map((field) => ( @@ -72,7 +72,7 @@ function AdminApplicationAuthClientDialog(props) { loading={submitting} disabled={disabled || !isDirty} > - {formatMessage('authClient.buttonSubmit')} + {formatMessage('oauthClient.buttonSubmit')} )} @@ -84,7 +84,7 @@ function AdminApplicationAuthClientDialog(props) { ); } -AdminApplicationAuthClientDialog.propTypes = { +AdminApplicationOAuthClientDialog.propTypes = { error: PropTypes.shape({ message: PropTypes.string, }), @@ -98,4 +98,4 @@ AdminApplicationAuthClientDialog.propTypes = { disabled: PropTypes.bool, }; -export default AdminApplicationAuthClientDialog; +export default AdminApplicationOAuthClientDialog; diff --git a/packages/web/src/components/AdminApplicationAuthClientDialog/style.js b/packages/web/src/components/AdminApplicationOAuthClientDialog/style.js similarity index 100% rename from packages/web/src/components/AdminApplicationAuthClientDialog/style.js rename to packages/web/src/components/AdminApplicationOAuthClientDialog/style.js diff --git a/packages/web/src/components/AdminApplicationAuthClients/index.jsx b/packages/web/src/components/AdminApplicationOAuthClients/index.jsx similarity index 70% rename from packages/web/src/components/AdminApplicationAuthClients/index.jsx rename to packages/web/src/components/AdminApplicationOAuthClients/index.jsx index f39bc384..ae41e8ab 100644 --- a/packages/web/src/components/AdminApplicationAuthClients/index.jsx +++ b/packages/web/src/components/AdminApplicationOAuthClients/index.jsx @@ -8,29 +8,30 @@ 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 NoResultFound from 'components/NoResultFound'; import * as URLS from 'config/urls'; import useFormatMessage from 'hooks/useFormatMessage'; -import useAdminAppAuthClients from 'hooks/useAdminAppAuthClients'; -import NoResultFound from 'components/NoResultFound'; +import useAdminOAuthClients from 'hooks/useAdminOAuthClients'; -function AdminApplicationAuthClients(props) { +function AdminApplicationOAuthClients(props) { const { appKey } = props; const formatMessage = useFormatMessage(); - const { data: appAuthClients, isLoading } = useAdminAppAuthClients(appKey); + const { data: appOAuthClients, isLoading } = useAdminOAuthClients(appKey); if (isLoading) return ; - if (!appAuthClients?.data.length) { + if (!appOAuthClients?.data.length) { return ( ); } - const sortedAuthClients = appAuthClients.data.slice().sort((a, b) => { + const sortedOAuthClients = appOAuthClients.data.slice().sort((a, b) => { if (a.id < b.id) { return -1; } @@ -42,7 +43,7 @@ function AdminApplicationAuthClients(props) { return (
- {sortedAuthClients.map((client) => ( + {sortedOAuthClients.map((client) => ( @@ -70,8 +71,13 @@ function AdminApplicationAuthClients(props) { ))} - @@ -79,8 +85,8 @@ function AdminApplicationAuthClients(props) { ); } -AdminApplicationAuthClients.propTypes = { +AdminApplicationOAuthClients.propTypes = { appKey: PropTypes.string.isRequired, }; -export default AdminApplicationAuthClients; +export default AdminApplicationOAuthClients; diff --git a/packages/web/src/components/AdminApplicationSettings/index.jsx b/packages/web/src/components/AdminApplicationSettings/index.jsx index 34ef8d0c..99b46a74 100644 --- a/packages/web/src/components/AdminApplicationSettings/index.jsx +++ b/packages/web/src/components/AdminApplicationSettings/index.jsx @@ -46,9 +46,8 @@ function AdminApplicationSettings(props) { const defaultValues = useMemo( () => ({ - customConnectionAllowed: - appConfig?.data?.customConnectionAllowed || false, - shared: appConfig?.data?.shared || false, + useOnlyPredefinedAuthClients: + appConfig?.data?.useOnlyPredefinedAuthClients || false, disabled: appConfig?.data?.disabled || false, }), [appConfig?.data], @@ -62,21 +61,17 @@ function AdminApplicationSettings(props) { - - + + + ({ ...field, @@ -31,13 +31,13 @@ function AdminApplicationUpdateAuthClient(props) { })); const submitHandler = async (values) => { - if (!adminAppAuthClient) { + if (!adminOAuthClient) { return; } const { name, active, ...formattedAuthDefaults } = values; - await updateAppAuthClient({ + await updateOAuthClient({ name, active, formattedAuthDefaults, @@ -64,31 +64,31 @@ function AdminApplicationUpdateAuthClient(props) { const defaultValues = useMemo( () => ({ - name: adminAppAuthClient?.data?.name || '', - active: adminAppAuthClient?.data?.active || false, + name: adminOAuthClient?.data?.name || '', + active: adminOAuthClient?.data?.active || false, ...getAuthFieldsDefaultValues(), }), - [adminAppAuthClient, getAuthFieldsDefaultValues], + [adminOAuthClient, getAuthFieldsDefaultValues], ); return ( - ); } -AdminApplicationUpdateAuthClient.propTypes = { +AdminApplicationUpdateOAuthClient.propTypes = { application: AppPropType.isRequired, onClose: PropTypes.func.isRequired, }; -export default AdminApplicationUpdateAuthClient; +export default AdminApplicationUpdateOAuthClient; diff --git a/packages/web/src/components/AppAuthClientsDialog/index.ee.jsx b/packages/web/src/components/AppAuthClientsDialog/index.ee.jsx deleted file mode 100644 index a9cce58d..00000000 --- a/packages/web/src/components/AppAuthClientsDialog/index.ee.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import PropTypes from 'prop-types'; -import Dialog from '@mui/material/Dialog'; -import DialogTitle from '@mui/material/DialogTitle'; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemButton from '@mui/material/ListItemButton'; -import ListItemText from '@mui/material/ListItemText'; -import * as React from 'react'; -import useAppAuthClients from 'hooks/useAppAuthClients'; -import useFormatMessage from 'hooks/useFormatMessage'; - -function AppAuthClientsDialog(props) { - const { appKey, onClientClick, onClose } = props; - const { data: appAuthClients } = useAppAuthClients(appKey); - - const formatMessage = useFormatMessage(); - - React.useEffect( - function autoAuthenticateSingleClient() { - if (appAuthClients?.data.length === 1) { - onClientClick(appAuthClients.data[0].id); - } - }, - [appAuthClients?.data], - ); - - if (!appAuthClients?.data.length || appAuthClients?.data.length === 1) - return ; - - return ( - - {formatMessage('appAuthClientsDialog.title')} - - - {appAuthClients.data.map((appAuthClient) => ( - - onClientClick(appAuthClient.id)}> - - - - ))} - - - ); -} - -AppAuthClientsDialog.propTypes = { - appKey: PropTypes.string.isRequired, - onClientClick: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -export default AppAuthClientsDialog; diff --git a/packages/web/src/components/AppConnectionContextMenu/index.jsx b/packages/web/src/components/AppConnectionContextMenu/index.jsx index fb94e4b3..8e7eb318 100644 --- a/packages/web/src/components/AppConnectionContextMenu/index.jsx +++ b/packages/web/src/components/AppConnectionContextMenu/index.jsx @@ -11,14 +11,7 @@ import { useQueryClient } from '@tanstack/react-query'; import Can from 'components/Can'; function ContextMenu(props) { - const { - appKey, - connection, - onClose, - onMenuItemClick, - anchorEl, - disableReconnection, - } = props; + const { appKey, connection, onClose, onMenuItemClick, anchorEl } = props; const formatMessage = useFormatMessage(); const queryClient = useQueryClient(); @@ -73,11 +66,11 @@ function ContextMenu(props) { {(allowed) => ( @@ -109,7 +102,6 @@ ContextMenu.propTypes = { PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) }), ]), - disableReconnection: PropTypes.bool.isRequired, }; export default ContextMenu; diff --git a/packages/web/src/components/AppConnectionRow/index.jsx b/packages/web/src/components/AppConnectionRow/index.jsx index 9311827b..dc7e85a6 100644 --- a/packages/web/src/components/AppConnectionRow/index.jsx +++ b/packages/web/src/components/AppConnectionRow/index.jsx @@ -30,8 +30,7 @@ const countTranslation = (value) => ( function AppConnectionRow(props) { const formatMessage = useFormatMessage(); const enqueueSnackbar = useEnqueueSnackbar(); - const { id, key, formattedData, verified, createdAt, reconnectable } = - props.connection; + const { id, key, formattedData, verified, createdAt } = props.connection; const [verificationVisible, setVerificationVisible] = React.useState(false); const contextButtonRef = React.useRef(null); const [anchorEl, setAnchorEl] = React.useState(null); @@ -174,7 +173,6 @@ function AppConnectionRow(props) { { if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption; - const option = typedSelectedOption; - const eventKey = option?.value; + const eventKey = selectedOption?.value; + const eventLabel = selectedOption?.label; + if (step.key !== eventKey) { onChange({ step: { ...step, key: eventKey, + keyLabel: eventLabel, }, }); } @@ -111,10 +111,8 @@ function ChooseAppAndEventSubstep(props) { const onAppChange = React.useCallback( (event, selectedOption) => { if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption; - const option = typedSelectedOption; - const appKey = option?.value; + const appKey = selectedOption?.value; + if (step.appKey !== appKey) { onChange({ step: { diff --git a/packages/web/src/components/ChooseConnectionSubstep/index.jsx b/packages/web/src/components/ChooseConnectionSubstep/index.jsx index 77390967..2f5df9ee 100644 --- a/packages/web/src/components/ChooseConnectionSubstep/index.jsx +++ b/packages/web/src/components/ChooseConnectionSubstep/index.jsx @@ -7,7 +7,7 @@ import TextField from '@mui/material/TextField'; import * as React from 'react'; import AddAppConnection from 'components/AddAppConnection'; -import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee'; +import AppOAuthClientsDialog from 'components/OAuthClientsDialog/index.ee'; import FlowSubstepTitle from 'components/FlowSubstepTitle'; import useAppConfig from 'hooks/useAppConfig.ee'; import { EditorContext } from 'contexts/Editor'; @@ -22,6 +22,7 @@ import useStepConnection from 'hooks/useStepConnection'; import { useQueryClient } from '@tanstack/react-query'; import useAppConnections from 'hooks/useAppConnections'; import useTestConnection from 'hooks/useTestConnection'; +import useOAuthClients from 'hooks/useOAuthClients'; const ADD_CONNECTION_VALUE = 'ADD_CONNECTION'; const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION'; @@ -53,6 +54,7 @@ function ChooseConnectionSubstep(props) { const [showAddSharedConnectionDialog, setShowAddSharedConnectionDialog] = React.useState(false); const queryClient = useQueryClient(); + const { data: appOAuthClients } = useOAuthClients(application.key); const { authenticate } = useAuthenticateApp({ appKey: application.key, @@ -93,30 +95,53 @@ function ChooseConnectionSubstep(props) { appWithConnections?.map((connection) => optionGenerator(connection)) || []; + const addCustomConnection = { + label: formatMessage('chooseConnectionSubstep.addNewConnection'), + value: ADD_CONNECTION_VALUE, + }; + + const addConnectionWithOAuthClient = { + label: formatMessage( + 'chooseConnectionSubstep.addConnectionWithOAuthClient', + ), + value: ADD_SHARED_CONNECTION_VALUE, + }; + + // means there is no app config. defaulting to custom connections only + if (!appConfig?.data) { + return options.concat([addCustomConnection]); + } + + // app is disabled. + if (appConfig.data.disabled) return options; + + // means only OAuth clients are allowed for connection creation and there is OAuth client if ( - !appConfig?.data || - (!appConfig.data?.disabled && appConfig.data?.customConnectionAllowed) + appConfig.data.useOnlyPredefinedAuthClients === true && + appOAuthClients.data.length > 0 ) { - options.push({ - label: formatMessage('chooseConnectionSubstep.addNewConnection'), - value: ADD_CONNECTION_VALUE, - }); + return options.concat([addConnectionWithOAuthClient]); } - if (appConfig?.data?.connectionAllowed) { - options.push({ - label: formatMessage('chooseConnectionSubstep.addNewSharedConnection'), - value: ADD_SHARED_CONNECTION_VALUE, - }); + // means there is no OAuth client. so we don't show the `addConnectionWithOAuthClient` + if ( + appConfig.data.useOnlyPredefinedAuthClients === true && + appOAuthClients.data.length === 0 + ) { + return options; } - return options; - }, [data, formatMessage, appConfig?.data]); + if (appOAuthClients.data.length === 0) { + return options.concat([addCustomConnection]); + } - const handleClientClick = async (appAuthClientId) => { + return options.concat([addCustomConnection, addConnectionWithOAuthClient]); + }, [data, formatMessage, appConfig, appOAuthClients]); + + const handleClientClick = async (oauthClientId) => { try { const response = await authenticate?.({ - appAuthClientId, + oauthClientId, }); const connectionId = response?.createConnection.id; @@ -162,10 +187,7 @@ function ChooseConnectionSubstep(props) { const handleChange = React.useCallback( async (event, selectedOption) => { if (typeof selectedOption === 'object') { - // TODO: try to simplify type casting below. - const typedSelectedOption = selectedOption; - const option = typedSelectedOption; - const connectionId = option?.value; + const connectionId = selectedOption?.value; if (connectionId === ADD_CONNECTION_VALUE) { setShowAddConnectionDialog(true); @@ -270,7 +292,7 @@ function ChooseConnectionSubstep(props) { )} {application && showAddSharedConnectionDialog && ( - setShowAddSharedConnectionDialog(false)} onClientClick={handleClientClick} diff --git a/packages/web/src/components/Container/index.jsx b/packages/web/src/components/Container/index.jsx index ffafaa14..ef75335b 100644 --- a/packages/web/src/components/Container/index.jsx +++ b/packages/web/src/components/Container/index.jsx @@ -1,10 +1,19 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import MuiContainer from '@mui/material/Container'; -export default function Container(props) { - return ; +export default function Container({ maxWidth = 'lg', ...props }) { + return ; } -Container.defaultProps = { - maxWidth: 'lg', +Container.propTypes = { + maxWidth: PropTypes.oneOf([ + 'xs', + 'sm', + 'md', + 'lg', + 'xl', + false, + PropTypes.string, + ]), }; diff --git a/packages/web/src/components/EditableTypography/index.jsx b/packages/web/src/components/EditableTypography/index.jsx index bb234326..693bed1f 100644 --- a/packages/web/src/components/EditableTypography/index.jsx +++ b/packages/web/src/components/EditableTypography/index.jsx @@ -7,37 +7,68 @@ import { Box, TextField } from './style'; const noop = () => null; function EditableTypography(props) { - const { children, onConfirm = noop, sx, ...typographyProps } = props; + const { + children, + onConfirm = noop, + sx, + iconColor = 'inherit', + disabled = false, + prefixValue = '', + ...typographyProps + } = props; + const [editing, setEditing] = React.useState(false); + const handleClick = React.useCallback(() => { + if (disabled) return; + setEditing((editing) => !editing); - }, []); + }, [disabled]); + const handleTextFieldClick = React.useCallback((event) => { event.stopPropagation(); }, []); + const handleTextFieldKeyDown = React.useCallback( async (event) => { const target = event.target; - if (event.key === 'Enter') { + const eventKey = event.key; + + if (eventKey === 'Enter') { if (target.value !== children) { await onConfirm(target.value); } + + setEditing(false); + } + + if (eventKey === 'Escape') { setEditing(false); } }, - [children], + [children, onConfirm], ); + const handleTextFieldBlur = React.useCallback( async (event) => { const value = event.target.value; + if (value !== children) { await onConfirm(value); } + setEditing(false); }, [onConfirm, children], ); - let component = {children}; + + let component = ( + + {prefixValue} + {children} + + ); + if (editing) { component = ( ); } - return ( - - + return ( + {component} + + {!disabled && editing === false && ( + + )} ); } EditableTypography.propTypes = { children: PropTypes.string.isRequired, + disabled: PropTypes.bool, + iconColor: PropTypes.oneOf(['action', 'inherit']), onConfirm: PropTypes.func, + prefixValue: PropTypes.string, sx: PropTypes.object, }; diff --git a/packages/web/src/components/EditableTypography/style.js b/packages/web/src/components/EditableTypography/style.js index 8e11fd83..4ed685b8 100644 --- a/packages/web/src/components/EditableTypography/style.js +++ b/packages/web/src/components/EditableTypography/style.js @@ -2,17 +2,22 @@ import { styled } from '@mui/material/styles'; import MuiBox from '@mui/material/Box'; import MuiTextField from '@mui/material/TextField'; import { inputClasses } from '@mui/material/Input'; -const boxShouldForwardProp = (prop) => !['editing'].includes(prop); + +const boxShouldForwardProp = (prop) => !['editing', 'disabled'].includes(prop); + export const Box = styled(MuiBox, { shouldForwardProp: boxShouldForwardProp, })` display: flex; flex: 1; - width: 300px; + min-width: 300px; + max-width: 90%; height: 33px; align-items: center; + ${({ disabled }) => !disabled && 'cursor: pointer;'} ${({ editing }) => editing && 'border-bottom: 1px dashed #000;'} `; + export const TextField = styled(MuiTextField)({ width: '100%', [`.${inputClasses.root}:before, .${inputClasses.root}:after, .${inputClasses.root}:hover`]: diff --git a/packages/web/src/components/Editor/index.jsx b/packages/web/src/components/Editor/index.jsx index 96ca3258..9170d06d 100644 --- a/packages/web/src/components/Editor/index.jsx +++ b/packages/web/src/components/Editor/index.jsx @@ -27,6 +27,10 @@ function Editor(props) { connectionId: step.connection?.id, }; + if (step.name || step.keyLabel) { + payload.name = step.name || step.keyLabel; + } + if (step.appKey) { payload.appKey = step.appKey; } diff --git a/packages/web/src/components/EditorLayout/index.jsx b/packages/web/src/components/EditorLayout/index.jsx index c8878b5c..201b6407 100644 --- a/packages/web/src/components/EditorLayout/index.jsx +++ b/packages/web/src/components/EditorLayout/index.jsx @@ -6,29 +6,36 @@ import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import DownloadIcon from '@mui/icons-material/Download'; import Snackbar from '@mui/material/Snackbar'; import { ReactFlowProvider } from 'reactflow'; import { EditorProvider } from 'contexts/Editor'; -import EditableTypography from 'components/EditableTypography'; -import Container from 'components/Container'; -import Editor from 'components/Editor'; -import Can from 'components/Can'; -import useFormatMessage from 'hooks/useFormatMessage'; -import * as URLS from 'config/urls'; import { TopBar } from './style'; +import * as URLS from 'config/urls'; +import Can from 'components/Can'; +import Container from 'components/Container'; +import EditableTypography from 'components/EditableTypography'; +import Editor from 'components/Editor'; +import EditorNew from 'components/EditorNew/EditorNew'; import useFlow from 'hooks/useFlow'; +import useFormatMessage from 'hooks/useFormatMessage'; import useUpdateFlow from 'hooks/useUpdateFlow'; import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus'; -import EditorNew from 'components/EditorNew/EditorNew'; +import useExportFlow from 'hooks/useExportFlow'; +import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; export default function EditorLayout() { const { flowId } = useParams(); const formatMessage = useFormatMessage(); + const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: updateFlow } = useUpdateFlow(flowId); const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId); + const { mutateAsync: exportFlow } = useExportFlow(flowId); + const downloadJsonAsFile = useDownloadJsonAsFile(); const { data, isLoading: isFlowLoading } = useFlow(flowId); const flow = data?.data; @@ -38,6 +45,19 @@ export default function EditorLayout() { }); }; + const onExportFlow = async (name) => { + const flowExport = await exportFlow(); + + downloadJsonAsFile({ + contents: flowExport.data, + name: flowExport.data.name, + }); + + enqueueSnackbar(formatMessage('flowEditor.flowSuccessfullyExported'), { + variant: 'success', + }); + }; + return ( <> {flow?.name} @@ -79,7 +100,23 @@ export default function EditorLayout() { )} - + + + {(allowed) => ( + + )} + + {(allowed) => (
} + path={`/oauth-clients/*`} + element={} /> diff --git a/packages/web/src/pages/Application/index.jsx b/packages/web/src/pages/Application/index.jsx index 74794784..0ba6ab62 100644 --- a/packages/web/src/pages/Application/index.jsx +++ b/packages/web/src/pages/Application/index.jsx @@ -6,7 +6,6 @@ import { Navigate, Routes, useParams, - useSearchParams, useMatch, useNavigate, } from 'react-router-dom'; @@ -31,6 +30,7 @@ import AppIcon from 'components/AppIcon'; import Container from 'components/Container'; import PageTitle from 'components/PageTitle'; import useApp from 'hooks/useApp'; +import useOAuthClients from 'hooks/useOAuthClients'; import Can from 'components/Can'; import { AppPropType } from 'propTypes/propTypes'; @@ -61,47 +61,59 @@ export default function Application() { end: false, }); const flowsPathMatch = useMatch({ path: URLS.APP_FLOWS_PATTERN, end: false }); - const [searchParams] = useSearchParams(); const { appKey } = useParams(); const navigate = useNavigate(); + const { data: appOAuthClients } = useOAuthClients(appKey); const { data, loading } = useApp(appKey); const app = data?.data || {}; const { data: appConfig } = useAppConfig(appKey); - const connectionId = searchParams.get('connectionId') || undefined; const currentUserAbility = useCurrentUserAbility(); const goToApplicationPage = () => navigate('connections'); const connectionOptions = React.useMemo(() => { - const shouldHaveCustomConnection = - appConfig?.data?.connectionAllowed && - appConfig?.data?.customConnectionAllowed; + const addCustomConnection = { + label: formatMessage('app.addConnection'), + key: 'addConnection', + 'data-test': 'add-connection-button', + to: URLS.APP_ADD_CONNECTION(appKey, false), + disabled: + !currentUserAbility.can('create', 'Connection') || + appConfig?.data?.useOnlyPredefinedAuthClients === true || + appConfig?.data?.disabled === true, + }; - const options = [ - { - label: formatMessage('app.addConnection'), - key: 'addConnection', - 'data-test': 'add-connection-button', - to: URLS.APP_ADD_CONNECTION(appKey, appConfig?.data?.connectionAllowed), - disabled: !currentUserAbility.can('create', 'Connection'), - }, - ]; + const addConnectionWithOAuthClient = { + label: formatMessage('app.addConnectionWithOAuthClient'), + key: 'addConnectionWithOAuthClient', + 'data-test': 'add-connection-with-auth-client-button', + to: URLS.APP_ADD_CONNECTION(appKey, true), + disabled: + !currentUserAbility.can('create', 'Connection') || + appOAuthClients?.data?.length === 0 || + appConfig?.data?.disabled === true, + }; - if (shouldHaveCustomConnection) { - options.push({ - label: formatMessage('app.addCustomConnection'), - key: 'addCustomConnection', - 'data-test': 'add-custom-connection-button', - to: URLS.APP_ADD_CONNECTION(appKey), - disabled: !currentUserAbility.can('create', 'Connection'), - }); + // means there is no app config. defaulting to custom connections only + if (!appConfig?.data) { + return [addCustomConnection]; } - return options; - }, [appKey, appConfig?.data, currentUserAbility, formatMessage]); + // means only OAuth clients are allowed for connection creation + if (appConfig?.data?.useOnlyPredefinedAuthClients === true) { + return [addConnectionWithOAuthClient]; + } + + // means there is no OAuth client. so we don't show the `addConnectionWithOAuthClient` + if (appOAuthClients?.data?.length === 0) { + return [addCustomConnection]; + } + + return [addCustomConnection, addConnectionWithOAuthClient]; + }, [appKey, appConfig, appOAuthClients, currentUserAbility, formatMessage]); if (loading) return null; @@ -153,14 +165,7 @@ export default function Application() { {(allowed) => ( disabled) - } + disabled={!allowed} options={connectionOptions} /> )} diff --git a/packages/web/src/pages/CreateRole/index.ee.jsx b/packages/web/src/pages/CreateRole/index.ee.jsx index b5ff22c9..88ac1d82 100644 --- a/packages/web/src/pages/CreateRole/index.ee.jsx +++ b/packages/web/src/pages/CreateRole/index.ee.jsx @@ -1,10 +1,14 @@ import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; import PermissionCatalogField from 'components/PermissionCatalogField/index.ee'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useNavigate } from 'react-router-dom'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; import Container from 'components/Container'; import Form from 'components/Form'; @@ -19,13 +23,49 @@ import useFormatMessage from 'hooks/useFormatMessage'; import useAdminCreateRole from 'hooks/useAdminCreateRole'; import usePermissionCatalog from 'hooks/usePermissionCatalog.ee'; +const getValidationSchema = (formatMessage) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('roleForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + name: yup + .string() + .trim() + .required(getMandatoryFieldMessage('roleForm.name')), + description: yup.string().trim(), + }); +}; + +const getPermissionsErrorMessage = (error) => { + const errors = error?.response?.data?.errors; + + if (errors) { + const permissionsErrors = Object.keys(errors) + .filter((key) => key.startsWith('permissions')) + .reduce((obj, key) => { + obj[key] = errors[key]; + return obj; + }, {}); + + if (Object.keys(permissionsErrors).length > 0) { + return JSON.stringify(permissionsErrors, null, 2); + } + } + + return null; +}; + export default function CreateRole() { const navigate = useNavigate(); const formatMessage = useFormatMessage(); const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: createRole, isPending: isCreateRolePending } = useAdminCreateRole(); - const { data: permissionCatalogData } = usePermissionCatalog(); + const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } = + usePermissionCatalog(); + const [permissionError, setPermissionError] = React.useState(null); const defaultValues = React.useMemo( () => ({ @@ -43,6 +83,7 @@ export default function CreateRole() { const handleRoleCreation = async (roleData) => { try { + setPermissionError(null); const permissions = getPermissions(roleData.computedPermissions); await createRole({ @@ -60,16 +101,13 @@ export default function CreateRole() { navigate(URLS.ROLES); } catch (error) { - const errors = Object.values(error.response.data.errors); - - for (const [errorMessage] of errors) { - enqueueSnackbar(errorMessage, { - variant: 'error', - SnackbarProps: { - 'data-test': 'snackbar-error', - }, - }); + const permissionError = getPermissionsErrorMessage(error); + if (permissionError) { + setPermissionError(permissionError); } + + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -83,37 +121,67 @@ export default function CreateRole() { - - - + ( + + - + - + - - {formatMessage('createRole.submit')} - - - + {permissionError && ( + + + {formatMessage('createRole.permissionsError')} + +
+                      {permissionError}
+                    
+
+ )} + + {errors?.root?.general && !permissionError && ( + + {errors?.root?.general?.message} + + )} + + + {formatMessage('createRole.submit')} + +
+ )} + />
diff --git a/packages/web/src/pages/CreateUser/index.jsx b/packages/web/src/pages/CreateUser/index.jsx index ad96ba96..5eb04376 100644 --- a/packages/web/src/pages/CreateUser/index.jsx +++ b/packages/web/src/pages/CreateUser/index.jsx @@ -3,9 +3,10 @@ import Grid from '@mui/material/Grid'; import Stack from '@mui/material/Stack'; import Alert from '@mui/material/Alert'; import MuiTextField from '@mui/material/TextField'; -import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; import { useQueryClient } from '@tanstack/react-query'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; import Can from 'components/Can'; import Container from 'components/Container'; @@ -16,50 +17,70 @@ import TextField from 'components/TextField'; import useFormatMessage from 'hooks/useFormatMessage'; import useRoles from 'hooks/useRoles.ee'; import useAdminCreateUser from 'hooks/useAdminCreateUser'; +import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; function generateRoleOptions(roles) { return roles?.map(({ name: label, id: value }) => ({ label, value })); } +const getValidationSchema = (formatMessage, canUpdateRole) => { + const getMandatoryFieldMessage = (fieldTranslationId) => + formatMessage('userForm.mandatoryInput', { + inputName: formatMessage(fieldTranslationId), + }); + + return yup.object().shape({ + fullName: yup + .string() + .trim() + .required(getMandatoryFieldMessage('userForm.fullName')), + email: yup + .string() + .trim() + .email(formatMessage('userForm.validateEmail')) + .required(getMandatoryFieldMessage('userForm.email')), + ...(canUpdateRole + ? { + roleId: yup + .string() + .required(getMandatoryFieldMessage('userForm.role')), + } + : {}), + }); +}; + +const defaultValues = { + fullName: '', + email: '', + roleId: '', +}; + export default function CreateUser() { const formatMessage = useFormatMessage(); const { mutateAsync: createUser, isPending: isCreateUserPending, data: createdUser, + isSuccess: createUserSuccess, } = useAdminCreateUser(); const { data: rolesData, loading: isRolesLoading } = useRoles(); const roles = rolesData?.data; - const enqueueSnackbar = useEnqueueSnackbar(); const queryClient = useQueryClient(); + const currentUserAbility = useCurrentUserAbility(); + const canUpdateRole = currentUserAbility.can('update', 'Role'); const handleUserCreation = async (userData) => { try { await createUser({ fullName: userData.fullName, email: userData.email, - roleId: userData.role?.id, + roleId: userData.roleId, }); queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); - - enqueueSnackbar(formatMessage('createUser.successfullyCreated'), { - variant: 'success', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-create-user-success', - }, - }); } catch (error) { - enqueueSnackbar(formatMessage('createUser.error'), { - variant: 'error', - persist: true, - SnackbarProps: { - 'data-test': 'snackbar-error', - }, - }); - - throw new Error('Failed while creating!'); + const errors = error?.response?.data?.errors; + throw errors || error; } }; @@ -73,74 +94,111 @@ export default function CreateUser() { -
- - - - - - - ( + + ( - - )} - loading={isRolesLoading} + error={!!errors?.fullName} + helperText={errors?.fullName?.message} /> - - - {formatMessage('createUser.submit')} - + - {createdUser && ( - + ( + + )} + loading={isRolesLoading} + showHelperText={false} + /> + + + {errors?.root?.general && ( + + {errors?.root?.general?.message} + + )} + + {createUserSuccess && ( + + {formatMessage('createUser.successfullyCreated')} + + )} + + {createdUser && ( + + {formatMessage('createUser.invitationEmailInfo', { + link: () => ( + + {createdUser.data.acceptInvitationUrl} + + ), + })} + + )} + + - {formatMessage('createUser.invitationEmailInfo', { - link: () => ( - - {createdUser.data.acceptInvitationUrl} - - ), - })} - - )} - -
+ {formatMessage('createUser.submit')} + + + )} + />
diff --git a/packages/web/src/pages/EditRole/index.ee.jsx b/packages/web/src/pages/EditRole/index.ee.jsx index 1ed4881c..92573e1e 100644 --- a/packages/web/src/pages/EditRole/index.ee.jsx +++ b/packages/web/src/pages/EditRole/index.ee.jsx @@ -1,6 +1,5 @@ import LoadingButton from '@mui/lab/LoadingButton'; import Grid from '@mui/material/Grid'; -import Skeleton from '@mui/material/Skeleton'; import Stack from '@mui/material/Stack'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import * as React from 'react'; @@ -30,7 +29,8 @@ export default function EditRole() { const { data: roleData, isLoading: isRoleLoading } = useRole({ roleId }); const { mutateAsync: updateRole, isPending: isUpdateRolePending } = useAdminUpdateRole(roleId); - const { data: permissionCatalogData } = usePermissionCatalog(); + const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } = + usePermissionCatalog(); const role = roleData?.data; const permissionCatalog = permissionCatalogData?.data; const enqueueSnackbar = useEnqueueSnackbar(); @@ -84,36 +84,30 @@ export default function EditRole() {
- {isRoleLoading && ( - <> - - - - )} - {!isRoleLoading && role && ( - <> - - - - - )} + + ({ + color: '#001F52', + fontSize: theme.typography.pxToRem(12), + border: '1px solid', + borderColor: alpha(theme.palette.primary.main, 0.3), + bgcolor: alpha( + theme.palette.primary.main, + theme.palette.action.selectedOpacity, + ), + }), + }, + ], + }, MuiContainer: { defaultProps: { maxWidth: 'xl', @@ -294,6 +315,7 @@ export const defaultTheme = createTheme({ }, }, }); + export const mationTheme = createTheme( deepmerge(defaultTheme, { palette: { @@ -315,4 +337,5 @@ export const mationTheme = createTheme( }, }), ); + export default defaultTheme; diff --git a/packages/web/yarn.lock b/packages/web/yarn.lock index 253ca91e..d2b3f70b 100644 --- a/packages/web/yarn.lock +++ b/packages/web/yarn.lock @@ -9633,6 +9633,11 @@ slate@^0.94.1: is-plain-object "^5.0.0" tiny-warning "^1.0.3" +slugify@^1.6.6: + version "1.6.6" + resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b" + integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw== + sockjs@^0.3.24: version "0.3.24" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" @@ -9784,7 +9789,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9888,7 +9902,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -10952,7 +10973,16 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==