diff --git a/packages/backend/src/apps/gitlab/auth/verify-credentials.ts b/packages/backend/src/apps/gitlab/auth/verify-credentials.ts index 1c3bf39f66..dfcbc083c4 100644 --- a/packages/backend/src/apps/gitlab/auth/verify-credentials.ts +++ b/packages/backend/src/apps/gitlab/auth/verify-credentials.ts @@ -25,6 +25,12 @@ const verifyCredentials = async ($: IGlobalVariable) => { $.auth.data.accessToken = data.access_token; const currentUser = await getCurrentUser($); + const screenName = [ + currentUser.username, + $.auth.data.instanceUrl, + ] + .filter(Boolean) + .join(' @ '); await $.auth.set({ clientId: $.auth.data.clientId, @@ -34,7 +40,7 @@ const verifyCredentials = async ($: IGlobalVariable) => { scope: data.scope, tokenType: data.token_type, userId: currentUser.id, - screenName: `${currentUser.username} @ ${$.auth.data.instanceUrl}`, + screenName, }); }; diff --git a/packages/backend/src/db/migrations/20230812132005_create_app_configs.ts b/packages/backend/src/db/migrations/20230812132005_create_app_configs.ts new file mode 100644 index 0000000000..18d7c79c53 --- /dev/null +++ b/packages/backend/src/db/migrations/20230812132005_create_app_configs.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('app_configs', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('key').unique().notNullable(); + table.boolean('allow_custom_connection').notNullable().defaultTo(false); + table.boolean('shared').notNullable().defaultTo(false); + table.boolean('disabled').notNullable().defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('app_configs'); +} diff --git a/packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.ts b/packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.ts new file mode 100644 index 0000000000..4a47a1677c --- /dev/null +++ b/packages/backend/src/db/migrations/20230813172729_create_app_auth_clients.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('app_auth_clients', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').unique().notNullable(); + table.uuid('app_config_id').notNullable().references('id').inTable('app_configs'); + table.text('auth_defaults').notNullable(); + table.boolean('active').notNullable().defaultTo(false); + + table.timestamps(true, true); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('app_auth_clients'); +} diff --git a/packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.ts b/packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.ts new file mode 100644 index 0000000000..c2da6a557c --- /dev/null +++ b/packages/backend/src/db/migrations/20230815161102_add_app_auth_client_id_in_connections.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.table('connections', async (table) => { + table.uuid('app_auth_client_id').references('id').inTable('app_auth_clients'); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.table('connections', (table) => { + table.dropColumn('app_auth_client_id'); + }); +} diff --git a/packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.ts b/packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.ts new file mode 100644 index 0000000000..0117ed096d --- /dev/null +++ b/packages/backend/src/db/migrations/20230816121044_seed_update_app_permissions_to_admin.ts @@ -0,0 +1,33 @@ +import { Knex } from 'knex'; + +const getPermissionForRole = ( + roleId: string, + subject: string, + actions: string[] +) => + actions.map((action) => ({ + role_id: roleId, + subject, + action, + conditions: [], + })); + +export async function up(knex: Knex): Promise { + const role = (await knex('roles') + .first(['id', 'key']) + .where({ key: 'admin' }) + .limit(1)) as { id: string; key: string }; + + await knex('permissions').insert( + getPermissionForRole(role.id, 'App', [ + 'create', + 'read', + 'delete', + 'update', + ]) + ); +} + +export async function down(knex: Knex): Promise { + await knex('permissions').where({ subject: 'App' }).delete(); +} diff --git a/packages/backend/src/graphql/mutation-resolvers.ts b/packages/backend/src/graphql/mutation-resolvers.ts index 511059a5a7..1dc619e5be 100644 --- a/packages/backend/src/graphql/mutation-resolvers.ts +++ b/packages/backend/src/graphql/mutation-resolvers.ts @@ -1,3 +1,5 @@ +import createAppAuthClient from './mutations/create-app-auth-client.ee'; +import createAppConfig from './mutations/create-app-config.ee'; import createConnection from './mutations/create-connection'; import createFlow from './mutations/create-flow'; import createRole from './mutations/create-role.ee'; @@ -17,6 +19,8 @@ import login from './mutations/login'; import registerUser from './mutations/register-user.ee'; import resetConnection from './mutations/reset-connection'; import resetPassword from './mutations/reset-password.ee'; +import updateAppAuthClient from './mutations/update-app-auth-client.ee'; +import updateAppConfig from './mutations/update-app-config.ee'; import updateConfig from './mutations/update-config.ee'; import updateConnection from './mutations/update-connection'; import updateCurrentUser from './mutations/update-current-user'; @@ -30,6 +34,8 @@ import upsertSamlAuthProvidersRoleMappings from './mutations/upsert-saml-auth-pr import verifyConnection from './mutations/verify-connection'; const mutationResolvers = { + createAppAuthClient, + createAppConfig, createConnection, createFlow, createRole, @@ -49,6 +55,8 @@ const mutationResolvers = { registerUser, resetConnection, resetPassword, + updateAppAuthClient, + updateAppConfig, updateConfig, updateConnection, updateCurrentUser, diff --git a/packages/backend/src/graphql/mutations/create-app-auth-client.ee.ts b/packages/backend/src/graphql/mutations/create-app-auth-client.ee.ts new file mode 100644 index 0000000000..a4c4554fba --- /dev/null +++ b/packages/backend/src/graphql/mutations/create-app-auth-client.ee.ts @@ -0,0 +1,35 @@ +import { IJSONObject } from '@automatisch/types'; +import AppConfig from '../../models/app-config'; +import Context from '../../types/express/context'; + +type Params = { + input: { + appConfigId: string; + name: string; + formattedAuthDefaults?: IJSONObject; + active?: boolean; + }; +}; + +const createAppAuthClient = async ( + _parent: unknown, + params: Params, + context: Context +) => { + context.currentUser.can('update', 'App'); + + const appConfig = await AppConfig + .query() + .findById(params.input.appConfigId) + .throwIfNotFound(); + + const appAuthClient = await appConfig + .$relatedQuery('appAuthClients') + .insert( + params.input + ); + + return appAuthClient; +}; + +export default createAppAuthClient; diff --git a/packages/backend/src/graphql/mutations/create-app-config.ee.ts b/packages/backend/src/graphql/mutations/create-app-config.ee.ts new file mode 100644 index 0000000000..ae77f8d5e5 --- /dev/null +++ b/packages/backend/src/graphql/mutations/create-app-config.ee.ts @@ -0,0 +1,36 @@ +import App from '../../models/app'; +import AppConfig from '../../models/app-config'; +import Context from '../../types/express/context'; + +type Params = { + input: { + key: string; + allowCustomConnection?: boolean; + shared?: boolean; + disabled?: boolean; + }; +}; + +const createAppConfig = async ( + _parent: unknown, + params: Params, + context: Context +) => { + context.currentUser.can('update', 'App'); + + const key = params.input.key; + + const app = await App.findOneByKey(key); + + if (!app) throw new Error('The app cannot be found!'); + + const appConfig = await AppConfig + .query() + .insert( + params.input + ); + + return appConfig; +}; + +export default createAppConfig; diff --git a/packages/backend/src/graphql/mutations/create-connection.ts b/packages/backend/src/graphql/mutations/create-connection.ts index 11b48ba552..608e50c002 100644 --- a/packages/backend/src/graphql/mutations/create-connection.ts +++ b/packages/backend/src/graphql/mutations/create-connection.ts @@ -1,13 +1,16 @@ +import { IJSONObject } from '@automatisch/types'; import App from '../../models/app'; +import AppConfig from '../../models/app-config'; import Context from '../../types/express/context'; -import { IJSONObject } from '@automatisch/types'; type Params = { input: { key: string; + appAuthClientId: string; formattedData: IJSONObject; }; }; + const createConnection = async ( _parent: unknown, params: Params, @@ -15,13 +18,42 @@ const createConnection = async ( ) => { context.currentUser.can('create', 'Connection'); - await App.findOneByKey(params.input.key); + const { key, appAuthClientId } = params.input; + + const app = await App.findOneByKey(key); + + const appConfig = await AppConfig.query().findOne({ key }); + + let formattedData = params.input.formattedData; + if (appConfig) { + if (appConfig.disabled) throw new Error('This application has been disabled for new connections!'); + + if (!appConfig.allowCustomConnection && formattedData) throw new Error(`Custom connections cannot be created for ${app.name}!`); + + if (appConfig.shared && !formattedData) { + const authClient = await appConfig + .$relatedQuery('appAuthClients') + .findById(appAuthClientId) + .where({ + active: true + }) + .throwIfNotFound(); + + formattedData = authClient.formattedAuthDefaults; + } + } + + const createdConnection = await context + .currentUser + .$relatedQuery('connections') + .insert({ + key, + appAuthClientId, + formattedData, + verified: false, + }); - return await context.currentUser.$relatedQuery('connections').insert({ - key: params.input.key, - formattedData: params.input.formattedData, - verified: false, - }); + return createdConnection; }; export default createConnection; diff --git a/packages/backend/src/graphql/mutations/delete-app-auth-client.ee.ts b/packages/backend/src/graphql/mutations/delete-app-auth-client.ee.ts new file mode 100644 index 0000000000..28ec6c82ac --- /dev/null +++ b/packages/backend/src/graphql/mutations/delete-app-auth-client.ee.ts @@ -0,0 +1,28 @@ +import Context from '../../types/express/context'; +import AppAuthClient from '../../models/app-auth-client'; + +type Params = { + input: { + id: string; + }; +}; + +const deleteAppAuthClient = async ( + _parent: unknown, + params: Params, + context: Context +) => { + context.currentUser.can('delete', 'App'); + + await AppAuthClient + .query() + .delete() + .findOne({ + id: params.input.id, + }) + .throwIfNotFound(); + + return; +}; + +export default deleteAppAuthClient; diff --git a/packages/backend/src/graphql/mutations/update-app-auth-client.ee.ts b/packages/backend/src/graphql/mutations/update-app-auth-client.ee.ts new file mode 100644 index 0000000000..2847c1e6d2 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-app-auth-client.ee.ts @@ -0,0 +1,38 @@ +import { IJSONObject } from '@automatisch/types'; +import AppAuthClient from '../../models/app-auth-client'; +import Context from '../../types/express/context'; + +type Params = { + input: { + id: string; + name: string; + formattedAuthDefaults?: IJSONObject; + active?: boolean; + }; +}; + +const updateAppAuthClient = async ( + _parent: unknown, + params: Params, + context: Context +) => { + context.currentUser.can('update', 'App'); + + const { + id, + ...appAuthClientData + } = params.input; + + const appAuthClient = await AppAuthClient + .query() + .findById(id) + .throwIfNotFound(); + + await appAuthClient + .$query() + .patch(appAuthClientData); + + return appAuthClient; +}; + +export default updateAppAuthClient; diff --git a/packages/backend/src/graphql/mutations/update-app-config.ee.ts b/packages/backend/src/graphql/mutations/update-app-config.ee.ts new file mode 100644 index 0000000000..c20a916855 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-app-config.ee.ts @@ -0,0 +1,39 @@ +import AppConfig from '../../models/app-config'; +import Context from '../../types/express/context'; + +type Params = { + input: { + id: string; + allowCustomConnection?: boolean; + shared?: boolean; + disabled?: boolean; + }; +}; + +const updateAppConfig = async ( + _parent: unknown, + params: Params, + context: Context +) => { + context.currentUser.can('update', 'App'); + + const { + id, + ...appConfigToUpdate + } = params.input; + + const appConfig = await AppConfig + .query() + .findById(id) + .throwIfNotFound(); + + await appConfig + .$query() + .patch( + appConfigToUpdate + ); + + return appConfig; +}; + +export default updateAppConfig; diff --git a/packages/backend/src/graphql/mutations/update-connection.ts b/packages/backend/src/graphql/mutations/update-connection.ts index a741a9eb7a..d72651d268 100644 --- a/packages/backend/src/graphql/mutations/update-connection.ts +++ b/packages/backend/src/graphql/mutations/update-connection.ts @@ -1,10 +1,12 @@ -import Context from '../../types/express/context'; import { IJSONObject } from '@automatisch/types'; +import Context from '../../types/express/context'; +import AppAuthClient from '../../models/app-auth-client'; type Params = { input: { id: string; - formattedData: IJSONObject; + formattedData?: IJSONObject; + appAuthClientId?: string; }; }; @@ -22,10 +24,21 @@ const updateConnection = async ( }) .throwIfNotFound(); + let formattedData = params.input.formattedData; + + if (params.input.appAuthClientId) { + const appAuthClient = await AppAuthClient + .query() + .findById(params.input.appAuthClientId) + .throwIfNotFound(); + + formattedData = appAuthClient.formattedAuthDefaults; + } + connection = await connection.$query().patchAndFetch({ formattedData: { ...connection.formattedData, - ...params.input.formattedData, + ...formattedData, }, }); diff --git a/packages/backend/src/graphql/queries/get-app-auth-client.ee.ts b/packages/backend/src/graphql/queries/get-app-auth-client.ee.ts new file mode 100644 index 0000000000..14fa2ed3d7 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-app-auth-client.ee.ts @@ -0,0 +1,30 @@ +import AppAuthClient from '../../models/app-auth-client'; +import Context from '../../types/express/context'; + +type Params = { + id: string; +}; + +const getAppAuthClient = async (_parent: unknown, params: Params, context: Context) => { + let canSeeAllClients = false; + try { + context.currentUser.can('read', 'App'); + + canSeeAllClients = true; + } catch { + // void + } + + const appAuthClient = AppAuthClient + .query() + .findById(params.id) + .throwIfNotFound(); + + if (!canSeeAllClients) { + appAuthClient.where({ active: true }); + } + + return await appAuthClient; +}; + +export default getAppAuthClient; diff --git a/packages/backend/src/graphql/queries/get-app-auth-clients.ee.ts b/packages/backend/src/graphql/queries/get-app-auth-clients.ee.ts new file mode 100644 index 0000000000..d9d05b02c5 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-app-auth-clients.ee.ts @@ -0,0 +1,40 @@ +import AppConfig from '../../models/app-config'; +import Context from '../../types/express/context'; + +type Params = { + appKey: string; + active: boolean; +}; + +const getAppAuthClients = async (_parent: unknown, params: Params, context: Context) => { + let canSeeAllClients = false; + try { + context.currentUser.can('read', 'App'); + + canSeeAllClients = true; + } catch { + // void + } + + const appConfig = await AppConfig + .query() + .findOne({ + key: params.appKey, + }) + .throwIfNotFound(); + + const appAuthClients = appConfig + .$relatedQuery('appAuthClients') + .where({ active: params.active }) + .skipUndefined(); + + if (!canSeeAllClients) { + appAuthClients.where({ + active: true + }) + } + + return await appAuthClients; +}; + +export default getAppAuthClients; diff --git a/packages/backend/src/graphql/queries/get-app-config.ee.ts b/packages/backend/src/graphql/queries/get-app-config.ee.ts new file mode 100644 index 0000000000..7875f423a3 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-app-config.ee.ts @@ -0,0 +1,23 @@ +import AppConfig from '../../models/app-config'; +import Context from '../../types/express/context'; + +type Params = { + key: string; +}; + +const getAppConfig = async (_parent: unknown, params: Params, context: Context) => { + context.currentUser.can('create', 'Connection'); + + const appConfig = await AppConfig + .query() + .withGraphFetched({ + appAuthClients: true + }) + .findOne({ + key: params.key + }); + + return appConfig; +}; + +export default getAppConfig; diff --git a/packages/backend/src/graphql/queries/get-app.ts b/packages/backend/src/graphql/queries/get-app.ts index 241e8be823..6a1709a02a 100644 --- a/packages/backend/src/graphql/queries/get-app.ts +++ b/packages/backend/src/graphql/queries/get-app.ts @@ -19,6 +19,10 @@ const getApp = async (_parent: unknown, params: Params, context: Context) => { const connections = await connectionBaseQuery .clone() .select('connections.*') + .withGraphFetched({ + appConfig: true, + appAuthClient: true + }) .fullOuterJoinRelated('steps') .where({ 'connections.key': params.key, diff --git a/packages/backend/src/graphql/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index 768c8b3b50..f691735c66 100644 --- a/packages/backend/src/graphql/query-resolvers.ts +++ b/packages/backend/src/graphql/query-resolvers.ts @@ -1,9 +1,12 @@ import getApp from './queries/get-app'; +import getAppAuthClient from './queries/get-app-auth-client.ee'; +import getAppAuthClients from './queries/get-app-auth-clients.ee'; +import getAppConfig from './queries/get-app-config.ee'; import getApps from './queries/get-apps'; import getAutomatischInfo from './queries/get-automatisch-info'; import getBillingAndUsage from './queries/get-billing-and-usage.ee'; -import getConnectedApps from './queries/get-connected-apps'; import getConfig from './queries/get-config.ee'; +import getConnectedApps from './queries/get-connected-apps'; import getCurrentUser from './queries/get-current-user'; import getDynamicData from './queries/get-dynamic-data'; import getDynamicFields from './queries/get-dynamic-fields'; @@ -30,6 +33,9 @@ import testConnection from './queries/test-connection'; const queryResolvers = { getApp, + getAppAuthClient, + getAppAuthClients, + getAppConfig, getApps, getAutomatischInfo, getBillingAndUsage, diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index f4867c2199..b48daac36d 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -5,6 +5,9 @@ type Query { onlyWithActions: Boolean ): [App] getApp(key: String!): App + getAppConfig(key: String!): AppConfig + getAppAuthClient(id: String!): AppAuthClient + getAppAuthClients(appKey: String!, active: Boolean): [AppAuthClient] getConnectedApps(name: String): [App] testConnection(id: String!): Connection getFlow(id: String!): Flow @@ -49,10 +52,12 @@ type Query { getUser(id: String!): User getUsers(limit: Int!, offset: Int!): UserConnection healthcheck: AppHealth - listSamlAuthProviders: [ListSamlAuthProviders] + listSamlAuthProviders: [ListSamlAuthProvider] } type Mutation { + createAppConfig(input: CreateAppConfigInput): AppConfig + createAppAuthClient(input: CreateAppAuthClientInput): AppAuthClient createConnection(input: CreateConnectionInput): Connection createFlow(input: CreateFlowInput): Flow createRole(input: CreateRoleInput): Role @@ -72,6 +77,8 @@ type Mutation { registerUser(input: RegisterUserInput): User resetConnection(input: ResetConnectionInput): Connection resetPassword(input: ResetPasswordInput): Boolean + updateAppAuthClient(input: UpdateAppAuthClientInput): AppAuthClient + updateAppConfig(input: UpdateAppConfigInput): AppConfig updateConfig(input: JSONObject): JSONObject updateConnection(input: UpdateConnectionInput): Connection updateCurrentUser(input: UpdateCurrentUserInput): User @@ -162,6 +169,16 @@ type SubstepArgumentAdditionalFieldsArgument { value: String } +type AppConfig { + id: String + key: String + allowCustomConnection: Boolean + canConnect: Boolean + canCustomConnect: Boolean + shared: Boolean + disabled: Boolean +} + type App { name: String key: String @@ -181,7 +198,9 @@ type App { type AppAuth { fields: [Field] authenticationSteps: [AuthenticationStep] + sharedAuthenticationSteps: [AuthenticationStep] reconnectionSteps: [ReconnectionStep] + sharedReconnectionSteps: [ReconnectionStep] } enum ArgumentEnumType { @@ -219,6 +238,8 @@ type AuthLink { type Connection { id: String key: String + reconnectable: Boolean + appAuthClientId: String formattedData: ConnectionData verified: Boolean app: App @@ -328,7 +349,8 @@ type UserEdge { input CreateConnectionInput { key: String! - formattedData: JSONObject! + appAuthClientId: String + formattedData: JSONObject } input GenerateAuthUrlInput { @@ -337,7 +359,8 @@ input GenerateAuthUrlInput { input UpdateConnectionInput { id: String! - formattedData: JSONObject! + formattedData: JSONObject + appAuthClientId: String } input ResetConnectionInput { @@ -690,7 +713,7 @@ type PaymentPlan { productId: String } -type ListSamlAuthProviders { +type ListSamlAuthProvider { id: String name: String issuer: String @@ -725,6 +748,41 @@ type Subject { key: String } +input CreateAppConfigInput { + key: String + allowCustomConnection: Boolean + shared: Boolean + disabled: Boolean +} + +input UpdateAppConfigInput { + id: String + allowCustomConnection: Boolean + shared: Boolean + disabled: Boolean +} + +type AppAuthClient { + id: String + appConfigId: String + name: String + active: Boolean +} + +input CreateAppAuthClientInput { + appConfigId: String + name: String + formattedAuthDefaults: JSONObject + active: Boolean +} + +input UpdateAppAuthClientInput { + id: String + name: String + formattedAuthDefaults: JSONObject + active: Boolean +} + schema { query: Query mutation: Mutation diff --git a/packages/backend/src/helpers/add-authentication-steps.ts b/packages/backend/src/helpers/add-authentication-steps.ts index 1cf448464f..0728ec8cf5 100644 --- a/packages/backend/src/helpers/add-authentication-steps.ts +++ b/packages/backend/src/helpers/add-authentication-steps.ts @@ -3,6 +3,7 @@ import { IApp } from '@automatisch/types'; function addAuthenticationSteps(app: IApp): IApp { if (app.auth.generateAuthUrl) { app.auth.authenticationSteps = authenticationStepsWithAuthUrl; + app.auth.sharedAuthenticationSteps = sharedAuthenticationStepsWithAuthUrl; } else { app.auth.authenticationSteps = authenticationStepsWithoutAuthUrl; } @@ -98,4 +99,65 @@ const authenticationStepsWithAuthUrl = [ }, ]; +const sharedAuthenticationStepsWithAuthUrl = [ + { + type: 'mutation' as const, + name: 'createConnection', + arguments: [ + { + name: 'key', + value: '{key}', + }, + { + name: 'appAuthClientId', + value: '{appAuthClientId}', + }, + ], + }, + { + type: 'mutation' as const, + name: 'generateAuthUrl', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, + { + type: 'openWithPopup' as const, + name: 'openAuthPopup', + arguments: [ + { + name: 'url', + value: '{generateAuthUrl.url}', + }, + ], + }, + { + type: 'mutation' as const, + name: 'updateConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + { + name: 'formattedData', + value: '{openAuthPopup.all}', + }, + ], + }, + { + type: 'mutation' as const, + name: 'verifyConnection', + arguments: [ + { + name: 'id', + value: '{createConnection.id}', + }, + ], + }, +]; + export default addAuthenticationSteps; diff --git a/packages/backend/src/helpers/add-reconnection-steps.ts b/packages/backend/src/helpers/add-reconnection-steps.ts index 1d21008def..38ec9e495d 100644 --- a/packages/backend/src/helpers/add-reconnection-steps.ts +++ b/packages/backend/src/helpers/add-reconnection-steps.ts @@ -67,11 +67,21 @@ function addReconnectionSteps(app: IApp): IApp { if (hasReconnectionSteps) return app; - const updatedSteps = replaceCreateConnectionsWithUpdate( - app.auth.authenticationSteps - ); + if (app.auth.authenticationSteps) { + const updatedSteps = replaceCreateConnectionsWithUpdate( + app.auth.authenticationSteps + ); - app.auth.reconnectionSteps = [resetConnectionStep, ...updatedSteps]; + app.auth.reconnectionSteps = [resetConnectionStep, ...updatedSteps]; + } + + if (app.auth.sharedAuthenticationSteps) { + const updatedStepsWithEmbeddedDefaults = replaceCreateConnectionsWithUpdate( + app.auth.sharedAuthenticationSteps + ); + + app.auth.sharedReconnectionSteps = [resetConnectionStep, ...updatedStepsWithEmbeddedDefaults]; + } return app; } diff --git a/packages/backend/src/models/app-auth-client.ts b/packages/backend/src/models/app-auth-client.ts new file mode 100644 index 0000000000..cc442580b1 --- /dev/null +++ b/packages/backend/src/models/app-auth-client.ts @@ -0,0 +1,91 @@ +import { IJSONObject } from '@automatisch/types'; +import { AES, enc } from 'crypto-js'; +import { ModelOptions, QueryContext } from 'objection'; +import appConfig from '../config/app'; +import AppConfig from './app-config'; +import Base from './base'; + +class AppAuthClient extends Base { + id!: string; + name: string; + active: boolean; + appConfigId!: string; + authDefaults: string; + formattedAuthDefaults?: IJSONObject; + appConfig?: AppConfig; + + static tableName = 'app_auth_clients'; + + static jsonSchema = { + type: 'object', + required: ['name', 'appConfigId', 'formattedAuthDefaults'], + + properties: { + id: { type: 'string', format: 'uuid' }, + appConfigId: { type: 'string', format: 'uuid' }, + active: { type: 'boolean' }, + authDefaults: { type: ['string', 'null'] }, + formattedAuthDefaults: { type: 'object' }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + }, + }; + + static relationMappings = () => ({ + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'app_auth_clients.app_config_id', + to: 'app_configs.id', + }, + }, + }); + + encryptData(): void { + if (!this.eligibleForEncryption()) return; + + this.authDefaults = AES.encrypt( + JSON.stringify(this.formattedAuthDefaults), + appConfig.encryptionKey + ).toString(); + + delete this.formattedAuthDefaults; + } + decryptData(): void { + if (!this.eligibleForDecryption()) return; + + this.formattedAuthDefaults = JSON.parse( + AES.decrypt(this.authDefaults, appConfig.encryptionKey).toString(enc.Utf8) + ); + } + + eligibleForEncryption(): boolean { + return this.formattedAuthDefaults ? true : false; + } + + eligibleForDecryption(): boolean { + return this.authDefaults ? true : false; + } + + // TODO: Make another abstraction like beforeSave instead of using + // beforeInsert and beforeUpdate separately for the same operation. + async $beforeInsert(queryContext: QueryContext): Promise { + await super.$beforeInsert(queryContext); + this.encryptData(); + } + + async $beforeUpdate( + opt: ModelOptions, + queryContext: QueryContext + ): Promise { + await super.$beforeUpdate(opt, queryContext); + this.encryptData(); + } + + async $afterFind(): Promise { + this.decryptData(); + } +} + +export default AppAuthClient; diff --git a/packages/backend/src/models/app-config.ts b/packages/backend/src/models/app-config.ts new file mode 100644 index 0000000000..e34c014d90 --- /dev/null +++ b/packages/backend/src/models/app-config.ts @@ -0,0 +1,70 @@ +import App from './app'; +import Base from './base'; +import AppAuthClient from './app-auth-client'; + +class AppConfig extends Base { + id!: string; + key!: string; + allowCustomConnection: boolean; + shared: boolean; + disabled: boolean; + app?: App; + appAuthClients?: AppAuthClient[]; + + static tableName = 'app_configs'; + + static jsonSchema = { + type: 'object', + required: ['key'], + + properties: { + id: { type: 'string', format: 'uuid' }, + key: { type: 'string' }, + allowCustomConnection: { type: 'boolean', default: false }, + shared: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + }, + }; + + static get virtualAttributes() { + return ['canConnect', 'canCustomConnect']; + } + + static relationMappings = () => ({ + appAuthClients: { + relation: Base.HasManyRelation, + modelClass: AppAuthClient, + join: { + from: 'app_configs.id', + to: 'app_auth_clients.app_config_id', + }, + }, + }); + + get canCustomConnect() { + return !this.disabled && this.allowCustomConnection; + } + + get canConnect() { + const hasSomeActiveAppAuthClients = !!this.appAuthClients + ?.some(appAuthClient => appAuthClient.active); + const shared = this.shared; + const active = this.disabled === false; + + const conditions = [ + hasSomeActiveAppAuthClients, + shared, + active + ]; + + return conditions.every(Boolean); + } + + async getApp() { + if (!this.key) return null; + + return await App.findOneByKey(this.key); + } +} + +export default AppConfig; diff --git a/packages/backend/src/models/connection.ts b/packages/backend/src/models/connection.ts index cda1d7cb64..8fccf88f11 100644 --- a/packages/backend/src/models/connection.ts +++ b/packages/backend/src/models/connection.ts @@ -3,6 +3,8 @@ import type { RelationMappings } from 'objection'; import { AES, enc } from 'crypto-js'; import { IRequest } from '@automatisch/types'; import App from './app'; +import AppConfig from './app-config'; +import AppAuthClient from './app-auth-client'; import Base from './base'; import User from './user'; import Step from './step'; @@ -25,6 +27,9 @@ class Connection extends Base { user?: User; steps?: Step[]; triggerSteps?: Step[]; + appAuthClientId?: string; + appAuthClient?: AppAuthClient; + appConfig?: AppConfig; static tableName = 'connections'; @@ -38,6 +43,7 @@ class Connection extends Base { data: { type: 'string' }, formattedData: { type: 'object' }, userId: { type: 'string', format: 'uuid' }, + appAuthClientId: { type: 'string', format: 'uuid' }, verified: { type: 'boolean', default: false }, draft: { type: 'boolean' }, deletedAt: { type: 'string' }, @@ -46,6 +52,10 @@ class Connection extends Base { }, }; + static get virtualAttributes() { + return ['reconnectable']; + } + static relationMappings = (): RelationMappings => ({ user: { relation: Base.BelongsToOneRelation, @@ -74,8 +84,36 @@ class Connection extends Base { builder.where('type', '=', 'trigger'); }, }, + appConfig: { + relation: Base.BelongsToOneRelation, + modelClass: AppConfig, + join: { + from: 'connections.key', + to: 'app_configs.key', + }, + }, + appAuthClient: { + relation: Base.BelongsToOneRelation, + modelClass: AppAuthClient, + join: { + from: 'connections.app_auth_client_id', + to: 'app_auth_clients.id', + }, + }, }); + get reconnectable() { + if (this.appAuthClientId) { + return this.appAuthClient.active; + } + + if (this.appConfig) { + return !this.appConfig.disabled && this.appConfig.allowCustomConnection; + } + + return true; + } + encryptData(): void { if (!this.eligibleForEncryption()) return; diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index 383ff03fc5..fe58670cac 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -294,6 +294,7 @@ class User extends Base { if (Array.isArray(this.permissions)) { this.permissions = this.permissions.filter((permission) => { const restrictedSubjects = [ + 'App', 'Role', 'SamlAuthProvider', 'Config', diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 0921334190..61f3de35ce 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -27,6 +27,8 @@ export interface IConnection { flowCount?: number; appData?: IApp; createdAt: string; + reconnectable?: boolean; + appAuthClientId?: string; } export interface IExecutionStep { @@ -247,6 +249,8 @@ export interface IAuth { fields?: IField[]; authenticationSteps?: IAuthenticationStep[]; reconnectionSteps?: IAuthenticationStep[]; + sharedAuthenticationSteps?: IAuthenticationStep[]; + sharedReconnectionSteps?: IAuthenticationStep[]; } export interface ITriggerOutput { @@ -424,6 +428,24 @@ type TSamlAuthProvider = { defaultRoleId: string; } +type AppConfig = { + id: string; + key: string; + allowCustomConnection: boolean; + canConnect: boolean; + canCustomConnect: boolean; + shared: boolean; + disabled: boolean; +} + +type AppAuthClient = { + id: string; + name: string; + appConfigId: string; + authDefaults: string; + formattedAuthDefaults: IJSONObject; +} + declare module 'axios' { interface AxiosResponse { httpError?: IJSONObject; diff --git a/packages/web/src/components/AddAppConnection/index.tsx b/packages/web/src/components/AddAppConnection/index.tsx index 201c0acac7..2a9e64e0d7 100644 --- a/packages/web/src/components/AddAppConnection/index.tsx +++ b/packages/web/src/components/AddAppConnection/index.tsx @@ -1,17 +1,19 @@ -import * as React from 'react'; +import type { IApp, IField, IJSONObject } from '@automatisch/types'; +import LoadingButton from '@mui/lab/LoadingButton'; import Alert from '@mui/material/Alert'; -import DialogTitle from '@mui/material/DialogTitle'; +import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; -import Dialog from '@mui/material/Dialog'; -import LoadingButton from '@mui/lab/LoadingButton'; +import DialogTitle from '@mui/material/DialogTitle'; +import * as React from 'react'; import { FieldValues, SubmitHandler } from 'react-hook-form'; -import type { IApp, IJSONObject, IField } from '@automatisch/types'; +import { useNavigate, useSearchParams } from 'react-router-dom'; -import useFormatMessage from 'hooks/useFormatMessage'; -import computeAuthStepVariables from 'helpers/computeAuthStepVariables'; -import { processStep } from 'helpers/authenticationSteps'; +import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee'; import InputCreator from 'components/InputCreator'; +import * as URLS from 'config/urls'; +import useAuthenticateApp from 'hooks/useAuthenticateApp.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; import { generateExternalLink } from '../../helpers/translationValues'; import { Form } from './style'; @@ -21,24 +23,27 @@ type AddAppConnectionProps = { connectionId?: string; }; -type Response = { - [key: string]: any; -}; - export default function AddAppConnection( props: AddAppConnectionProps ): React.ReactElement { const { application, connectionId, onClose } = props; const { name, authDocUrl, key, auth } = application; + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const formatMessage = useFormatMessage(); const [error, setError] = React.useState(null); const [inProgress, setInProgress] = React.useState(false); const hasConnection = Boolean(connectionId); - const steps = hasConnection - ? auth?.reconnectionSteps - : auth?.authenticationSteps; - - React.useEffect(() => { + const useShared = searchParams.get('shared') === 'true'; + const appAuthClientId = searchParams.get('appAuthClientId') || undefined; + const { authenticate } = useAuthenticateApp({ + appKey: key, + connectionId, + appAuthClientId, + useShared: !!appAuthClientId, + }); + + React.useEffect(function relayProviderData() { if (window.opener) { window.opener.postMessage({ source: 'automatisch', @@ -48,51 +53,61 @@ export default function AddAppConnection( } }, []); - const submitHandler: SubmitHandler = React.useCallback( - async (data) => { - if (!steps) return; + React.useEffect( + function initiateSharedAuthenticationForGivenAuthClient() { + if (!appAuthClientId) return; + if (!authenticate) return; - setInProgress(true); - setError(null); - - const response: Response = { - key, - connection: { - id: connectionId, - }, - fields: data, + const asyncAuthenticate = async () => { + await authenticate(); + + navigate(URLS.APP_CONNECTIONS(key)); }; - let stepIndex = 0; - while (stepIndex < steps.length) { - const step = steps[stepIndex]; - const variables = computeAuthStepVariables(step.arguments, response); + asyncAuthenticate(); + }, + [appAuthClientId, authenticate] + ); - try { - const stepResponse = await processStep(step, variables); + const handleClientClick = (appAuthClientId: string) => + navigate(URLS.APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID(key, appAuthClientId)); - response[step.name] = stepResponse; - } catch (err) { - const error = err as IJSONObject; - console.log(error); - setError((error.graphQLErrors as IJSONObject[])?.[0]); - setInProgress(false); + const handleAuthClientsDialogClose = () => + navigate(URLS.APP_CONNECTIONS(key)); - break; - } + const submitHandler: SubmitHandler = React.useCallback( + async (data) => { + if (!authenticate) return; - stepIndex++; + setInProgress(true); - if (stepIndex === steps.length) { - onClose(response); - } + try { + const response = await authenticate({ + fields: data, + }); + onClose(response as Record); + } catch (err) { + const error = err as IJSONObject; + console.log(error); + setError((error.graphQLErrors as IJSONObject[])?.[0]); + } finally { + setInProgress(false); } - - setInProgress(false); }, - [connectionId, key, steps, onClose] + [authenticate] ); + if (useShared) + return ( + + ); + + if (appAuthClientId) return ; + return ( diff --git a/packages/web/src/components/AppAuthClientsDialog/index.ee.tsx b/packages/web/src/components/AppAuthClientsDialog/index.ee.tsx new file mode 100644 index 0000000000..511feea7e6 --- /dev/null +++ b/packages/web/src/components/AppAuthClientsDialog/index.ee.tsx @@ -0,0 +1,50 @@ +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.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; + +type AppAuthClientsDialogProps = { + appKey: string; + onClientClick: (appAuthClientId: string) => void; + onClose: () => void; +}; + +export default function AppAuthClientsDialog(props: AppAuthClientsDialogProps) { + const { appKey, onClientClick, onClose } = props; + const { appAuthClients } = useAppAuthClients(appKey); + const formatMessage = useFormatMessage(); + + React.useEffect( + function autoAuthenticateSingleClient() { + if (appAuthClients?.length === 1) { + onClientClick(appAuthClients[0].id); + } + }, + [appAuthClients] + ); + + if (!appAuthClients?.length || appAuthClients?.length === 1) + return ; + + return ( + + {formatMessage('appAuthClientsDialog.title')} + + + {appAuthClients.map((appAuthClient) => ( + + onClientClick(appAuthClient.id)}> + + + + ))} + + + ); +} diff --git a/packages/web/src/components/AppConnectionContextMenu/index.tsx b/packages/web/src/components/AppConnectionContextMenu/index.tsx index d18a7818ed..d88e696adb 100644 --- a/packages/web/src/components/AppConnectionContextMenu/index.tsx +++ b/packages/web/src/components/AppConnectionContextMenu/index.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'; import Menu from '@mui/material/Menu'; import type { PopoverProps } from '@mui/material/Popover'; import MenuItem from '@mui/material/MenuItem'; +import type { IConnection } from '@automatisch/types'; import * as URLS from 'config/urls'; import useFormatMessage from 'hooks/useFormatMessage'; @@ -13,16 +14,24 @@ type Action = { type ContextMenuProps = { appKey: string; - connectionId: string; + connection: IConnection; onClose: () => void; onMenuItemClick: (event: React.MouseEvent, action: Action) => void; anchorEl: PopoverProps['anchorEl']; + disableReconnection: boolean; }; export default function ContextMenu( props: ContextMenuProps ): React.ReactElement { - const { appKey, connectionId, onClose, onMenuItemClick, anchorEl } = props; + const { + appKey, + connection, + onClose, + onMenuItemClick, + anchorEl, + disableReconnection, + } = props; const formatMessage = useFormatMessage(); const createActionHandler = React.useCallback( @@ -45,7 +54,7 @@ export default function ContextMenu( > {formatMessage('connection.viewFlows')} @@ -57,7 +66,12 @@ export default function ContextMenu( {formatMessage('connection.reconnect')} diff --git a/packages/web/src/components/AppConnectionRow/index.tsx b/packages/web/src/components/AppConnectionRow/index.tsx index b874779b3a..e60772d708 100644 --- a/packages/web/src/components/AppConnectionRow/index.tsx +++ b/packages/web/src/components/AppConnectionRow/index.tsx @@ -45,8 +45,15 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement { const [deleteConnection] = useMutation(DELETE_CONNECTION); const formatMessage = useFormatMessage(); - const { id, key, formattedData, verified, createdAt, flowCount } = - props.connection; + const { + id, + key, + formattedData, + verified, + createdAt, + flowCount, + reconnectable, + } = props.connection; const contextButtonRef = React.useRef(null); const [anchorEl, setAnchorEl] = React.useState(null); @@ -159,7 +166,8 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement { {anchorEl && ( { + try { + const response = await authenticate?.({ + appAuthClientId, + }); + + const connectionId = response?.createConnection.id; + + if (connectionId) { + await refetch(); + + onChange({ + step: { + ...step, + connection: { + id: connectionId, + }, + }, + }); + } + } catch (err) { + // void + } finally { + setShowAddSharedConnectionDialog(false); + } + }; const { name } = substep; @@ -131,6 +178,11 @@ function ChooseConnectionSubstep( return; } + if (connectionId === ADD_SHARED_CONNECTION_VALUE) { + setShowAddSharedConnectionDialog(true); + return; + } + if (connectionId !== step.connection?.id) { onChange({ step: { @@ -216,6 +268,14 @@ function ChooseConnectionSubstep( application={application} /> )} + + {application && showAddSharedConnectionDialog && ( + setShowAddSharedConnectionDialog(false)} + onClientClick={handleClientClick} + /> + )} ); } diff --git a/packages/web/src/components/SplitButton/index.tsx b/packages/web/src/components/SplitButton/index.tsx new file mode 100644 index 0000000000..4ce8d80600 --- /dev/null +++ b/packages/web/src/components/SplitButton/index.tsx @@ -0,0 +1,116 @@ +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Grow from '@mui/material/Grow'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +type SplitButtonProps = { + options: { + key: string; + 'data-test'?: string; + label: React.ReactNode; + to: string; + }[]; + disabled?: boolean; + defaultActionIndex?: number; +}; + +export default function SplitButton(props: SplitButtonProps) { + const { options, disabled, defaultActionIndex = 0 } = props; + const [open, setOpen] = React.useState(false); + const anchorRef = React.useRef(null); + + const multiOptions = options.length > 1; + const selectedOption = options[defaultActionIndex]; + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event: Event) => { + if ( + anchorRef.current && + anchorRef.current.contains(event.target as HTMLElement) + ) { + return; + } + + setOpen(false); + }; + + return ( + + + + + {multiOptions && ( + + )} + + + {multiOptions && ( + + {({ TransitionProps, placement }) => ( + + + + + {options.map((option, index) => ( + + {option.label} + + ))} + + + + + )} + + )} + + ); +} diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index 6d534654fb..f07ab3a140 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -20,13 +20,24 @@ export const APP_PATTERN = '/app/:appKey'; export const APP_CONNECTIONS = (appKey: string) => `/app/${appKey}/connections`; export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections'; -export const APP_ADD_CONNECTION = (appKey: string) => - `/app/${appKey}/connections/add`; +export const APP_ADD_CONNECTION = (appKey: string, shared = false) => + `/app/${appKey}/connections/add?shared=${shared}`; +export const APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID = (appKey: string, appAuthClientId: string) => + `/app/${appKey}/connections/add?appAuthClientId=${appAuthClientId}`; export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add'; export const APP_RECONNECT_CONNECTION = ( appKey: string, - connectionId: string -) => `/app/${appKey}/connections/${connectionId}/reconnect`; + connectionId: string, + appAuthClientId?: string, +) => { + const path = `/app/${appKey}/connections/${connectionId}/reconnect`; + + if (appAuthClientId) { + return `${path}?appAuthClientId=${appAuthClientId}`; + } + + return path; +}; export const APP_RECONNECT_CONNECTION_PATTERN = '/app/:appKey/connections/:connectionId/reconnect'; export const APP_FLOWS = (appKey: string) => `/app/${appKey}/flows`; diff --git a/packages/web/src/graphql/queries/get-app-auth-client.ee.ts b/packages/web/src/graphql/queries/get-app-auth-client.ee.ts new file mode 100644 index 0000000000..fecc656e51 --- /dev/null +++ b/packages/web/src/graphql/queries/get-app-auth-client.ee.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const GET_APP_AUTH_CLIENT = gql` + query GetAppAuthClient($id: String!) { + getAppAuthClient(id: $id) { + id + appConfigId + name + active + } + } +`; + diff --git a/packages/web/src/graphql/queries/get-app-auth-clients.ee.ts b/packages/web/src/graphql/queries/get-app-auth-clients.ee.ts new file mode 100644 index 0000000000..e1d71fcacd --- /dev/null +++ b/packages/web/src/graphql/queries/get-app-auth-clients.ee.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const GET_APP_AUTH_CLIENTS = gql` + query GetAppAuthClients($appKey: String!, $active: Boolean) { + getAppAuthClients(appKey: $appKey, active: $active) { + id + appConfigId + name + active + } + } +`; + diff --git a/packages/web/src/graphql/queries/get-app-config.ee.ts b/packages/web/src/graphql/queries/get-app-config.ee.ts new file mode 100644 index 0000000000..a70e5cffc0 --- /dev/null +++ b/packages/web/src/graphql/queries/get-app-config.ee.ts @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client'; + +export const GET_APP_CONFIG = gql` + query GetAppConfig($key: String!) { + getAppConfig(key: $key) { + id + key + allowCustomConnection + canConnect + canCustomConnect + shared + disabled + } + } +`; + diff --git a/packages/web/src/graphql/queries/get-app-connections.ts b/packages/web/src/graphql/queries/get-app-connections.ts index a2603c0fd5..89cee2e3c8 100644 --- a/packages/web/src/graphql/queries/get-app-connections.ts +++ b/packages/web/src/graphql/queries/get-app-connections.ts @@ -7,6 +7,8 @@ export const GET_APP_CONNECTIONS = gql` connections { id key + reconnectable + appAuthClientId verified flowCount formattedData { diff --git a/packages/web/src/graphql/queries/get-app.ts b/packages/web/src/graphql/queries/get-app.ts index 5b92957192..046d596a72 100644 --- a/packages/web/src/graphql/queries/get-app.ts +++ b/packages/web/src/graphql/queries/get-app.ts @@ -39,6 +39,19 @@ export const GET_APP = gql` } } } + sharedAuthenticationSteps { + type + name + arguments { + name + value + type + properties { + name + value + } + } + } reconnectionSteps { type name @@ -52,6 +65,19 @@ export const GET_APP = gql` } } } + sharedReconnectionSteps { + type + name + arguments { + name + value + type + properties { + name + value + } + } + } } connections { id diff --git a/packages/web/src/graphql/queries/get-apps.ts b/packages/web/src/graphql/queries/get-apps.ts index 9ab62f62a1..62f9ed4ad7 100644 --- a/packages/web/src/graphql/queries/get-apps.ts +++ b/packages/web/src/graphql/queries/get-apps.ts @@ -49,6 +49,19 @@ export const GET_APPS = gql` } } } + sharedAuthenticationSteps { + type + name + arguments { + name + value + type + properties { + name + value + } + } + } reconnectionSteps { type name @@ -62,6 +75,19 @@ export const GET_APPS = gql` } } } + sharedReconnectionSteps { + type + name + arguments { + name + value + type + properties { + name + value + } + } + } } triggers { name diff --git a/packages/web/src/helpers/authenticationSteps.ts b/packages/web/src/helpers/authenticationSteps.ts index afe3b96a63..ef1a1207ee 100644 --- a/packages/web/src/helpers/authenticationSteps.ts +++ b/packages/web/src/helpers/authenticationSteps.ts @@ -57,7 +57,9 @@ const processOpenWithPopup = ( popup?.focus(); const closeCheckIntervalId = setInterval(() => { - if (popup.closed) { + if (!popup) return; + + if (popup?.closed) { clearInterval(closeCheckIntervalId); reject({ message: 'Error occured while verifying credentials!' }); } diff --git a/packages/web/src/hooks/useApp.ts b/packages/web/src/hooks/useApp.ts new file mode 100644 index 0000000000..e4b1ff1484 --- /dev/null +++ b/packages/web/src/hooks/useApp.ts @@ -0,0 +1,26 @@ +import { useQuery } from '@apollo/client'; +import { IApp } from '@automatisch/types'; + +import { GET_APP } from 'graphql/queries/get-app'; + +type QueryResponse = { + getApp: IApp; +} + +export default function useApp(key: string) { + const { + data, + loading + } = useQuery( + GET_APP, + { + variables: { key } + } + ); + const app = data?.getApp; + + return { + app, + loading, + }; +} diff --git a/packages/web/src/hooks/useAppAuthClient.ee.ts b/packages/web/src/hooks/useAppAuthClient.ee.ts new file mode 100644 index 0000000000..32a45b05d6 --- /dev/null +++ b/packages/web/src/hooks/useAppAuthClient.ee.ts @@ -0,0 +1,31 @@ +import { useLazyQuery } from '@apollo/client'; +import { AppConfig } from '@automatisch/types'; +import * as React from 'react'; + +import { GET_APP_AUTH_CLIENT } from 'graphql/queries/get-app-auth-client.ee'; + +type QueryResponse = { + getAppAuthClient: AppConfig; +} + +export default function useAppAuthClient(id: string) { + const [ + getAppAuthClient, + { + data, + loading + } + ] = useLazyQuery(GET_APP_AUTH_CLIENT); + const appAuthClient = data?.getAppAuthClient; + + React.useEffect(function fetchUponId() { + if (!id) return; + + getAppAuthClient({ variables: { id } }); + }, [id]); + + return { + appAuthClient, + loading, + }; +} diff --git a/packages/web/src/hooks/useAppAuthClients.ee.ts b/packages/web/src/hooks/useAppAuthClients.ee.ts new file mode 100644 index 0000000000..411a278162 --- /dev/null +++ b/packages/web/src/hooks/useAppAuthClients.ee.ts @@ -0,0 +1,33 @@ +import { useLazyQuery } from '@apollo/client'; +import { AppAuthClient } from '@automatisch/types'; +import * as React from 'react'; + +import { GET_APP_AUTH_CLIENTS } from 'graphql/queries/get-app-auth-clients.ee'; + +type QueryResponse = { + getAppAuthClients: AppAuthClient[]; +} + +export default function useAppAuthClient(appKey: string) { + const [ + getAppAuthClients, + { + data, + loading + } + ] = useLazyQuery(GET_APP_AUTH_CLIENTS, { + context: { autoSnackbar: false }, + }); + const appAuthClients = data?.getAppAuthClients; + + React.useEffect(function fetchUponAppKey() { + if (!appKey) return; + + getAppAuthClients({ variables: { appKey, active: true } }); + }, [appKey]); + + return { + appAuthClients, + loading, + }; +} diff --git a/packages/web/src/hooks/useAppConfig.ee.ts b/packages/web/src/hooks/useAppConfig.ee.ts new file mode 100644 index 0000000000..a57278d099 --- /dev/null +++ b/packages/web/src/hooks/useAppConfig.ee.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@apollo/client'; +import { AppConfig } from '@automatisch/types'; + +import { GET_APP_CONFIG } from 'graphql/queries/get-app-config.ee'; + +type QueryResponse = { + getAppConfig: AppConfig; +} + +export default function useAppConfig(key: string) { + const { + data, + loading + } = useQuery( + GET_APP_CONFIG, + { + variables: { key }, + context: { autoSnackbar: false } + } + ); + const appConfig = data?.getAppConfig; + + return { + appConfig, + loading, + }; +} diff --git a/packages/web/src/hooks/useAuthenticateApp.ee.ts b/packages/web/src/hooks/useAuthenticateApp.ee.ts new file mode 100644 index 0000000000..3078e9a88d --- /dev/null +++ b/packages/web/src/hooks/useAuthenticateApp.ee.ts @@ -0,0 +1,100 @@ +import { IApp } from '@automatisch/types'; +import * as React from 'react'; + +import { processStep } from 'helpers/authenticationSteps'; +import computeAuthStepVariables from 'helpers/computeAuthStepVariables'; +import useApp from './useApp'; + +type UseAuthenticateAppParams = { + appKey: string; + appAuthClientId?: string; + useShared?: boolean; + connectionId?: string; +} + +type AuthenticatePayload = { + fields?: Record; + appAuthClientId?: string; +} + +function getSteps(auth: IApp['auth'], hasConnection: boolean, useShared: boolean) { + if (hasConnection) { + if (useShared) { + return auth?.sharedReconnectionSteps; + } + + return auth?.reconnectionSteps; + } + + if (useShared) { + return auth?.sharedAuthenticationSteps; + } + + return auth?.authenticationSteps; +} + +export default function useAuthenticateApp(payload: UseAuthenticateAppParams) { + const { + appKey, + appAuthClientId, + connectionId, + useShared = false, + } = payload; + const { app } = useApp(appKey); + const [ + authenticationInProgress, + setAuthenticationInProgress + ] = React.useState(false); + const steps = getSteps(app?.auth, !!connectionId, useShared); + + const authenticate = React.useMemo(() => { + if (!steps?.length) return; + + return async function authenticate(payload: AuthenticatePayload = {}) { + const { + fields, + } = payload; + setAuthenticationInProgress(true); + + const response: Record = { + key: appKey, + appAuthClientId: appAuthClientId || payload.appAuthClientId, + connection: { + id: connectionId, + }, + fields + }; + + let stepIndex = 0; + while (stepIndex < steps?.length) { + const step = steps[stepIndex]; + const variables = computeAuthStepVariables(step.arguments, response); + + try { + const stepResponse = await processStep(step, variables); + + response[step.name] = stepResponse; + } catch (err) { + console.log(err); + throw err; + + setAuthenticationInProgress(false); + break; + } + + stepIndex++; + + if (stepIndex === steps.length) { + return response; + } + + setAuthenticationInProgress(false); + } + } + }, [steps, appKey, appAuthClientId, connectionId]); + + return { + authenticate, + inProgress: authenticationInProgress, + }; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 3df96d32f2..50da2ef539 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -19,6 +19,7 @@ "app.connectionCount": "{count} connections", "app.flowCount": "{count} flows", "app.addConnection": "Add connection", + "app.addCustomConnection": "Add custom connection", "app.reconnectConnection": "Reconnect connection", "app.createFlow": "Create flow", "app.settings": "Settings", @@ -69,6 +70,7 @@ "filterConditions.orContinueIf": "OR continue if…", "chooseConnectionSubstep.continue": "Continue", "chooseConnectionSubstep.addNewConnection": "Add new connection", + "chooseConnectionSubstep.addNewSharedConnection": "Add new shared connection", "chooseConnectionSubstep.chooseConnection": "Choose connection", "flow.createdAt": "created {datetime}", "flow.updatedAt": "updated {datetime}", @@ -209,5 +211,6 @@ "roleList.description": "Description", "permissionSettings.cancel": "Cancel", "permissionSettings.apply": "Apply", - "permissionSettings.title": "Conditions" + "permissionSettings.title": "Conditions", + "appAuthClientsDialog.title": "Choose your authentication client" } diff --git a/packages/web/src/pages/Application/index.tsx b/packages/web/src/pages/Application/index.tsx index d0ea7ccaec..f76a3dab5b 100644 --- a/packages/web/src/pages/Application/index.tsx +++ b/packages/web/src/pages/Application/index.tsx @@ -19,9 +19,11 @@ import Tab from '@mui/material/Tab'; import AddIcon from '@mui/icons-material/Add'; import useFormatMessage from 'hooks/useFormatMessage'; +import useAppConfig from 'hooks/useAppConfig.ee'; import { GET_APP } from 'graphql/queries/get-app'; import * as URLS from 'config/urls'; +import SplitButton from 'components/SplitButton'; import ConditionalIconButton from 'components/ConditionalIconButton'; import AppConnections from 'components/AppConnections'; import AppFlows from 'components/AppFlows'; @@ -35,6 +37,13 @@ type ApplicationParams = { connectionId?: string; }; +type ConnectionOption = { + key: string; + label: string; + 'data-test': string; + to: string; +}; + const ReconnectConnection = (props: any): React.ReactElement => { const { application, onClose } = props; const { connectionId } = useParams() as ApplicationParams; @@ -61,11 +70,36 @@ export default function Application(): React.ReactElement | null { const { appKey } = useParams() as ApplicationParams; const navigate = useNavigate(); const { data, loading } = useQuery(GET_APP, { variables: { key: appKey } }); + const { appConfig } = useAppConfig(appKey); const connectionId = searchParams.get('connectionId') || undefined; const goToApplicationPage = () => navigate('connections'); const app = data?.getApp || {}; + const connectionOptions = React.useMemo(() => { + const shouldHaveCustomConnection = + appConfig?.canConnect && appConfig?.canCustomConnect; + const options: ConnectionOption[] = [ + { + label: formatMessage('app.addConnection'), + key: 'addConnection', + 'data-test': 'add-connection-button', + to: URLS.APP_ADD_CONNECTION(appKey, appConfig?.canConnect), + }, + ]; + + if (shouldHaveCustomConnection) { + options.push({ + label: formatMessage('app.addCustomConnection'), + key: 'addCustomConnection', + 'data-test': 'add-custom-connection-button', + to: URLS.APP_ADD_CONNECTION(appKey), + }); + } + + return options; + }, [appKey, appConfig]); + if (loading) return null; return ( @@ -111,19 +145,14 @@ export default function Application(): React.ReactElement | null { } - data-test="add-connection-button" - > - {formatMessage('app.addConnection')} - + } />