feat: introduce app configs with shared auth clients (#1213)

This commit is contained in:
Ali BARIN
2023-08-16 15:46:43 +02:00
committed by GitHub
parent 25983e046c
commit 3b54b29a99
47 changed files with 1504 additions and 113 deletions

View File

@@ -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,
});
};

View File

@@ -0,0 +1,17 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
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<void> {
return knex.schema.dropTable('app_configs');
}

View File

@@ -0,0 +1,17 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
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<void> {
return knex.schema.dropTable('app_auth_clients');
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
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<void> {
return await knex.schema.table('connections', (table) => {
table.dropColumn('app_auth_client_id');
});
}

View File

@@ -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<void> {
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<void> {
await knex('permissions').where({ subject: 'App' }).delete();
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,13 +1,16 @@
import App from '../../models/app';
import Context from '../../types/express/context';
import { IJSONObject } from '@automatisch/types';
import App from '../../models/app';
import AppConfig from '../../models/app-config';
import Context from '../../types/express/context';
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;
return await context.currentUser.$relatedQuery('connections').insert({
key: params.input.key,
formattedData: params.input.formattedData,
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 createdConnection;
};
export default createConnection;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View File

@@ -67,11 +67,21 @@ function addReconnectionSteps(app: IApp): IApp {
if (hasReconnectionSteps) return app;
if (app.auth.authenticationSteps) {
const updatedSteps = replaceCreateConnectionsWithUpdate(
app.auth.authenticationSteps
);
app.auth.reconnectionSteps = [resetConnectionStep, ...updatedSteps];
}
if (app.auth.sharedAuthenticationSteps) {
const updatedStepsWithEmbeddedDefaults = replaceCreateConnectionsWithUpdate(
app.auth.sharedAuthenticationSteps
);
app.auth.sharedReconnectionSteps = [resetConnectionStep, ...updatedStepsWithEmbeddedDefaults];
}
return app;
}

View File

@@ -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<void> {
await super.$beforeInsert(queryContext);
this.encryptData();
}
async $beforeUpdate(
opt: ModelOptions,
queryContext: QueryContext
): Promise<void> {
await super.$beforeUpdate(opt, queryContext);
this.encryptData();
}
async $afterFind(): Promise<void> {
this.decryptData();
}
}
export default AppAuthClient;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;

View File

@@ -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<IJSONObject | null>(null);
const [inProgress, setInProgress] = React.useState(false);
const hasConnection = Boolean(connectionId);
const steps = hasConnection
? auth?.reconnectionSteps
: auth?.authenticationSteps;
const useShared = searchParams.get('shared') === 'true';
const appAuthClientId = searchParams.get('appAuthClientId') || undefined;
const { authenticate } = useAuthenticateApp({
appKey: key,
connectionId,
appAuthClientId,
useShared: !!appAuthClientId,
});
React.useEffect(() => {
React.useEffect(function relayProviderData() {
if (window.opener) {
window.opener.postMessage({
source: 'automatisch',
@@ -48,51 +53,61 @@ export default function AddAppConnection(
}
}, []);
const submitHandler: SubmitHandler<FieldValues> = React.useCallback(
async (data) => {
if (!steps) return;
React.useEffect(
function initiateSharedAuthenticationForGivenAuthClient() {
if (!appAuthClientId) return;
if (!authenticate) return;
setInProgress(true);
setError(null);
const asyncAuthenticate = async () => {
await authenticate();
const response: Response = {
key,
connection: {
id: connectionId,
},
fields: data,
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]
);
const handleClientClick = (appAuthClientId: string) =>
navigate(URLS.APP_ADD_CONNECTION_WITH_AUTH_CLIENT_ID(key, appAuthClientId));
const handleAuthClientsDialogClose = () =>
navigate(URLS.APP_CONNECTIONS(key));
const submitHandler: SubmitHandler<FieldValues> = React.useCallback(
async (data) => {
if (!authenticate) return;
setInProgress(true);
try {
const stepResponse = await processStep(step, variables);
response[step.name] = stepResponse;
const response = await authenticate({
fields: data,
});
onClose(response as Record<string, unknown>);
} catch (err) {
const error = err as IJSONObject;
console.log(error);
setError((error.graphQLErrors as IJSONObject[])?.[0]);
} finally {
setInProgress(false);
break;
}
stepIndex++;
if (stepIndex === steps.length) {
onClose(response);
}
}
setInProgress(false);
},
[connectionId, key, steps, onClose]
[authenticate]
);
if (useShared)
return (
<AppAuthClientsDialog
appKey={key}
onClose={handleAuthClientsDialogClose}
onClientClick={handleClientClick}
/>
);
if (appAuthClientId) return <React.Fragment />;
return (
<Dialog open={true} onClose={onClose} data-test="add-app-connection-dialog">
<DialogTitle>

View File

@@ -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 <React.Fragment />;
return (
<Dialog onClose={onClose} open={true}>
<DialogTitle>{formatMessage('appAuthClientsDialog.title')}</DialogTitle>
<List sx={{ pt: 0 }}>
{appAuthClients.map((appAuthClient) => (
<ListItem disableGutters key={appAuthClient.id}>
<ListItemButton onClick={() => onClientClick(appAuthClient.id)}>
<ListItemText primary={appAuthClient.name} />
</ListItemButton>
</ListItem>
))}
</List>
</Dialog>
);
}

View File

@@ -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(
>
<MenuItem
component={Link}
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connectionId)}
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
onClick={createActionHandler({ type: 'viewFlows' })}
>
{formatMessage('connection.viewFlows')}
@@ -57,7 +66,12 @@ export default function ContextMenu(
<MenuItem
component={Link}
to={URLS.APP_RECONNECT_CONNECTION(appKey, connectionId)}
disabled={disableReconnection}
to={URLS.APP_RECONNECT_CONNECTION(
appKey,
connection.id,
connection.appAuthClientId
)}
onClick={createActionHandler({ type: 'reconnect' })}
>
{formatMessage('connection.reconnect')}

View File

@@ -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<SVGSVGElement | null>(null);
const [anchorEl, setAnchorEl] = React.useState<SVGSVGElement | null>(null);
@@ -159,7 +166,8 @@ function AppConnectionRow(props: AppConnectionRowProps): React.ReactElement {
{anchorEl && (
<ConnectionContextMenu
appKey={key}
connectionId={id}
connection={props.connection}
disableReconnection={!reconnectable}
onClose={handleClose}
onMenuItemClick={onContextMenuAction}
anchorEl={anchorEl}

View File

@@ -1,18 +1,21 @@
import * as React from 'react';
import { useQuery, useLazyQuery } from '@apollo/client';
import TextField from '@mui/material/TextField';
import { useLazyQuery, useQuery } from '@apollo/client';
import Autocomplete from '@mui/material/Autocomplete';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
import ListItem from '@mui/material/ListItem';
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';
import * as React from 'react';
import type { IApp, IConnection, IStep, ISubstep } from '@automatisch/types';
import useFormatMessage from 'hooks/useFormatMessage';
import { EditorContext } from 'contexts/Editor';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import AddAppConnection from 'components/AddAppConnection';
import AppAuthClientsDialog from 'components/AppAuthClientsDialog/index.ee';
import FlowSubstepTitle from 'components/FlowSubstepTitle';
import useAppConfig from 'hooks/useAppConfig.ee';
import { EditorContext } from 'contexts/Editor';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
import { TEST_CONNECTION } from 'graphql/queries/test-connection';
import useAuthenticateApp from 'hooks/useAuthenticateApp.ee';
import useFormatMessage from 'hooks/useFormatMessage';
type ChooseConnectionSubstepProps = {
application: IApp;
@@ -26,6 +29,7 @@ type ChooseConnectionSubstepProps = {
};
const ADD_CONNECTION_VALUE = 'ADD_CONNECTION';
const ADD_SHARED_CONNECTION_VALUE = 'ADD_SHARED_CONNECTION';
const optionGenerator = (
connection: IConnection
@@ -53,11 +57,18 @@ function ChooseConnectionSubstep(
const { connection, appKey } = step;
const formatMessage = useFormatMessage();
const editorContext = React.useContext(EditorContext);
const { authenticate } = useAuthenticateApp({
appKey: application.key,
useShared: true,
});
const [showAddConnectionDialog, setShowAddConnectionDialog] =
React.useState(false);
const [showAddSharedConnectionDialog, setShowAddSharedConnectionDialog] =
React.useState(false);
const { data, loading, refetch } = useQuery(GET_APP_CONNECTIONS, {
variables: { key: appKey },
});
const { appConfig } = useAppConfig(application.key);
// TODO: show detailed error when connection test/verification fails
const [
testConnection,
@@ -86,13 +97,49 @@ function ChooseConnectionSubstep(
optionGenerator(connection)
) || [];
if (!appConfig || appConfig.canCustomConnect) {
options.push({
label: formatMessage('chooseConnectionSubstep.addNewConnection'),
value: ADD_CONNECTION_VALUE,
});
}
if (appConfig?.canConnect) {
options.push({
label: formatMessage('chooseConnectionSubstep.addNewSharedConnection'),
value: ADD_SHARED_CONNECTION_VALUE,
});
}
return options;
}, [data, formatMessage]);
}, [data, formatMessage, appConfig]);
const handleClientClick = async (appAuthClientId: string) => {
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 && (
<AppAuthClientsDialog
appKey={application.key}
onClose={() => setShowAddSharedConnectionDialog(false)}
onClientClick={handleClientClick}
/>
)}
</React.Fragment>
);
}

View File

@@ -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<HTMLDivElement>(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 (
<React.Fragment>
<ButtonGroup
variant="contained"
ref={anchorRef}
aria-label="split button"
disabled={disabled}
>
<Button
size="large"
data-test={selectedOption['data-test']}
component={Link}
to={selectedOption.to}
sx={{
// Link component causes style loss in ButtonGroup
borderRadius: 0,
borderRight: '1px solid #bdbdbd',
}}
>
{selectedOption.label}
</Button>
{multiOptions && (
<Button size="small" onClick={handleToggle} sx={{ borderRadius: 0 }}>
<ArrowDropDownIcon />
</Button>
)}
</ButtonGroup>
{multiOptions && (
<Popper
sx={{
zIndex: 1,
}}
open={open}
anchorEl={anchorRef.current}
transition
disablePortal
>
{({ TransitionProps, placement }) => (
<Grow
{...TransitionProps}
style={{
transformOrigin:
placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper>
<ClickAwayListener onClickAway={handleClose}>
<MenuList autoFocusItem>
{options.map((option, index) => (
<MenuItem
key={option.key}
selected={index === defaultActionIndex}
component={Link}
to={option.to}
>
{option.label}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
)}
</React.Fragment>
);
}

View File

@@ -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`;

View File

@@ -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
}
}
`;

View File

@@ -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
}
}
`;

View File

@@ -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
}
}
`;

View File

@@ -7,6 +7,8 @@ export const GET_APP_CONNECTIONS = gql`
connections {
id
key
reconnectable
appAuthClientId
verified
flowCount
formattedData {

View File

@@ -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

View File

@@ -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

View File

@@ -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!' });
}

View File

@@ -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<QueryResponse>(
GET_APP,
{
variables: { key }
}
);
const app = data?.getApp;
return {
app,
loading,
};
}

View File

@@ -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<QueryResponse>(GET_APP_AUTH_CLIENT);
const appAuthClient = data?.getAppAuthClient;
React.useEffect(function fetchUponId() {
if (!id) return;
getAppAuthClient({ variables: { id } });
}, [id]);
return {
appAuthClient,
loading,
};
}

View File

@@ -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<QueryResponse>(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,
};
}

View File

@@ -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<QueryResponse>(
GET_APP_CONFIG,
{
variables: { key },
context: { autoSnackbar: false }
}
);
const appConfig = data?.getAppConfig;
return {
appConfig,
loading,
};
}

View File

@@ -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<string, string>;
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<string, any> = {
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,
};
}

View File

@@ -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"
}

View File

@@ -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 {
<Route
path={`${URLS.CONNECTIONS}/*`}
element={
<ConditionalIconButton
type="submit"
variant="contained"
color="primary"
size="large"
component={Link}
to={URLS.APP_ADD_CONNECTION(appKey)}
fullWidth
icon={<AddIcon />}
data-test="add-connection-button"
>
{formatMessage('app.addConnection')}
</ConditionalIconButton>
<SplitButton
disabled={
appConfig &&
!appConfig?.canConnect &&
!appConfig?.canCustomConnect
}
options={connectionOptions}
/>
}
/>
</Routes>