Compare commits

...

23 Commits

Author SHA1 Message Date
Faruk AYDIN
934a525898 feat: Add updateSamlAuthProvider graphQL mutation 2023-08-07 16:14:42 +02:00
Ömer Faruk Aydın
40e10cc270 Merge pull request #1196 from automatisch/remove-role-check
chore: Warn user about default role of SAML before deleting role
2023-08-07 15:31:13 +02:00
Ömer Faruk Aydın
41db227eb3 Merge pull request #1195 from automatisch/saml-configuration-create
feat: Add createSamlAuthProvider graphQL mutation
2023-08-07 15:30:52 +02:00
Faruk AYDIN
43eea965c5 chore: Warn user about default role of SAML before deleting role 2023-08-07 15:21:32 +02:00
Faruk AYDIN
8101c9f0bc feat: Add createSamlAuthProvider graphQL mutation 2023-08-07 15:02:25 +02:00
Rıdvan Akca
b4cda90338 feat(auth): add feedback state for user and role management (#1191) 2023-08-07 11:08:29 +02:00
Ali BARIN
7ca37c412e fix: clone base db queries 2023-08-03 21:11:59 +02:00
Ali BARIN
e4e3356dc9 fix: add fallback for api url 2023-08-03 20:19:02 +02:00
Ali BARIN
0deaa03218 feat(auth): add user and role management 2023-08-03 19:39:48 +02:00
Ali BARIN
a7104c41a2 feat(sso): introduce authentication with SAML 2023-08-03 19:39:48 +02:00
Ali BARIN
5176b8c322 feat(authorization): add update connection checks 2023-08-03 19:39:48 +02:00
Ali BARIN
c37c70446d feat(authorization): add read connection checks 2023-08-03 19:39:48 +02:00
Ali BARIN
63abc8a2c8 feat(authorization): add delete flow checks 2023-08-03 19:39:48 +02:00
Ali BARIN
ba5c038e3b feat(authorization): add create flow checks 2023-08-03 19:39:48 +02:00
Ali BARIN
a6669415f5 feat(authorization): add delete connection checks 2023-08-03 19:39:48 +02:00
Ali BARIN
4086fad867 feat(authorization): add create connection checks 2023-08-03 19:39:48 +02:00
Ali BARIN
8a71c13078 feat(authorization): add read execution checks 2023-08-03 19:39:48 +02:00
Ali BARIN
5d77f64e76 feat(authorization): add update flow checks 2023-08-03 19:39:48 +02:00
Ali BARIN
0d092b977f feat(authorization): add read flow checks 2023-08-03 19:39:48 +02:00
Ali BARIN
69582ff83d feat: introduce role based access control 2023-08-03 19:39:48 +02:00
Ömer Faruk Aydın
a5c7da331a Merge pull request #1190 from automatisch/docs-available-apps
docs: Remove warning from available apps
2023-08-02 17:31:07 +02:00
Faruk AYDIN
8e842296b7 docs: Remove warning from available apps 2023-08-02 17:19:53 +02:00
Ömer Faruk Aydın
7db14d1df7 Merge pull request #1189 from automatisch/release/0.8.0
Release v0.8.0
2023-08-02 15:48:33 +02:00
150 changed files with 4200 additions and 307 deletions

View File

@@ -33,7 +33,32 @@ services:
- '6379:6379' - '6379:6379'
expose: expose:
- 6379 - 6379
keycloak:
image: quay.io/keycloak/keycloak:21.1
restart: always
container_name: keycloak
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
- KC_DB=postgres
- KC_DB_URL_HOST=postgres
- KC_DB_URL_DATABASE=keycloak
- KC_DB_USERNAME=automatisch_user
- KC_DB_PASSWORD=automatisch_password
- KC_HEALTH_ENABLED=true
ports:
- "8080:8080"
command: start-dev
depends_on:
- postgres
healthcheck:
test: "curl -f http://localhost:8080/health/ready || exit 1"
volumes:
- keycloak:/opt/keycloak/data/
expose:
- 8080
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
keycloak:

View File

@@ -2,18 +2,33 @@ import appConfig from '../../src/config/app';
import logger from '../../src/helpers/logger'; import logger from '../../src/helpers/logger';
import client from './client'; import client from './client';
import User from '../../src/models/user'; import User from '../../src/models/user';
import Role from '../../src/models/role';
import '../../src/config/orm'; import '../../src/config/orm';
async function fetchAdminRole() {
const role = await Role
.query()
.where({
key: 'admin'
})
.limit(1)
.first();
return role;
}
export async function createUser( export async function createUser(
email = 'user@automatisch.io', email = 'user@automatisch.io',
password = 'sample' password = 'sample'
) { ) {
const UNIQUE_VIOLATION_CODE = '23505'; const UNIQUE_VIOLATION_CODE = '23505';
const role = await fetchAdminRole();
const userParams = { const userParams = {
email, email,
password, password,
fullName: 'Initial admin', fullName: 'Initial admin',
role: 'admin', roleId: role.id,
}; };
try { try {

View File

@@ -12,6 +12,7 @@ const knexConfig = {
database: appConfig.postgresDatabase, database: appConfig.postgresDatabase,
ssl: appConfig.postgresEnableSsl, ssl: appConfig.postgresEnableSsl,
}, },
asyncStackTraces: appConfig.isDev,
searchPath: [appConfig.postgresSchema], searchPath: [appConfig.postgresSchema],
pool: { min: 0, max: 20 }, pool: { min: 0, max: 20 },
migrations: { migrations: {

View File

@@ -4,7 +4,7 @@
"license": "See LICENSE file", "license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.", "description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"scripts": { "scripts": {
"dev": "ts-node-dev --exit-child src/server.ts", "dev": "ts-node-dev --watch 'src/graphql/schema.graphql' --exit-child src/server.ts",
"worker": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/worker.ts", "worker": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/worker.ts",
"build": "tsc && yarn copy-statics", "build": "tsc && yarn copy-statics",
"build:watch": "nodemon --watch 'src/**/*.ts' --watch 'bin/**/*.ts' --exec yarn build --ext ts", "build:watch": "nodemon --watch 'src/**/*.ts' --watch 'bin/**/*.ts' --exec yarn build --ext ts",
@@ -24,12 +24,15 @@
"dependencies": { "dependencies": {
"@automatisch/web": "^0.8.0", "@automatisch/web": "^0.8.0",
"@bull-board/express": "^3.10.1", "@bull-board/express": "^3.10.1",
"@casl/ability": "^6.5.0",
"@graphql-tools/graphql-file-loader": "^7.3.4", "@graphql-tools/graphql-file-loader": "^7.3.4",
"@graphql-tools/load": "^7.5.2", "@graphql-tools/load": "^7.5.2",
"@node-saml/passport-saml": "^4.0.4",
"@rudderstack/rudder-sdk-node": "^1.1.2", "@rudderstack/rudder-sdk-node": "^1.1.2",
"@sentry/node": "^7.42.0", "@sentry/node": "^7.42.0",
"@sentry/tracing": "^7.42.0", "@sentry/tracing": "^7.42.0",
"@types/luxon": "^2.3.1", "@types/luxon": "^2.3.1",
"@types/passport": "^1.0.12",
"@types/xmlrpc": "^1.3.7", "@types/xmlrpc": "^1.3.7",
"ajv-formats": "^2.1.1", "ajv-formats": "^2.1.1",
"axios": "0.24.0", "axios": "0.24.0",
@@ -62,6 +65,7 @@
"nodemailer": "6.7.0", "nodemailer": "6.7.0",
"oauth-1.0a": "^2.2.6", "oauth-1.0a": "^2.2.6",
"objection": "^3.0.0", "objection": "^3.0.0",
"passport": "^0.6.0",
"pg": "^8.7.1", "pg": "^8.7.1",
"php-serialize": "^4.0.2", "php-serialize": "^4.0.2",
"stripe": "^11.13.0", "stripe": "^11.13.0",

View File

@@ -17,6 +17,7 @@ import {
} from './helpers/create-bull-board-handler'; } from './helpers/create-bull-board-handler';
import injectBullBoardHandler from './helpers/inject-bull-board-handler'; import injectBullBoardHandler from './helpers/inject-bull-board-handler';
import router from './routes'; import router from './routes';
import configurePassport from './helpers/passport';
createBullBoardHandler(serverAdapter); createBullBoardHandler(serverAdapter);
@@ -50,6 +51,9 @@ app.use(
}) })
); );
app.use(cors(corsOptions)); app.use(cors(corsOptions));
configurePassport(app);
app.use('/', router); app.use('/', router);
webUIHandler(app); webUIHandler(app);

View File

@@ -0,0 +1,46 @@
import { Knex } from 'knex';
import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('roles', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('name').notNullable();
table.string('key').notNullable();
table.string('description');
table.timestamps(true, true);
});
const uniqueUserRoles = await knex('users')
.select('role')
.groupBy('role');
let shouldCreateAdminRole = true;
for (const { role } of uniqueUserRoles) {
// skip empty roles
if (!role) continue;
const lowerCaseRole = lowerCase(role);
if (lowerCaseRole === 'admin') {
shouldCreateAdminRole = false;
}
await knex('roles').insert({
name: capitalize(role),
key: lowerCaseRole,
});
}
if (shouldCreateAdminRole) {
await knex('roles').insert({
name: 'Admin',
key: 'admin',
});
}
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('roles');
}

View File

@@ -0,0 +1,48 @@
import { Knex } from 'knex';
const getPermissionForRole = (roleId: string, subject: string, actions: string[], conditions: string[] = []) => actions
.map(action => ({
role_id: roleId,
subject,
action,
conditions,
}));
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('role_id').references('id').inTable('roles');
table.string('action').notNullable();
table.string('subject').notNullable();
table.jsonb('conditions').notNullable().defaultTo([]);
table.timestamps(true, true);
});
const roles = await knex('roles').select(['id', 'key']) as { id: string, key: string }[];
for (const role of roles) {
// `admin` role should have no conditions unlike others by default
const isAdmin = role.key === 'admin';
const roleConditions = isAdmin ? [] : ['isCreator'];
// default permissions
await knex('permissions').insert([
...getPermissionForRole(role.id, 'Connection', ['create', 'read', 'delete', 'update'], roleConditions),
...getPermissionForRole(role.id, 'Execution', ['read'], roleConditions),
...getPermissionForRole(role.id, 'Flow', ['create', 'delete', 'publish', 'read', 'update'], roleConditions),
]);
// admin specific permission
if (isAdmin) {
await knex('permissions').insert([
...getPermissionForRole(role.id, 'User', ['create', 'read', 'delete', 'update']),
...getPermissionForRole(role.id, 'Role', ['create', 'read', 'delete', 'update']),
]);
}
}
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('permissions');
}

View File

@@ -0,0 +1,29 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('users', async (table) => {
table.uuid('role_id').references('id').inTable('roles');
});
const theRole = await knex('roles').select('id').limit(1).first();
const roles = await knex('roles').select('id', 'key');
for (const role of roles) {
await knex('users')
.where({
role: role.key
})
.update({
role_id: role.id
});
}
// backfill not-migratables
await knex('users').whereNull('role_id').update({ role_id: theRole.id });
}
export async function down(knex: Knex): Promise<void> {
return await knex.schema.table('users', (table) => {
table.dropColumn('role_id');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.table('users', async (table) => {
table.dropColumn('role');
});
}
export async function down(knex: Knex): Promise<void> {
return await knex.schema.table('users', (table) => {
table.string('role').defaultTo('user');
});
}

View File

@@ -0,0 +1,24 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('saml_auth_providers', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.string('name').notNullable();
table.text('certificate').notNullable();
table.string('signature_algorithm').notNullable();
table.string('issuer').notNullable();
table.text('entry_point').notNullable();
table.text('firstname_attribute_name').notNullable();
table.text('surname_attribute_name').notNullable();
table.text('email_attribute_name').notNullable();
table.text('role_attribute_name').notNullable();
table.uuid('default_role_id').references('id').inTable('roles');
table.boolean('active').defaultTo(false);
table.timestamps(true, true);
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('saml_auth_providers');
}

View File

@@ -0,0 +1,17 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.createTable('identities', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('user_id').references('id').inTable('users');
table.string('remote_id').notNullable();
table.string('provider_id').notNullable();
table.string('provider_type').notNullable();
table.timestamps(true, true);
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('identities');
}

View File

@@ -0,0 +1,11 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return await knex.schema.alterTable('users', (table) => {
table.string('password').nullable().alter();
});
}
export async function down(): Promise<void> {
// void
}

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, 'SamlAuthProvider', [
'create',
'read',
'delete',
'update',
])
);
}
export async function down(knex: Knex): Promise<void> {
await knex('permissions').where({ subject: 'SamlAuthProvider' }).delete();
}

View File

@@ -1,47 +1,63 @@
import createConnection from './mutations/create-connection'; import createConnection from './mutations/create-connection';
import generateAuthUrl from './mutations/generate-auth-url';
import updateConnection from './mutations/update-connection';
import resetConnection from './mutations/reset-connection';
import verifyConnection from './mutations/verify-connection';
import deleteConnection from './mutations/delete-connection';
import createFlow from './mutations/create-flow'; import createFlow from './mutations/create-flow';
import createRole from './mutations/create-role.ee';
import createStep from './mutations/create-step';
import createUser from './mutations/create-user.ee';
import deleteConnection from './mutations/delete-connection';
import deleteCurrentUser from './mutations/delete-current-user.ee';
import deleteFlow from './mutations/delete-flow';
import deleteRole from './mutations/delete-role.ee';
import deleteStep from './mutations/delete-step';
import deleteUser from './mutations/delete-user.ee';
import duplicateFlow from './mutations/duplicate-flow';
import executeFlow from './mutations/execute-flow';
import forgotPassword from './mutations/forgot-password.ee';
import generateAuthUrl from './mutations/generate-auth-url';
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 updateConnection from './mutations/update-connection';
import updateCurrentUser from './mutations/update-current-user';
import updateFlow from './mutations/update-flow'; import updateFlow from './mutations/update-flow';
import updateFlowStatus from './mutations/update-flow-status'; import updateFlowStatus from './mutations/update-flow-status';
import executeFlow from './mutations/execute-flow'; import updateRole from './mutations/update-role.ee';
import deleteFlow from './mutations/delete-flow';
import duplicateFlow from './mutations/duplicate-flow';
import createStep from './mutations/create-step';
import updateStep from './mutations/update-step'; import updateStep from './mutations/update-step';
import deleteStep from './mutations/delete-step'; import updateUser from './mutations/update-user.ee';
import createUser from './mutations/create-user.ee'; import verifyConnection from './mutations/verify-connection';
import deleteUser from './mutations/delete-user.ee'; import createSamlAuthProvider from './mutations/create-saml-auth-provider.ee';
import updateUser from './mutations/update-user'; import updateSamlAuthProvider from './mutations/update-saml-auth-provider.ee';
import forgotPassword from './mutations/forgot-password.ee';
import resetPassword from './mutations/reset-password.ee';
import login from './mutations/login';
const mutationResolvers = { const mutationResolvers = {
createConnection, createConnection,
generateAuthUrl,
updateConnection,
resetConnection,
verifyConnection,
deleteConnection,
createFlow, createFlow,
createRole,
createStep,
createUser,
deleteConnection,
deleteCurrentUser,
deleteFlow,
deleteRole,
deleteStep,
deleteUser,
duplicateFlow,
executeFlow,
forgotPassword,
generateAuthUrl,
login,
registerUser,
resetConnection,
resetPassword,
updateConnection,
updateCurrentUser,
updateUser,
updateFlow, updateFlow,
updateFlowStatus, updateFlowStatus,
executeFlow, updateRole,
deleteFlow,
duplicateFlow,
createStep,
updateStep, updateStep,
deleteStep, verifyConnection,
createUser, createSamlAuthProvider,
deleteUser, updateSamlAuthProvider,
updateUser,
forgotPassword,
resetPassword,
login,
}; };
export default mutationResolvers; export default mutationResolvers;

View File

@@ -13,6 +13,8 @@ const createConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Connection');
await App.findOneByKey(params.input.key); await App.findOneByKey(params.input.key);
return await context.currentUser.$relatedQuery('connections').insert({ return await context.currentUser.$relatedQuery('connections').insert({

View File

@@ -14,6 +14,8 @@ const createFlow = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Flow');
const connectionId = params?.input?.connectionId; const connectionId = params?.input?.connectionId;
const appKey = params?.input?.triggerAppKey; const appKey = params?.input?.triggerAppKey;

View File

@@ -0,0 +1,34 @@
import kebabCase from 'lodash/kebabCase';
import Permission from '../../models/permission';
import Role from '../../models/role';
import Context from '../../types/express/context';
type Params = {
input: {
name: string;
description: string;
permissions: Permission[];
};
};
const createRole = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('create', 'Role');
const { name, description, permissions } = params.input;
const key = kebabCase(name);
const existingRole = await Role.query().findOne({ key });
if (existingRole) {
throw new Error('Role already exists!');
}
return await Role.query().insertGraph({
key,
name,
description,
permissions,
}, { relate: ['permissions'] }).returning('*');
};
export default createRole;

View File

@@ -0,0 +1,54 @@
import type { SamlConfig } from '@node-saml/passport-saml';
import SamlAuthProvider from '../../models/saml-auth-provider.ee';
import Context from '../../types/express/context';
type Params = {
input: {
name: string;
certificate: string;
signatureAlgorithm: SamlConfig['signatureAlgorithm'];
issuer: string;
entryPoint: string;
firstnameAttributeName: string;
surnameAttributeName: string;
emailAttributeName: string;
roleAttributeName: string;
defaultRoleId: string;
active: boolean;
};
};
const createSamlAuthProvider = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('create', 'SamlAuthProvider');
const samlAuthProviderPayload: Partial<SamlAuthProvider> = {
...params.input,
};
const existingSamlAuthProvider = await SamlAuthProvider.query()
.limit(1)
.first();
let samlAuthProvider: SamlAuthProvider;
if (!existingSamlAuthProvider) {
samlAuthProvider = await SamlAuthProvider.query().insert(
samlAuthProviderPayload
);
return samlAuthProvider;
}
samlAuthProvider = await SamlAuthProvider.query().patchAndFetchById(
existingSamlAuthProvider.id,
samlAuthProviderPayload
);
return samlAuthProvider;
};
export default createSamlAuthProvider;

View File

@@ -22,6 +22,8 @@ const createStep = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('update', 'Flow');
const { input } = params; const { input } = params;
if (input.appKey && input.key) { if (input.appKey && input.key) {

View File

@@ -1,14 +1,21 @@
import User from '../../models/user'; import User from '../../models/user';
import Role from '../../models/role';
import Context from '../../types/express/context';
type Params = { type Params = {
input: { input: {
fullName: string; fullName: string;
email: string; email: string;
password: string; password: string;
role: {
id: string;
};
}; };
}; };
const createUser = async (_parent: unknown, params: Params) => { const createUser = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('create', 'User');
const { fullName, email, password } = params.input; const { fullName, email, password } = params.input;
const existingUser = await User.query().findOne({ email }); const existingUser = await User.query().findOne({ email });
@@ -17,12 +24,23 @@ const createUser = async (_parent: unknown, params: Params) => {
throw new Error('User already exists!'); throw new Error('User already exists!');
} }
const user = await User.query().insert({ const userPayload: Partial<User> = {
fullName, fullName,
email, email,
password, password,
role: 'user', };
});
try {
context.currentUser.can('update', 'Role');
userPayload.roleId = params.input.role.id;
} catch {
// void
const role = await Role.query().findOne({ key: 'user' });
userPayload.roleId = role.id;
}
const user = await User.query().insert(userPayload);
return user; return user;
}; };

View File

@@ -11,6 +11,8 @@ const deleteConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('delete', 'Connection');
await context.currentUser await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.delete() .delete()

View File

@@ -0,0 +1,22 @@
import { Duration } from 'luxon';
import Context from '../../types/express/context';
import deleteUserQueue from '../../queues/delete-user.ee';
const deleteCurrentUser = async (_parent: unknown, params: never, context: Context) => {
const id = context.currentUser.id;
await context.currentUser.$query().delete();
const jobName = `Delete user - ${id}`;
const jobPayload = { id };
const millisecondsFor30Days = Duration.fromObject({ days: 30 }).toMillis();
const jobOptions = {
delay: millisecondsFor30Days
};
await deleteUserQueue.add(jobName, jobPayload, jobOptions);
return true;
};
export default deleteCurrentUser;

View File

@@ -13,6 +13,8 @@ const deleteFlow = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('delete', 'Flow');
const flow = await context.currentUser const flow = await context.currentUser
.$relatedQuery('flows') .$relatedQuery('flows')
.findOne({ .findOne({

View File

@@ -0,0 +1,47 @@
import Role from '../../models/role';
import SamlAuthProvider from '../../models/saml-auth-provider.ee';
import Context from '../../types/express/context';
type Params = {
input: {
id: string;
};
};
const deleteRole = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('delete', 'Role');
const role = await Role.query().findById(params.input.id).throwIfNotFound();
const count = await role.$relatedQuery('users').resultSize();
if (count > 0) {
throw new Error('All users must be migrated away from the role!');
}
if (role.isAdmin) {
throw new Error('Admin role cannot be deleted!');
}
const samlAuthProviderUsingDefaultRole = await SamlAuthProvider.query()
.where({ default_role_id: role.id })
.limit(1)
.first();
if (samlAuthProviderUsingDefaultRole) {
throw new Error(
'You need to change the default role in the SAML configuration before deleting this role.'
);
}
// delete permissions first
await role.$relatedQuery('permissions').delete();
await role.$query().delete();
return true;
};
export default deleteRole;

View File

@@ -11,6 +11,8 @@ const deleteStep = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('update', 'Flow');
const step = await context.currentUser const step = await context.currentUser
.$relatedQuery('steps') .$relatedQuery('steps')
.withGraphFetched('flow') .withGraphFetched('flow')

View File

@@ -1,11 +1,24 @@
import Context from '../../types/express/context';
import deleteUserQueue from '../../queues/delete-user.ee';
import { Duration } from 'luxon'; import { Duration } from 'luxon';
import Context from '../../types/express/context';
import User from '../../models/user';
import deleteUserQueue from '../../queues/delete-user.ee';
const deleteUser = async (_parent: unknown, params: never, context: Context) => { type Params = {
const id = context.currentUser.id; input: {
id: string;
};
};
await context.currentUser.$query().delete(); const deleteUser = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('delete', 'User');
const id = params.input.id;
await User.query().deleteById(id);
const jobName = `Delete user - ${id}`; const jobName = `Delete user - ${id}`;
const jobPayload = { id }; const jobPayload = { id };

View File

@@ -53,6 +53,8 @@ const duplicateFlow = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Flow');
const flow = await context.currentUser const flow = await context.currentUser
.$relatedQuery('flows') .$relatedQuery('flows')
.withGraphJoined('[steps]') .withGraphJoined('[steps]')

View File

@@ -12,6 +12,8 @@ const executeFlow = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('update', 'Flow');
const { stepId } = params.input; const { stepId } = params.input;
const untilStep = await context.currentUser const untilStep = await context.currentUser

View File

@@ -13,6 +13,8 @@ const generateAuthUrl = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Connection');
const connection = await context.currentUser const connection = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.findOne({ .findOne({

View File

@@ -0,0 +1,33 @@
import User from '../../models/user';
import Role from '../../models/role';
type Params = {
input: {
fullName: string;
email: string;
password: string;
};
};
const registerUser = async (_parent: unknown, params: Params) => {
const { fullName, email, password } = params.input;
const existingUser = await User.query().findOne({ email });
if (existingUser) {
throw new Error('User already exists!');
}
const role = await Role.query().findOne({ key: 'user' });
const user = await User.query().insert({
fullName,
email,
password,
roleId: role.id,
});
return user;
};
export default registerUser;

View File

@@ -11,6 +11,8 @@ const resetConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Connection');
let connection = await context.currentUser let connection = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.findOne({ .findOne({

View File

@@ -13,6 +13,8 @@ const updateConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Connection');
let connection = await context.currentUser let connection = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.findOne({ .findOne({

View File

@@ -8,7 +8,7 @@ type Params = {
}; };
}; };
const updateUser = async ( const updateCurrentUser = async (
_parent: unknown, _parent: unknown,
params: Params, params: Params,
context: Context context: Context
@@ -22,4 +22,4 @@ const updateUser = async (
return user; return user;
}; };
export default updateUser; export default updateCurrentUser;

View File

@@ -18,6 +18,8 @@ const updateFlowStatus = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('publish', 'Flow');
let flow = await context.currentUser let flow = await context.currentUser
.$relatedQuery('flows') .$relatedQuery('flows')
.findOne({ .findOne({
@@ -55,7 +57,7 @@ const updateFlowStatus = async (
} else { } else {
if (newActiveValue) { if (newActiveValue) {
flow = await flow.$query().patchAndFetch({ flow = await flow.$query().patchAndFetch({
published_at: new Date().toISOString(), publishedAt: new Date().toISOString(),
}); });
const jobName = `${JOB_NAME}-${flow.id}`; const jobName = `${JOB_NAME}-${flow.id}`;
@@ -78,7 +80,10 @@ const updateFlowStatus = async (
} }
} }
flow = await flow.$query().withGraphFetched('steps').patchAndFetch({ flow = await flow
.$query()
.withGraphFetched('steps')
.patchAndFetch({
active: newActiveValue, active: newActiveValue,
}); });

View File

@@ -12,6 +12,8 @@ const updateFlow = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('update', 'Flow');
let flow = await context.currentUser let flow = await context.currentUser
.$relatedQuery('flows') .$relatedQuery('flows')
.findOne({ .findOne({

View File

@@ -0,0 +1,91 @@
import Context from '../../types/express/context';
import Role from '../../models/role';
import Permission from '../../models/permission';
import permissionCatalog from '../../helpers/permission-catalog.ee';
type Params = {
input: {
id: string;
name: string;
description: string;
permissions: Permission[];
};
};
const updateRole = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('update', 'Role');
const {
id,
name,
description,
permissions,
} = params.input;
const role = await Role
.query()
.findById(id)
.throwIfNotFound();
try {
const updatedRole = await Role.transaction(async (trx) => {
await role.$relatedQuery('permissions', trx).delete();
if (permissions?.length) {
const sanitizedPermissions = permissions
.filter((permission) => {
const {
action,
subject,
conditions,
} = permission;
const relevantAction = permissionCatalog.actions.find(actionCatalogItem => actionCatalogItem.key === action);
const validSubject = relevantAction.subjects.includes(subject);
const validConditions = conditions.every(condition => {
return !!permissionCatalog
.conditions
.find((conditionCatalogItem) => conditionCatalogItem.key === condition);
})
return validSubject && validConditions;
})
.map((permission) => ({
...permission,
roleId: role.id,
}));
await Permission.query().insert(sanitizedPermissions);
}
await role
.$query(trx)
.patch(
{
name,
description,
}
);
return await Role
.query(trx)
.leftJoinRelated({
permissions: true
})
.withGraphFetched({
permissions: true
})
.findById(id);
});
return updatedRole;
} catch (err) {
throw new Error('The role could not be updated!');
}
};
export default updateRole;

View File

@@ -0,0 +1,45 @@
import type { SamlConfig } from '@node-saml/passport-saml';
import SamlAuthProvider from '../../models/saml-auth-provider.ee';
import Context from '../../types/express/context';
type Params = {
input: {
name: string;
certificate: string;
signatureAlgorithm: SamlConfig['signatureAlgorithm'];
issuer: string;
entryPoint: string;
firstnameAttributeName: string;
surnameAttributeName: string;
emailAttributeName: string;
roleAttributeName: string;
defaultRoleId: string;
active: boolean;
};
};
const updateSamlAuthProvider = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('update', 'SamlAuthProvider');
const samlAuthProviderPayload: Partial<SamlAuthProvider> = {
...params.input,
};
const existingSamlAuthProvider = await SamlAuthProvider.query()
.limit(1)
.first()
.throwIfNotFound();
const samlAuthProvider = await SamlAuthProvider.query().patchAndFetchById(
existingSamlAuthProvider.id,
samlAuthProviderPayload
);
return samlAuthProvider;
};
export default updateSamlAuthProvider;

View File

@@ -23,6 +23,8 @@ const updateStep = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('update', 'Flow');
const { input } = params; const { input } = params;
let step = await context.currentUser let step = await context.currentUser

View File

@@ -0,0 +1,43 @@
import Context from '../../types/express/context';
import User from '../../models/user';
type Params = {
input: {
id: string;
email: string;
fullName: string;
role: {
id: string;
};
};
};
const updateUser = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('update', 'User');
const userPayload: Partial<User> = {
email: params.input.email,
fullName: params.input.fullName,
};
try {
context.currentUser.can('update', 'Role');
userPayload.roleId = params.input.role.id;
} catch {
// void
}
const user = await User.query().patchAndFetchById(
params.input.id,
userPayload
);
return user;
};
export default updateUser;

View File

@@ -13,6 +13,8 @@ const verifyConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
context.currentUser.can('create', 'Connection');
let connection = await context.currentUser let connection = await context.currentUser
.$relatedQuery('connections') .$relatedQuery('connections')
.findOne({ .findOne({

View File

@@ -1,4 +1,5 @@
import App from '../../models/app'; import App from '../../models/app';
import Connection from '../../models/connection';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
type Params = { type Params = {
@@ -6,11 +7,17 @@ type Params = {
}; };
const getApp = async (_parent: unknown, params: Params, context: Context) => { const getApp = async (_parent: unknown, params: Params, context: Context) => {
const conditions = context.currentUser.can('read', 'Connection');
const userConnections = context.currentUser.$relatedQuery('connections');
const allConnections = Connection.query();
const connectionBaseQuery = conditions.isCreator ? userConnections : allConnections;
const app = await App.findOneByKey(params.key); const app = await App.findOneByKey(params.key);
if (context.currentUser) { if (context.currentUser) {
const connections = await context.currentUser const connections = await connectionBaseQuery
.$relatedQuery('connections') .clone()
.select('connections.*') .select('connections.*')
.fullOuterJoinRelated('steps') .fullOuterJoinRelated('steps')
.where({ .where({

View File

@@ -1,5 +1,5 @@
import App from '../../models/app';
import { IApp } from '@automatisch/types'; import { IApp } from '@automatisch/types';
import App from '../../models/app';
type Params = { type Params = {
name: string; name: string;

View File

@@ -1,6 +1,8 @@
import { IConnection } from '@automatisch/types';
import App from '../../models/app'; import App from '../../models/app';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import { IApp, IConnection } from '@automatisch/types'; import Flow from '../../models/flow';
import Connection from '../../models/connection';
type Params = { type Params = {
name: string; name: string;
@@ -11,17 +13,27 @@ const getConnectedApps = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const conditions = context.currentUser.can('read', 'Connection');
const userConnections = context.currentUser.$relatedQuery('connections');
const allConnections = Connection.query();
const connectionBaseQuery = conditions.isCreator ? userConnections : allConnections;
const userFlows = context.currentUser.$relatedQuery('flows');
const allFlows = Flow.query();
const flowBaseQuery = conditions.isCreator ? userFlows : allFlows;
let apps = await App.findAll(params.name); let apps = await App.findAll(params.name);
const connections = await context.currentUser const connections = await connectionBaseQuery
.$relatedQuery('connections') .clone()
.select('connections.key') .select('connections.key')
.where({ draft: false }) .where({ draft: false })
.count('connections.id as count') .count('connections.id as count')
.groupBy('connections.key'); .groupBy('connections.key');
const flows = await context.currentUser const flows = await flowBaseQuery
.$relatedQuery('flows') .clone()
.withGraphJoined('steps') .withGraphJoined('steps')
.orderBy('created_at', 'desc'); .orderBy('created_at', 'desc');

View File

@@ -1,6 +1,7 @@
import { IDynamicData, IJSONObject } from '@automatisch/types'; import { IDynamicData, IJSONObject } from '@automatisch/types';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import App from '../../models/app'; import App from '../../models/app';
import Step from '../../models/step';
import ExecutionStep from '../../models/execution-step'; import ExecutionStep from '../../models/execution-step';
import globalVariable from '../../helpers/global-variable'; import globalVariable from '../../helpers/global-variable';
import computeParameters from '../../helpers/compute-parameters'; import computeParameters from '../../helpers/compute-parameters';
@@ -16,8 +17,13 @@ const getDynamicData = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const step = await context.currentUser const conditions = context.currentUser.can('update', 'Flow');
.$relatedQuery('steps') const userSteps = context.currentUser.$relatedQuery('steps');
const allSteps = Step.query();
const stepBaseQuery = conditions.isCreator ? userSteps : allSteps;
const step = await stepBaseQuery
.clone()
.withGraphFetched({ .withGraphFetched({
connection: true, connection: true,
flow: true, flow: true,

View File

@@ -1,6 +1,7 @@
import { IDynamicFields, IJSONObject } from '@automatisch/types'; import { IDynamicFields, IJSONObject } from '@automatisch/types';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import App from '../../models/app'; import App from '../../models/app';
import Step from '../../models/step';
import globalVariable from '../../helpers/global-variable'; import globalVariable from '../../helpers/global-variable';
type Params = { type Params = {
@@ -14,8 +15,13 @@ const getDynamicFields = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const step = await context.currentUser const conditions = context.currentUser.can('update', 'Flow');
.$relatedQuery('steps') const userSteps = context.currentUser.$relatedQuery('steps');
const allSteps = Step.query();
const stepBaseQuery = conditions.isCreator ? userSteps : allSteps;
const step = await stepBaseQuery
.clone()
.withGraphFetched({ .withGraphFetched({
connection: true, connection: true,
flow: true, flow: true,

View File

@@ -1,5 +1,6 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import paginate from '../../helpers/pagination'; import paginate from '../../helpers/pagination';
import Execution from '../../models/execution';
type Params = { type Params = {
executionId: string; executionId: string;
@@ -12,8 +13,13 @@ const getExecutionSteps = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const execution = await context.currentUser const conditions = context.currentUser.can('read', 'Execution');
.$relatedQuery('executions') const userExecutions = context.currentUser.$relatedQuery('executions');
const allExecutions = Execution.query();
const executionBaseQuery = conditions.isCreator ? userExecutions : allExecutions;
const execution = await executionBaseQuery
.clone()
.withSoftDeleted() .withSoftDeleted()
.findById(params.executionId) .findById(params.executionId)
.throwIfNotFound(); .throwIfNotFound();

View File

@@ -1,4 +1,5 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import Execution from '../../models/execution';
type Params = { type Params = {
executionId: string; executionId: string;
@@ -9,8 +10,13 @@ const getExecution = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const execution = await context.currentUser const conditions = context.currentUser.can('read', 'Execution');
.$relatedQuery('executions') const userExecutions = context.currentUser.$relatedQuery('executions');
const allExecutions = Execution.query();
const executionBaseQuery = conditions.isCreator ? userExecutions : allExecutions;
const execution = await executionBaseQuery
.clone()
.withGraphFetched({ .withGraphFetched({
flow: { flow: {
steps: true, steps: true,

View File

@@ -1,5 +1,6 @@
import { raw } from 'objection'; import { raw } from 'objection';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import Execution from '../../models/execution';
import paginate from '../../helpers/pagination'; import paginate from '../../helpers/pagination';
type Params = { type Params = {
@@ -12,6 +13,12 @@ const getExecutions = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const conditions = context.currentUser.can('read', 'Execution');
const userExecutions = context.currentUser.$relatedQuery('executions');
const allExecutions = Execution.query();
const executionBaseQuery = conditions.isCreator ? userExecutions : allExecutions;
const selectStatusStatement = ` const selectStatusStatement = `
case case
when count(*) filter (where execution_steps.status = 'failure') > 0 when count(*) filter (where execution_steps.status = 'failure') > 0
@@ -21,8 +28,8 @@ const getExecutions = async (
as status as status
`; `;
const executions = context.currentUser const executions = executionBaseQuery
.$relatedQuery('executions') .clone()
.joinRelated('executionSteps as execution_steps') .joinRelated('executionSteps as execution_steps')
.select('executions.*', raw(selectStatusStatement)) .select('executions.*', raw(selectStatusStatement))
.withSoftDeleted() .withSoftDeleted()

View File

@@ -1,12 +1,18 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import Flow from '../../models/flow';
type Params = { type Params = {
id: string; id: string;
}; };
const getFlow = async (_parent: unknown, params: Params, context: Context) => { const getFlow = async (_parent: unknown, params: Params, context: Context) => {
const flow = await context.currentUser const conditions = context.currentUser.can('read', 'Flow');
.$relatedQuery('flows') const userFlows = context.currentUser.$relatedQuery('flows');
const allFlows = Flow.query();
const baseQuery = conditions.isCreator ? userFlows : allFlows;
const flow = await baseQuery
.clone()
.withGraphJoined('[steps.[connection]]') .withGraphJoined('[steps.[connection]]')
.orderBy('steps.position', 'asc') .orderBy('steps.position', 'asc')
.findOne({ 'flows.id': params.id }) .findOne({ 'flows.id': params.id })

View File

@@ -1,3 +1,4 @@
import Flow from '../../models/flow';
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import paginate from '../../helpers/pagination'; import paginate from '../../helpers/pagination';
@@ -10,8 +11,13 @@ type Params = {
}; };
const getFlows = async (_parent: unknown, params: Params, context: Context) => { const getFlows = async (_parent: unknown, params: Params, context: Context) => {
const flowsQuery = context.currentUser const conditions = context.currentUser.can('read', 'Flow');
.$relatedQuery('flows') const userFlows = context.currentUser.$relatedQuery('flows');
const allFlows = Flow.query();
const baseQuery = conditions.isCreator ? userFlows : allFlows;
const flowsQuery = baseQuery
.clone()
.joinRelated({ .joinRelated({
steps: true, steps: true,
}) })

View File

@@ -0,0 +1,7 @@
import permissionCatalog from '../../helpers/permission-catalog.ee';
const getPermissionCatalog = async () => {
return permissionCatalog;
};
export default getPermissionCatalog;

View File

@@ -0,0 +1,23 @@
import Context from '../../types/express/context';
import Role from '../../models/role';
type Params = {
id: string
};
const getRole = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'Role');
return await Role
.query()
.leftJoinRelated({
permissions: true
})
.withGraphFetched({
permissions: true
})
.findById(params.id)
.throwIfNotFound();
};
export default getRole;

View File

@@ -0,0 +1,10 @@
import Context from '../../types/express/context';
import Role from '../../models/role';
const getRoles = async (_parent: unknown, params: unknown, context: Context) => {
context.currentUser.can('read', 'Role');
return await Role.query().orderBy('name');
};
export default getRoles;

View File

@@ -0,0 +1,9 @@
import SamlAuthProvider from '../../models/saml-auth-provider.ee';
const getSamlAuthProviders = async () => {
const providers = await SamlAuthProvider.query().where({ active: true });
return providers;
};
export default getSamlAuthProviders;

View File

@@ -1,6 +1,7 @@
import Context from '../../types/express/context';
import ExecutionStep from '../../models/execution-step';
import { ref } from 'objection'; import { ref } from 'objection';
import ExecutionStep from '../../models/execution-step';
import Step from '../../models/step';
import Context from '../../types/express/context';
type Params = { type Params = {
stepId: string; stepId: string;
@@ -11,13 +12,18 @@ const getStepWithTestExecutions = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
const step = await context.currentUser const conditions = context.currentUser.can('update', 'Flow');
.$relatedQuery('steps') const userSteps = context.currentUser.$relatedQuery('steps');
const allSteps = Step.query();
const stepBaseQuery = conditions.isCreator ? userSteps : allSteps;
const step = await stepBaseQuery
.clone()
.findOne({ 'steps.id': params.stepId }) .findOne({ 'steps.id': params.stepId })
.throwIfNotFound(); .throwIfNotFound();
const previousStepsWithCurrentStep = await context.currentUser const previousStepsWithCurrentStep = await stepBaseQuery
.$relatedQuery('steps') .clone()
.withGraphJoined('executionSteps') .withGraphJoined('executionSteps')
.where('flow_id', '=', step.flowId) .where('flow_id', '=', step.flowId)
.andWhere('position', '<', step.position) .andWhere('position', '<', step.position)

View File

@@ -0,0 +1,23 @@
import Context from '../../types/express/context';
import User from '../../models/user';
type Params = {
id: string
};
const getUser = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'User');
return await User
.query()
.leftJoinRelated({
role: true
})
.withGraphFetched({
role: true
})
.findById(params.id)
.throwIfNotFound();
};
export default getUser;

View File

@@ -0,0 +1,26 @@
import Context from '../../types/express/context';
import paginate from '../../helpers/pagination';
import User from '../../models/user';
type Params = {
limit: number;
offset: number;
};
const getUsers = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'User');
const usersQuery = User
.query()
.leftJoinRelated({
role: true
})
.withGraphFetched({
role: true
})
.orderBy('full_name', 'desc');
return paginate(usersQuery, params.limit, params.offset);
};
export default getUsers;

View File

@@ -1,5 +1,6 @@
import Context from '../../types/express/context'; import Context from '../../types/express/context';
import App from '../../models/app'; import App from '../../models/app';
import Connection from '../../models/connection';
import globalVariable from '../../helpers/global-variable'; import globalVariable from '../../helpers/global-variable';
type Params = { type Params = {
@@ -12,8 +13,13 @@ const testConnection = async (
params: Params, params: Params,
context: Context context: Context
) => { ) => {
let connection = await context.currentUser const conditions = context.currentUser.can('update', 'Connection');
.$relatedQuery('connections') const userConnections = context.currentUser.$relatedQuery('connections');
const allConnections = Connection.query();
const connectionBaseQuery = conditions.isCreator ? userConnections : allConnections;
let connection = await connectionBaseQuery
.clone()
.findOne({ .findOne({
id: params.id, id: params.id,
}) })

View File

@@ -1,47 +1,59 @@
import getApps from './queries/get-apps';
import getApp from './queries/get-app'; import getApp from './queries/get-app';
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 getConnectedApps from './queries/get-connected-apps';
import testConnection from './queries/test-connection'; import getCurrentUser from './queries/get-current-user';
import getFlow from './queries/get-flow';
import getFlows from './queries/get-flows';
import getStepWithTestExecutions from './queries/get-step-with-test-executions';
import getExecution from './queries/get-execution';
import getExecutions from './queries/get-executions';
import getExecutionSteps from './queries/get-execution-steps';
import getDynamicData from './queries/get-dynamic-data'; import getDynamicData from './queries/get-dynamic-data';
import getDynamicFields from './queries/get-dynamic-fields'; import getDynamicFields from './queries/get-dynamic-fields';
import getCurrentUser from './queries/get-current-user'; import getExecution from './queries/get-execution';
import getPaymentPlans from './queries/get-payment-plans.ee'; import getExecutionSteps from './queries/get-execution-steps';
import getPaddleInfo from './queries/get-paddle-info.ee'; import getExecutions from './queries/get-executions';
import getBillingAndUsage from './queries/get-billing-and-usage.ee'; import getFlow from './queries/get-flow';
import getFlows from './queries/get-flows';
import getUser from './queries/get-user';
import getUsers from './queries/get-users';
import getInvoices from './queries/get-invoices.ee'; import getInvoices from './queries/get-invoices.ee';
import getAutomatischInfo from './queries/get-automatisch-info'; import getPaddleInfo from './queries/get-paddle-info.ee';
import getTrialStatus from './queries/get-trial-status.ee'; import getPaymentPlans from './queries/get-payment-plans.ee';
import getPermissionCatalog from './queries/get-permission-catalog.ee';
import getRole from './queries/get-role.ee';
import getRoles from './queries/get-roles.ee';
import getSamlAuthProviders from './queries/get-saml-auth-providers.ee';
import getStepWithTestExecutions from './queries/get-step-with-test-executions';
import getSubscriptionStatus from './queries/get-subscription-status.ee'; import getSubscriptionStatus from './queries/get-subscription-status.ee';
import getTrialStatus from './queries/get-trial-status.ee';
import healthcheck from './queries/healthcheck'; import healthcheck from './queries/healthcheck';
import testConnection from './queries/test-connection';
const queryResolvers = { const queryResolvers = {
getApps,
getApp, getApp,
getApps,
getAutomatischInfo,
getBillingAndUsage,
getConnectedApps, getConnectedApps,
testConnection, getCurrentUser,
getFlow, getDynamicData,
getFlows, getDynamicFields,
getStepWithTestExecutions,
getExecution, getExecution,
getExecutions, getExecutions,
getExecutionSteps, getExecutionSteps,
getDynamicData, getFlow,
getDynamicFields, getFlows,
getCurrentUser,
getPaymentPlans,
getPaddleInfo,
getBillingAndUsage,
getInvoices, getInvoices,
getAutomatischInfo, getPaddleInfo,
getTrialStatus, getPaymentPlans,
getPermissionCatalog,
getRole,
getRoles,
getSamlAuthProviders,
getStepWithTestExecutions,
getSubscriptionStatus, getSubscriptionStatus,
getTrialStatus,
getUser,
getUsers,
healthcheck, healthcheck,
testConnection,
}; };
export default queryResolvers; export default queryResolvers;

View File

@@ -41,31 +41,45 @@ type Query {
getAutomatischInfo: GetAutomatischInfo getAutomatischInfo: GetAutomatischInfo
getTrialStatus: GetTrialStatus getTrialStatus: GetTrialStatus
getSubscriptionStatus: GetSubscriptionStatus getSubscriptionStatus: GetSubscriptionStatus
getSamlAuthProviders: [GetSamlAuthProviders]
getUsers(limit: Int!, offset: Int!): UserConnection
getUser(id: String!): User
getRoles: [Role]
getRole(id: String!): Role
getPermissionCatalog: PermissionCatalog
healthcheck: AppHealth healthcheck: AppHealth
} }
type Mutation { type Mutation {
createConnection(input: CreateConnectionInput): Connection createConnection(input: CreateConnectionInput): Connection
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
updateConnection(input: UpdateConnectionInput): Connection
resetConnection(input: ResetConnectionInput): Connection
verifyConnection(input: VerifyConnectionInput): Connection
deleteConnection(input: DeleteConnectionInput): Boolean
createFlow(input: CreateFlowInput): Flow createFlow(input: CreateFlowInput): Flow
createRole(input: CreateRoleInput): Role
createStep(input: CreateStepInput): Step
createUser(input: CreateUserInput): User
deleteConnection(input: DeleteConnectionInput): Boolean
deleteCurrentUser: Boolean
deleteFlow(input: DeleteFlowInput): Boolean
deleteRole(input: DeleteRoleInput): Boolean
deleteStep(input: DeleteStepInput): Step
deleteUser(input: DeleteUserInput): Boolean
duplicateFlow(input: DuplicateFlowInput): Flow
executeFlow(input: ExecuteFlowInput): executeFlowType
forgotPassword(input: ForgotPasswordInput): Boolean
generateAuthUrl(input: GenerateAuthUrlInput): AuthLink
login(input: LoginInput): Auth
registerUser(input: RegisterUserInput): User
resetConnection(input: ResetConnectionInput): Connection
resetPassword(input: ResetPasswordInput): Boolean
updateConnection(input: UpdateConnectionInput): Connection
updateCurrentUser(input: UpdateCurrentUserInput): User
updateFlow(input: UpdateFlowInput): Flow updateFlow(input: UpdateFlowInput): Flow
updateFlowStatus(input: UpdateFlowStatusInput): Flow updateFlowStatus(input: UpdateFlowStatusInput): Flow
executeFlow(input: ExecuteFlowInput): executeFlowType updateRole(input: UpdateRoleInput): Role
deleteFlow(input: DeleteFlowInput): Boolean
duplicateFlow(input: DuplicateFlowInput): Flow
createStep(input: CreateStepInput): Step
updateStep(input: UpdateStepInput): Step updateStep(input: UpdateStepInput): Step
deleteStep(input: DeleteStepInput): Step
createUser(input: CreateUserInput): User
deleteUser: Boolean
updateUser(input: UpdateUserInput): User updateUser(input: UpdateUserInput): User
forgotPassword(input: ForgotPasswordInput): Boolean verifyConnection(input: VerifyConnectionInput): Connection
resetPassword(input: ResetPasswordInput): Boolean createSamlAuthProvider(input: CreateSamlAuthProviderInput): SamlAuthProvider
login(input: LoginInput): Auth updateSamlAuthProvider(input: UpdateSamlAuthProviderInput): SamlAuthProvider
} }
""" """
@@ -277,6 +291,29 @@ type Execution {
flow: Flow flow: Flow
} }
type SamlAuthProvider {
id: String
name: String
certificate: String
signatureAlgorithm: String
issuer: String
entryPoint: String
firstnameAttributeName: String
surnameAttributeName: String
emailAttributeName: String
roleAttributeName: String
active: Boolean
}
type UserConnection {
edges: [UserEdge]
pageInfo: PageInfo
}
type UserEdge {
node: User
}
input CreateConnectionInput { input CreateConnectionInput {
key: String! key: String!
formattedData: JSONObject! formattedData: JSONObject!
@@ -299,6 +336,34 @@ input VerifyConnectionInput {
id: String! id: String!
} }
input CreateSamlAuthProviderInput {
name: String!
certificate: String!
signatureAlgorithm: String!
issuer: String!
entryPoint: String!
firstnameAttributeName: String!
surnameAttributeName: String!
emailAttributeName: String!
roleAttributeName: String!
defaultRoleId: String!
active: Boolean!
}
input UpdateSamlAuthProviderInput {
name: String!
certificate: String!
signatureAlgorithm: String!
issuer: String!
entryPoint: String!
firstnameAttributeName: String!
surnameAttributeName: String!
emailAttributeName: String!
roleAttributeName: String!
defaultRoleId: String!
active: Boolean!
}
input DeleteConnectionInput { input DeleteConnectionInput {
id: String! id: String!
} }
@@ -360,9 +425,31 @@ input CreateUserInput {
fullName: String! fullName: String!
email: String! email: String!
password: String! password: String!
role: UserRoleInput!
}
input UserRoleInput {
id: String
} }
input UpdateUserInput { input UpdateUserInput {
id: String!
fullName: String
email: String
role: UserRoleInput
}
input DeleteUserInput {
id: String!
}
input RegisterUserInput {
fullName: String!
email: String!
password: String!
}
input UpdateCurrentUserInput {
email: String email: String
password: String password: String
fullName: String fullName: String
@@ -382,6 +469,29 @@ input LoginInput {
password: String! password: String!
} }
input PermissionInput {
action: String!
subject: String!
conditions: [String]
}
input CreateRoleInput {
name: String!
description: String
permissions: [PermissionInput]
}
input UpdateRoleInput {
id: String!
name: String!
description: String
permissions: [PermissionInput]
}
input DeleteRoleInput {
id: String!
}
""" """
The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf).
""" """
@@ -453,11 +563,21 @@ type User {
id: String id: String
fullName: String fullName: String
email: String email: String
role: String role: Role
permissions: [Permission]
createdAt: String createdAt: String
updatedAt: String updatedAt: String
} }
type Role {
id: String
name: String
key: String
description: String
isAdmin: Boolean
permissions: [Permission]
}
type PageInfo { type PageInfo {
currentPage: Int! currentPage: Int!
totalPages: Int! totalPages: Int!
@@ -554,6 +674,41 @@ type PaymentPlan {
productId: String productId: String
} }
type GetSamlAuthProviders {
id: String
name: String
issuer: String
}
type Permission {
id: String
action: String
subject: String
conditions: [String]
}
type PermissionCatalog {
actions: [Action]
subjects: [Subject]
conditions: [Condition]
}
type Action {
label: String
key: String
subjects: [String]
}
type Condition {
key: String
label: String
}
type Subject {
label: String
key: String
}
schema { schema {
query: Query query: Query
mutation: Mutation mutation: Mutation

View File

@@ -12,7 +12,17 @@ const isAuthenticated = rule()(async (_parent, _args, req) => {
const { userId } = jwt.verify(token, appConfig.appSecretKey) as { const { userId } = jwt.verify(token, appConfig.appSecretKey) as {
userId: string; userId: string;
}; };
req.currentUser = await User.query().findById(userId).throwIfNotFound(); req.currentUser = await User
.query()
.findById(userId)
.leftJoinRelated({
role: true,
permissions: true,
})
.withGraphFetched({
role: true,
permissions: true,
});
return true; return true;
} catch (error) { } catch (error) {
@@ -25,13 +35,14 @@ const authentication = shield(
Query: { Query: {
'*': isAuthenticated, '*': isAuthenticated,
getAutomatischInfo: allow, getAutomatischInfo: allow,
getSamlAuthProviders: allow,
healthcheck: allow, healthcheck: allow,
}, },
Mutation: { Mutation: {
'*': isAuthenticated, '*': isAuthenticated,
login: allow, registerUser: allow,
createUser: allow,
forgotPassword: allow, forgotPassword: allow,
login: allow,
resetPassword: allow, resetPassword: allow,
}, },
}, },

View File

@@ -0,0 +1,14 @@
import jwt from 'jsonwebtoken';
import appConfig from '../config/app';
const TOKEN_EXPIRES_IN = '14d';
const createAuthTokenByUserId = (userId: string) => {
const token = jwt.sign({ userId }, appConfig.appSecretKey, {
expiresIn: TOKEN_EXPIRES_IN,
});
return token;
};
export default createAuthTokenByUserId;

View File

@@ -0,0 +1,48 @@
import SamlAuthProvider from '../models/saml-auth-provider.ee';
import User from '../models/user';
import Identity from '../models/identity.ee';
const getUser = (user: Record<string, unknown>, providerConfig: SamlAuthProvider) => ({
name: user[providerConfig.firstnameAttributeName],
surname: user[providerConfig.surnameAttributeName],
id: user.nameID,
email: user[providerConfig.emailAttributeName],
role: user[providerConfig.roleAttributeName],
})
const findOrCreateUserBySamlIdentity = async (userIdentity: Record<string, unknown>, samlAuthProvider: SamlAuthProvider) => {
const mappedUser = getUser(userIdentity, samlAuthProvider);
const identity = await Identity.query().findOne({
remote_id: mappedUser.id,
});
if (identity) {
const user = await identity.$relatedQuery('user');
return user;
}
const createdUser = await User.query().insertGraph({
fullName: [
mappedUser.name,
mappedUser.surname
]
.filter(Boolean)
.join(' '),
email: mappedUser.email as string,
roleId: samlAuthProvider.defaultRoleId,
identities: [
{
remoteId: mappedUser.id as string,
providerId: samlAuthProvider.id,
providerType: 'saml'
}
]
}, {
relate: ['identities']
}).returning('*');
return createdUser;
};
export default findOrCreateUserBySamlIdentity;

View File

@@ -1,10 +1,11 @@
import { Model } from 'objection'; import { Model } from 'objection';
import ExtendedQueryBuilder from '../models/query-builder'; import ExtendedQueryBuilder from '../models/query-builder';
import type Base from '../models/base';
const paginate = async ( const paginate = async (
query: ExtendedQueryBuilder<Model, Model[]>, query: ExtendedQueryBuilder<Model, Model[]>,
limit: number, limit: number,
offset: number offset: number,
) => { ) => {
if (limit < 1 || limit > 100) { if (limit < 1 || limit > 100) {
throw new Error('Limit must be between 1 and 100'); throw new Error('Limit must be between 1 and 100');
@@ -20,11 +21,9 @@ const paginate = async (
currentPage: Math.ceil(offset / limit + 1), currentPage: Math.ceil(offset / limit + 1),
totalPages: Math.ceil(count / limit), totalPages: Math.ceil(count / limit),
}, },
edges: records.map((record: Model) => { edges: records.map((record: Base) => ({
return {
node: record, node: record,
}; })),
}),
}; };
}; };

View File

@@ -0,0 +1,84 @@
import { URL } from 'node:url';
import { IRequest } from '@automatisch/types';
import { MultiSamlStrategy } from '@node-saml/passport-saml';
import { Express } from 'express';
import passport from 'passport';
import appConfig from '../config/app';
import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id';
import SamlAuthProvider from '../models/saml-auth-provider.ee';
import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee'
export default function configurePassport(app: Express) {
app.use(passport.initialize({
userProperty: 'currentUser',
}));
passport.use(new MultiSamlStrategy(
{
passReqToCallback: true,
getSamlOptions: async function (request, done) {
const { issuer } = request.params;
const notFoundIssuer = new Error('Issuer cannot be found!');
if (!issuer) return done(notFoundIssuer);
const authProvider = await SamlAuthProvider.query().findOne({
issuer: request.params.issuer as string,
});
if (!authProvider) {
return done(notFoundIssuer);
}
return done(null, authProvider.config);
},
},
async function (request, user: Record<string, unknown>, done) {
const { issuer } = request.params;
const notFoundIssuer = new Error('Issuer cannot be found!');
if (!issuer) return done(notFoundIssuer);
const authProvider = await SamlAuthProvider.query().findOne({
issuer: request.params.issuer as string,
});
if (!authProvider) {
return done(notFoundIssuer);
}
const foundUserWithIdentity = await findOrCreateUserBySamlIdentity(user, authProvider);
return done(null, foundUserWithIdentity as unknown as Record<string, unknown>);
},
function (request, user: Record<string, unknown>, done: (error: any, user: Record<string, unknown>) => void) {
return done(null, null);
}
));
app.get('/login/saml/:issuer',
passport.authenticate('saml',
{
session: false,
successRedirect: '/',
})
);
app.post(
'/login/saml/:issuer/callback',
passport.authenticate('saml', {
session: false,
failureRedirect: '/',
failureFlash: true,
}),
(req: IRequest, res) => {
const token = createAuthTokenByUserId(req.currentUser.id);
const redirectUrl = new URL(
`/login/callback?token=${token}`,
appConfig.webAppUrl,
).toString();
res.redirect(redirectUrl);
}
);
};

View File

@@ -0,0 +1,72 @@
const Connection = {
label: 'Connection',
key: 'Connection',
};
const Flow = {
label: 'Flow',
key: 'Flow',
};
const Execution = {
label: 'Execution',
key: 'Execution',
};
const permissionCatalog = {
conditions: [
{
key: 'isCreator',
label: 'Is creator'
}
],
actions: [
{
label: 'Create',
key: 'create',
subjects: [
Connection.key,
Flow.key,
]
},
{
label: 'Read',
key: 'read',
subjects: [
Connection.key,
Execution.key,
Flow.key,
]
},
{
label: 'Update',
key: 'update',
subjects: [
Connection.key,
Flow.key,
]
},
{
label: 'Delete',
key: 'delete',
subjects: [
Connection.key,
Flow.key,
]
},
{
label: 'Publish',
key: 'publish',
subjects: [
Flow.key,
]
}
],
subjects: [
Connection,
Flow,
Execution
]
};
export default permissionCatalog;

View File

@@ -0,0 +1,20 @@
import { PureAbility, fieldPatternMatcher, mongoQueryMatcher } from '@casl/ability';
import type User from '../models/user'
// Must be kept in sync with `packages/web/src/helpers/userAbility.ts`!
export default function userAbility(user: Partial<User>) {
const permissions = user?.permissions;
const role = user?.role;
// We're not using mongo, but our fields, conditions match
const options = {
conditionsMatcher: mongoQueryMatcher,
fieldMatcher: fieldPatternMatcher
};
if (!role || !permissions) {
return new PureAbility([], options);
}
return new PureAbility<[string, string], string[]>(permissions, options);
}

View File

@@ -40,6 +40,9 @@ class Connection extends Base {
userId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' },
verified: { type: 'boolean', default: false }, verified: { type: 'boolean', default: false },
draft: { type: 'boolean' }, draft: { type: 'boolean' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -31,6 +31,9 @@ class ExecutionStep extends Base {
dataOut: { type: ['object', 'null'] }, dataOut: { type: ['object', 'null'] },
status: { type: 'string', enum: ['success', 'failure'] }, status: { type: 'string', enum: ['success', 'failure'] },
errorDetails: { type: ['object', 'null'] }, errorDetails: { type: ['object', 'null'] },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -22,6 +22,9 @@ class Execution extends Base {
flowId: { type: 'string', format: 'uuid' }, flowId: { type: 'string', format: 'uuid' },
testRun: { type: 'boolean', default: false }, testRun: { type: 'boolean', default: false },
internalId: { type: 'string' }, internalId: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -19,7 +19,7 @@ class Flow extends Base {
status: 'paused' | 'published' | 'draft'; status: 'paused' | 'published' | 'draft';
steps: Step[]; steps: Step[];
triggerStep: Step; triggerStep: Step;
published_at: string; publishedAt: string;
remoteWebhookId: string; remoteWebhookId: string;
executions?: Execution[]; executions?: Execution[];
lastExecution?: Execution; lastExecution?: Execution;
@@ -37,6 +37,10 @@ class Flow extends Base {
userId: { type: 'string', format: 'uuid' }, userId: { type: 'string', format: 'uuid' },
remoteWebhookId: { type: 'string' }, remoteWebhookId: { type: 'string' },
active: { type: 'boolean' }, active: { type: 'boolean' },
publishedAt: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -0,0 +1,53 @@
import Base from './base';
import SamlAuthProvider from './saml-auth-provider.ee';
import User from './user';
class Identity extends Base {
id!: string;
remoteId!: string;
userId!: string;
providerId!: string;
providerType!: 'saml';
static tableName = 'identities';
static jsonSchema = {
type: 'object',
required: [
'providerId',
'remoteId',
'userId',
'providerType',
],
properties: {
id: { type: 'string', format: 'uuid' },
userId: { type: 'string', format: 'uuid' },
remoteId: { type: 'string', minLength: 1 },
providerId: { type: 'string', format: 'uuid' },
providerType: { type: 'string', enum: ['saml'] },
},
};
static relationMappings = () => ({
user: {
relation: Base.BelongsToOneRelation,
modelClass: User,
join: {
from: 'users.id',
to: 'identities.user_id',
},
},
samlAuthProvider: {
relation: Base.BelongsToOneRelation,
modelClass: SamlAuthProvider,
join: {
from: 'saml_auth_providers.id',
to: 'identities.provider_id'
},
},
});
}
export default Identity;

View File

@@ -0,0 +1,28 @@
import Base from './base';
class Permission extends Base {
id: string;
roleId: string;
action: string;
subject: string;
conditions: string[];
static tableName = 'permissions';
static jsonSchema = {
type: 'object',
required: ['roleId', 'action', 'subject'],
properties: {
id: { type: 'string', format: 'uuid' },
roleId: { type: 'string', format: 'uuid' },
action: { type: 'string', minLength: 1 },
subject: { type: 'string', minLength: 1 },
conditions: { type: 'array', items: { type: 'string' } },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
}
export default Permission;

View File

@@ -1,6 +1,7 @@
import { import {
Model, Model,
Page, Page,
ModelClass,
PartialModelObject, PartialModelObject,
ForClassMethod, ForClassMethod,
AnyQueryBuilder, AnyQueryBuilder,
@@ -8,6 +9,10 @@ import {
const DELETED_COLUMN_NAME = 'deleted_at'; const DELETED_COLUMN_NAME = 'deleted_at';
const supportsSoftDeletion = (modelClass: ModelClass<any>) => {
return modelClass.jsonSchema.properties.deletedAt;
}
const buildQueryBuidlerForClass = (): ForClassMethod => { const buildQueryBuidlerForClass = (): ForClassMethod => {
return (modelClass) => { return (modelClass) => {
const qb: AnyQueryBuilder = Model.QueryBuilder.forClass.call( const qb: AnyQueryBuilder = Model.QueryBuilder.forClass.call(
@@ -15,7 +20,7 @@ const buildQueryBuidlerForClass = (): ForClassMethod => {
modelClass modelClass
); );
qb.onBuild((builder) => { qb.onBuild((builder) => {
if (!builder.context().withSoftDeleted) { if (!builder.context().withSoftDeleted && supportsSoftDeletion(qb.modelClass())) {
builder.whereNull( builder.whereNull(
`${qb.modelClass().tableName}.${DELETED_COLUMN_NAME}` `${qb.modelClass().tableName}.${DELETED_COLUMN_NAME}`
); );
@@ -38,11 +43,15 @@ class ExtendedQueryBuilder<M extends Model, R = M[]> extends Model.QueryBuilder<
static forClass: ForClassMethod = buildQueryBuidlerForClass(); static forClass: ForClassMethod = buildQueryBuidlerForClass();
delete() { delete() {
if (supportsSoftDeletion(this.modelClass())) {
return this.patch({ return this.patch({
[DELETED_COLUMN_NAME]: new Date().toISOString(), [DELETED_COLUMN_NAME]: new Date().toISOString(),
} as unknown as PartialModelObject<M>); } as unknown as PartialModelObject<M>);
} }
return super.delete();
}
hardDelete() { hardDelete() {
return super.delete(); return super.delete();
} }

View File

@@ -0,0 +1,57 @@
import Base from './base';
import Permission from './permission';
import User from './user';
class Role extends Base {
id!: string;
name!: string;
key: string;
description: string;
users?: User[];
permissions?: Permission[];
static tableName = 'roles';
static jsonSchema = {
type: 'object',
required: ['name', 'key'],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1 },
description: { type: ['string', 'null'], maxLength: 255 },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
};
static get virtualAttributes() {
return ['isAdmin'];
}
static relationMappings = () => ({
users: {
relation: Base.HasManyRelation,
modelClass: User,
join: {
from: 'roles.id',
to: 'users.role_id',
},
},
permissions: {
relation: Base.HasManyRelation,
modelClass: Permission,
join: {
from: 'roles.id',
to: 'permissions.role_id',
},
},
});
get isAdmin() {
return this.key === 'admin';
}
}
export default Role;

View File

@@ -0,0 +1,84 @@
import { URL } from 'node:url';
import type { SamlConfig } from '@node-saml/passport-saml';
import appConfig from '../config/app';
import Base from './base';
import Identity from './identity.ee';
class SamlAuthProvider extends Base {
id!: string;
name: string;
certificate: string;
signatureAlgorithm: SamlConfig['signatureAlgorithm'];
issuer: string;
entryPoint: string;
firstnameAttributeName: string;
surnameAttributeName: string;
emailAttributeName: string;
roleAttributeName: string;
defaultRoleId: string;
active: boolean;
static tableName = 'saml_auth_providers';
static jsonSchema = {
type: 'object',
required: [
'name',
'certificate',
'signatureAlgorithm',
'entryPoint',
'issuer',
'firstnameAttributeName',
'surnameAttributeName',
'emailAttributeName',
'roleAttributeName',
'defaultRoleId',
],
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
certificate: { type: 'string', minLength: 1 },
signatureAlgorithm: {
type: 'string',
enum: ['sha1', 'sha256', 'sha512'],
},
issuer: { type: 'string', minLength: 1 },
entryPoint: { type: 'string', minLength: 1 },
firstnameAttributeName: { type: 'string', minLength: 1 },
surnameAttributeName: { type: 'string', minLength: 1 },
emailAttributeName: { type: 'string', minLength: 1 },
roleAttributeName: { type: 'string', minLength: 1 },
defaultRoleId: { type: 'string', format: 'uuid' },
active: { type: 'boolean' },
},
};
static relationMappings = () => ({
identities: {
relation: Base.HasOneRelation,
modelClass: Identity,
join: {
from: 'identities.provider_id',
to: 'saml_auth_providers.id',
},
},
});
get config(): SamlConfig {
const callbackUrl = new URL(
`/login/saml/${this.issuer}/callback`,
appConfig.baseUrl
).toString();
return {
callbackUrl,
cert: this.certificate,
entryPoint: this.entryPoint,
issuer: this.issuer,
signatureAlgorithm: this.signatureAlgorithm,
};
}
}
export default SamlAuthProvider;

View File

@@ -46,6 +46,9 @@ class Step extends Base {
position: { type: 'integer' }, position: { type: 'integer' },
parameters: { type: 'object' }, parameters: { type: 'object' },
webhookPath: { type: ['string', 'null'] }, webhookPath: { type: ['string', 'null'] },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -46,6 +46,9 @@ class Subscription extends Base {
nextBillDate: { type: 'string' }, nextBillDate: { type: 'string' },
lastBillDate: { type: 'string' }, lastBillDate: { type: 'string' },
cancellationEffectiveDate: { type: 'string' }, cancellationEffectiveDate: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -24,6 +24,9 @@ class UsageData extends Base {
subscriptionId: { type: 'string', format: 'uuid' }, subscriptionId: { type: 'string', format: 'uuid' },
consumedTaskCount: { type: 'integer' }, consumedTaskCount: { type: 'integer' },
nextResetAt: { type: 'string' }, nextResetAt: { type: 'string' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };

View File

@@ -1,23 +1,29 @@
import { QueryContext, ModelOptions } from 'objection';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import crypto from 'crypto';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import crypto from 'node:crypto';
import { ModelOptions, QueryContext } from 'objection';
import appConfig from '../config/app'; import appConfig from '../config/app';
import checkLicense from '../helpers/check-license.ee';
import userAbility from '../helpers/user-ability';
import Base from './base'; import Base from './base';
import ExtendedQueryBuilder from './query-builder';
import Connection from './connection'; import Connection from './connection';
import Flow from './flow';
import Step from './step';
import Execution from './execution'; import Execution from './execution';
import UsageData from './usage-data.ee'; import Flow from './flow';
import Identity from './identity.ee';
import Permission from './permission';
import ExtendedQueryBuilder from './query-builder';
import Role from './role';
import Step from './step';
import Subscription from './subscription.ee'; import Subscription from './subscription.ee';
import UsageData from './usage-data.ee';
class User extends Base { class User extends Base {
id!: string; id!: string;
fullName!: string; fullName!: string;
email!: string; email!: string;
roleId: string;
password!: string; password!: string;
role: string;
resetPasswordToken: string; resetPasswordToken: string;
resetPasswordTokenSentAt: string; resetPasswordTokenSentAt: string;
trialExpiryDate: string; trialExpiryDate: string;
@@ -29,19 +35,28 @@ class User extends Base {
currentUsageData?: UsageData; currentUsageData?: UsageData;
subscriptions?: Subscription[]; subscriptions?: Subscription[];
currentSubscription?: Subscription; currentSubscription?: Subscription;
role: Role;
permissions: Permission[];
identities: Identity[];
static tableName = 'users'; static tableName = 'users';
static jsonSchema = { static jsonSchema = {
type: 'object', type: 'object',
required: ['fullName', 'email', 'password'], required: ['fullName', 'email'],
properties: { properties: {
id: { type: 'string', format: 'uuid' }, id: { type: 'string', format: 'uuid' },
fullName: { type: 'string', minLength: 1 }, fullName: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
password: { type: 'string', minLength: 1, maxLength: 255 }, password: { type: 'string' },
role: { type: 'string', enum: ['admin', 'user'] }, resetPasswordToken: { type: 'string' },
resetPasswordTokenSentAt: { type: 'string' },
trialExpiryDate: { type: 'string' },
roleId: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string' },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
}, },
}; };
@@ -124,6 +139,30 @@ class User extends Base {
builder.orderBy('created_at', 'desc').limit(1).first(); builder.orderBy('created_at', 'desc').limit(1).first();
}, },
}, },
role: {
relation: Base.HasOneRelation,
modelClass: Role,
join: {
from: 'roles.id',
to: 'users.role_id',
},
},
permissions: {
relation: Base.HasManyRelation,
modelClass: Permission,
join: {
from: 'users.role_id',
to: 'permissions.role_id',
},
},
identities: {
relation: Base.HasManyRelation,
modelClass: Identity,
join: {
from: 'identities.user_id',
to: 'users.id',
},
},
}); });
login(password: string) { login(password: string) {
@@ -158,8 +197,10 @@ class User extends Base {
} }
async generateHash() { async generateHash() {
if (this.password) {
this.password = await bcrypt.hash(this.password, 10); this.password = await bcrypt.hash(this.password, 10);
} }
}
async startTrialPeriod() { async startTrialPeriod() {
this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate(); this.trialExpiryDate = DateTime.now().plus({ days: 30 }).toISODate();
@@ -232,10 +273,8 @@ class User extends Base {
async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) { async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) {
await super.$beforeUpdate(opt, queryContext); await super.$beforeUpdate(opt, queryContext);
if (this.password) {
await this.generateHash(); await this.generateHash();
} }
}
async $afterInsert(queryContext: QueryContext) { async $afterInsert(queryContext: QueryContext) {
await super.$afterInsert(queryContext); await super.$afterInsert(queryContext);
@@ -248,6 +287,51 @@ class User extends Base {
}); });
} }
} }
async $afterFind(): Promise<any> {
const hasValidLicense = await checkLicense();
if (hasValidLicense) return this;
if (Array.isArray(this.permissions)) {
this.permissions = this.permissions.filter((permission) => {
const isRolePermission = permission.subject === 'Role';
const isSamlAuthProviderPermission =
permission.subject === 'SamlAuthProvider';
return !isRolePermission && !isSamlAuthProviderPermission;
});
}
return this;
}
get ability(): ReturnType<typeof userAbility> {
return userAbility(this);
}
can(action: string, subject: string) {
const can = this.ability.can(action, subject);
if (!can) throw new Error('Not authorized!');
const relevantRule = this.ability.relevantRuleFor(action, subject);
const conditions = (relevantRule?.conditions as string[]) || [];
const conditionMap: Record<string, true> = Object.fromEntries(
conditions.map((condition) => [condition, true])
);
return conditionMap;
}
cannot(action: string, subject: string) {
const cannot = this.ability.cannot(action, subject);
if (cannot) throw new Error('Not authorized!');
return cannot;
}
} }
export default User; export default User;

View File

@@ -1,9 +1,5 @@
# Available Apps # Available Apps
:::warning
We just have a few available integrations at the moment and we also know that workflows you can build with them is limited. But we still made the project public and want to share them with you so you can get a sense of what Automatisch can do. Meanwhile, we're working on adding more integrations and improving the existing ones.
:::
Following integrations are currently supported by Automatisch. Following integrations are currently supported by Automatisch.
- [DeepL](/apps/deepl/actions) - [DeepL](/apps/deepl/actions)

View File

@@ -95,6 +95,30 @@ export interface IUser {
connections: IConnection[]; connections: IConnection[];
flows: IFlow[]; flows: IFlow[];
steps: IStep[]; steps: IStep[];
role: IRole;
permissions: IPermission[];
}
export interface IRole {
id: string;
key: string;
name: string;
description: string;
isAdmin: boolean;
permissions: IPermission[];
}
export interface IPermission {
id: string;
action: string;
subject: string;
conditions: string[];
}
export interface IPermissionCatalog {
actions: { label: string; key: string; subjects: string[] }[];
subjects: { label: string; key: string; }[];
conditions: { label: string; key: string; }[];
} }
export interface IFieldDropdown { export interface IFieldDropdown {
@@ -386,6 +410,20 @@ type TInvoice = {
receipt_url: string; receipt_url: string;
}; };
type TSamlAuthProvider = {
id: string;
name: string;
certificate: string;
signatureAlgorithm: "sha1" | "sha256" | "sha512";
issuer: string;
entryPoint: string;
firstnameAttributeName: string;
surnameAttributeName: string;
emailAttributeName: string;
roleAttributeName: string;
defaultRoleId: string;
}
declare module 'axios' { declare module 'axios' {
interface AxiosResponse { interface AxiosResponse {
httpError?: IJSONObject; httpError?: IJSONObject;

View File

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

View File

@@ -6,6 +6,8 @@
"dependencies": { "dependencies": {
"@apollo/client": "^3.6.9", "@apollo/client": "^3.6.9",
"@automatisch/types": "^0.8.0", "@automatisch/types": "^0.8.0",
"@casl/ability": "^6.5.0",
"@casl/react": "^3.1.0",
"@emotion/react": "^11.4.1", "@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.8", "@hookform/resolvers": "^2.8.8",
@@ -31,7 +33,7 @@
"notistack": "^2.0.2", "notistack": "^2.0.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-hook-form": "^7.43.9", "react-hook-form": "^7.45.2",
"react-intl": "^5.20.12", "react-intl": "^5.20.12",
"react-json-tree": "^0.16.2", "react-json-tree": "^0.16.2",
"react-router-dom": "^6.0.2", "react-router-dom": "^6.0.2",

View File

@@ -0,0 +1,87 @@
import { Route, Navigate } from 'react-router-dom';
import AdminSettingsLayout from 'components/AdminSettingsLayout';
import Users from 'pages/Users';
import EditUser from 'pages/EditUser';
import CreateUser from 'pages/CreateUser';
import Roles from 'pages/Roles/index.ee';
import CreateRole from 'pages/CreateRole/index.ee';
import EditRole from 'pages/EditRole/index.ee';
import * as URLS from 'config/urls';
import Can from 'components/Can';
// TODO: consider introducing redirections to `/` as fallback
export default (
<>
<Route
path={URLS.USERS}
element={
<Can I="read" a="User">
<AdminSettingsLayout>
<Users />
</AdminSettingsLayout>
</Can>
}
/>
<Route
path={URLS.CREATE_USER}
element={
<Can I="create" a="User">
<AdminSettingsLayout>
<CreateUser />
</AdminSettingsLayout>
</Can>
}
/>
<Route
path={URLS.USER_PATTERN}
element={
<Can I="update" a="User">
<AdminSettingsLayout>
<EditUser />
</AdminSettingsLayout>
</Can>
}
/>
<Route
path={URLS.ROLES}
element={
<Can I="read" a="Role">
<AdminSettingsLayout>
<Roles />
</AdminSettingsLayout>
</Can>
}
/>
<Route
path={URLS.CREATE_ROLE}
element={
<Can I="create" a="Role">
<AdminSettingsLayout>
<CreateRole />
</AdminSettingsLayout>
</Can>
}
/>
<Route
path={URLS.ROLE_PATTERN}
element={
<Can I="update" a="Role">
<AdminSettingsLayout>
<EditRole />
</AdminSettingsLayout>
</Can>
}
/>
<Route
path={URLS.ADMIN_SETTINGS}
element={<Navigate to={URLS.USERS} replace />}
/>
</>
);

View File

@@ -4,6 +4,7 @@ import MenuItem from '@mui/material/MenuItem';
import Menu, { MenuProps } from '@mui/material/Menu'; import Menu, { MenuProps } from '@mui/material/Menu';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Can from 'components/Can';
import apolloClient from 'graphql/client'; import apolloClient from 'graphql/client';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import useAuthentication from 'hooks/useAuthentication'; import useAuthentication from 'hooks/useAuthentication';
@@ -54,6 +55,15 @@ function AccountDropdownMenu(
{formatMessage('accountDropdownMenu.settings')} {formatMessage('accountDropdownMenu.settings')}
</MenuItem> </MenuItem>
<Can I="read" a="User">
<MenuItem
component={Link}
to={URLS.ADMIN_SETTINGS_DASHBOARD}
>
{formatMessage('accountDropdownMenu.adminSettings')}
</MenuItem>
</Can>
<MenuItem onClick={logout} data-test="logout-item"> <MenuItem onClick={logout} data-test="logout-item">
{formatMessage('accountDropdownMenu.logout')} {formatMessage('accountDropdownMenu.logout')}
</MenuItem> </MenuItem>

View File

@@ -12,7 +12,7 @@ import useFormatMessage from 'hooks/useFormatMessage';
import computeAuthStepVariables from 'helpers/computeAuthStepVariables'; import computeAuthStepVariables from 'helpers/computeAuthStepVariables';
import { processStep } from 'helpers/authenticationSteps'; import { processStep } from 'helpers/authenticationSteps';
import InputCreator from 'components/InputCreator'; import InputCreator from 'components/InputCreator';
import { generateExternalLink } from '../../helpers/translation-values'; import { generateExternalLink } from '../../helpers/translationValues';
import { Form } from './style'; import { Form } from './style';
type AddAppConnectionProps = { type AddAppConnectionProps = {

View File

@@ -0,0 +1,92 @@
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import GroupIcon from '@mui/icons-material/Group';
import GroupsIcon from '@mui/icons-material/Groups';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import { useTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import * as React from 'react';
import { SvgIconComponent } from '@mui/icons-material';
import AppBar from 'components/AppBar';
import Drawer from 'components/Drawer';
import * as URLS from 'config/urls';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
type SettingsLayoutProps = {
children: React.ReactNode;
};
type DrawerLink = {
Icon: SvgIconComponent,
primary: string,
to: string,
}
function createDrawerLinks({ canReadRole, canReadUser }: { canReadRole: boolean; canReadUser: boolean; }) {
const items = [
canReadUser ? {
Icon: GroupIcon,
primary: 'adminSettingsDrawer.users',
to: URLS.USERS,
} : null,
canReadRole ? {
Icon: GroupsIcon,
primary: 'adminSettingsDrawer.roles',
to: URLS.ROLES,
} : null
]
.filter(Boolean) as DrawerLink[];
return items;
}
const drawerBottomLinks = [
{
Icon: ArrowBackIosNewIcon,
primary: 'adminSettingsDrawer.goBack',
to: '/',
},
];
export default function SettingsLayout({
children,
}: SettingsLayoutProps): React.ReactElement {
const theme = useTheme();
const currentUserAbility = useCurrentUserAbility();
const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg'));
const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
const drawerLinks = createDrawerLinks({
canReadUser: currentUserAbility.can('read', 'User'),
canReadRole: currentUserAbility.can('read', 'Role'),
});
return (
<>
<AppBar
drawerOpen={isDrawerOpen}
onDrawerOpen={openDrawer}
onDrawerClose={closeDrawer}
/>
<Box sx={{ display: 'flex' }}>
<Drawer
links={drawerLinks}
bottomLinks={drawerBottomLinks}
open={isDrawerOpen}
onOpen={openDrawer}
onClose={closeDrawer}
/>
<Box sx={{ flex: 1 }}>
<Toolbar />
{children}
</Box>
</Box>
</>
);
}

View File

@@ -0,0 +1,22 @@
import { Can as OriginalCan } from '@casl/react';
import * as React from 'react';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
type CanProps = {
I: string;
a: string;
passThrough?: boolean;
children: React.ReactNode | ((isAllowed: boolean) => React.ReactNode);
} | {
I: string;
an: string;
passThrough?: boolean;
children: React.ReactNode | ((isAllowed: boolean) => React.ReactNode);
};
export default function Can(props: CanProps) {
const currentUserAbility = useCurrentUserAbility();
return (<OriginalCan ability={currentUserAbility} {...props} />);
};

View File

@@ -19,6 +19,8 @@ export default function ConditionalIconButton(props: any): React.ReactElement {
type={buttonProps.type} type={buttonProps.type}
size={buttonProps.size} size={buttonProps.size}
component={buttonProps.component} component={buttonProps.component}
to={buttonProps.to}
disabled={buttonProps.disabled}
> >
{icon} {icon}
</IconButton> </IconButton>

View File

@@ -2,7 +2,7 @@ import { styled } from '@mui/material/styles';
import MuiIconButton, { iconButtonClasses } from '@mui/material/IconButton'; import MuiIconButton, { iconButtonClasses } from '@mui/material/IconButton';
export const IconButton = styled(MuiIconButton)` export const IconButton = styled(MuiIconButton)`
&.${iconButtonClasses.colorPrimary} { &.${iconButtonClasses.colorPrimary}:not(.${iconButtonClasses.disabled}) {
background: ${({ theme }) => theme.palette.primary.main}; background: ${({ theme }) => theme.palette.primary.main};
color: ${({ theme }) => theme.palette.primary.contrastText}; color: ${({ theme }) => theme.palette.primary.contrastText};

View File

@@ -0,0 +1,58 @@
import * as React from 'react';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
type ConfirmationDialogProps = {
onClose: () => void;
onConfirm: () => void;
title: React.ReactNode;
description: React.ReactNode;
cancelButtonChildren: React.ReactNode;
confirmButtionChildren: React.ReactNode;
open?: boolean;
}
export default function ConfirmationDialog(props: ConfirmationDialogProps) {
const {
onClose,
onConfirm,
title,
description,
cancelButtonChildren,
confirmButtionChildren,
open = true,
} = props;
return (
<Dialog open={open} onClose={onClose}>
{title && (
<DialogTitle>
{title}
</DialogTitle>
)}
{description && (
<DialogContent>
<DialogContentText>
{description}
</DialogContentText>
</DialogContent>
)}
<DialogActions>
{(cancelButtonChildren && onClose) && (
<Button onClick={onClose}>{cancelButtonChildren}</Button>
)}
{(confirmButtionChildren && onConfirm) && (
<Button onClick={onConfirm} color="error">
{confirmButtionChildren}
</Button>
)}
</DialogActions>
</Dialog>
);
}

View File

@@ -0,0 +1,57 @@
import * as React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
type ControlledCheckboxProps = {
name: string;
} & CheckboxProps;
export default function ControlledCheckbox(props: ControlledCheckboxProps): React.ReactElement {
const { control } = useFormContext();
const {
required,
name,
defaultValue = false,
disabled = false,
onBlur,
onChange,
...checkboxProps
} = props;
return (
<Controller
rules={{ required }}
name={name}
defaultValue={defaultValue}
control={control}
render={({
field: {
ref,
onChange: controllerOnChange,
onBlur: controllerOnBlur,
value,
name,
...field
},
}) => {
return (
<Checkbox
{...checkboxProps}
{...field}
checked={!!value}
name={name}
disabled={disabled}
onChange={(...args) => {
controllerOnChange(...args);
onChange?.(...args);
}}
onBlur={(...args) => {
controllerOnBlur();
onBlur?.(...args);
}}
inputRef={ref}
/>
)}}
/>
);
}

View File

@@ -1,16 +1,11 @@
import * as React from 'react'; import * as React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation } from '@apollo/client'; import { useMutation } from '@apollo/client';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
import ConfirmationDialog from 'components/ConfirmationDialog';
import apolloClient from 'graphql/client'; import apolloClient from 'graphql/client';
import { DELETE_USER } from 'graphql/mutations/delete-user.ee'; import { DELETE_CURRENT_USER } from 'graphql/mutations/delete-current-user.ee';
import useAuthentication from 'hooks/useAuthentication'; import useAuthentication from 'hooks/useAuthentication';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUser from 'hooks/useCurrentUser'; import useCurrentUser from 'hooks/useCurrentUser';
@@ -20,37 +15,29 @@ type DeleteAccountDialogProps = {
} }
export default function DeleteAccountDialog(props: DeleteAccountDialogProps) { export default function DeleteAccountDialog(props: DeleteAccountDialogProps) {
const [deleteUser] = useMutation(DELETE_USER); const [deleteCurrentUser] = useMutation(DELETE_CURRENT_USER);
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const currentUser = useCurrentUser(); const currentUser = useCurrentUser();
const authentication = useAuthentication(); const authentication = useAuthentication();
const navigate = useNavigate(); const navigate = useNavigate();
const handleConfirm = React.useCallback(async () => { const handleConfirm = React.useCallback(async () => {
await deleteUser(); await deleteCurrentUser();
authentication.updateToken(''); authentication.updateToken('');
await apolloClient.clearStore(); await apolloClient.clearStore();
navigate(URLS.LOGIN); navigate(URLS.LOGIN);
}, [deleteUser, currentUser]); }, [deleteCurrentUser, currentUser]);
return ( return (
<Dialog open onClose={props.onClose}> <ConfirmationDialog
<DialogTitle > title={formatMessage('deleteAccountDialog.title')}
{formatMessage('deleteAccountDialog.title')} description={formatMessage('deleteAccountDialog.description')}
</DialogTitle> onClose={props.onClose}
<DialogContent> onConfirm={handleConfirm}
<DialogContentText id="alert-dialog-description"> cancelButtonChildren={formatMessage('deleteAccountDialog.cancel')}
{formatMessage('deleteAccountDialog.description')} confirmButtionChildren={formatMessage('deleteAccountDialog.confirm')}
</DialogContentText> />
</DialogContent>
<DialogActions>
<Button onClick={props.onClose}>{formatMessage('deleteAccountDialog.cancel')}</Button>
<Button onClick={handleConfirm} color="error">
{formatMessage('deleteAccountDialog.confirm')}
</Button>
</DialogActions>
</Dialog>
); );
} }

View File

@@ -0,0 +1,65 @@
import * as React from 'react';
import { useMutation } from '@apollo/client';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import { useSnackbar } from 'notistack';
import Can from 'components/Can';
import ConfirmationDialog from 'components/ConfirmationDialog';
import { DELETE_ROLE } from 'graphql/mutations/delete-role.ee';
import useFormatMessage from 'hooks/useFormatMessage';
type DeleteRoleButtonProps = {
disabled?: boolean;
roleId: string;
};
export default function DeleteRoleButton(props: DeleteRoleButtonProps) {
const { disabled, roleId } = props;
const [showConfirmation, setShowConfirmation] = React.useState(false);
const [deleteRole] = useMutation(DELETE_ROLE, {
variables: { input: { id: roleId } },
refetchQueries: ['GetRoles'],
});
const formatMessage = useFormatMessage();
const { enqueueSnackbar } = useSnackbar();
const handleConfirm = React.useCallback(async () => {
try {
await deleteRole();
setShowConfirmation(false);
enqueueSnackbar(formatMessage('deleteRoleButton.successfullyDeleted'), {
variant: 'success',
});
} catch (error) {
throw new Error('Failed while deleting!');
}
}, [deleteRole]);
return (
<>
<Can I="delete" a="Role" passThrough>
{(allowed) => (
<IconButton
disabled={!allowed || disabled}
onClick={() => setShowConfirmation(true)}
size="small"
>
<DeleteIcon />
</IconButton>
)}
</Can>
<ConfirmationDialog
open={showConfirmation}
title={formatMessage('deleteRoleButton.title')}
description={formatMessage('deleteRoleButton.description')}
onClose={() => setShowConfirmation(false)}
onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteRoleButton.cancel')}
confirmButtionChildren={formatMessage('deleteRoleButton.confirm')}
/>
</>
);
}

View File

@@ -0,0 +1,55 @@
import * as React from 'react';
import { useMutation } from '@apollo/client';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import { useSnackbar } from 'notistack';
import ConfirmationDialog from 'components/ConfirmationDialog';
import { DELETE_USER } from 'graphql/mutations/delete-user.ee';
import useFormatMessage from 'hooks/useFormatMessage';
type DeleteUserButtonProps = {
userId: string;
};
export default function DeleteUserButton(props: DeleteUserButtonProps) {
const { userId } = props;
const [showConfirmation, setShowConfirmation] = React.useState(false);
const [deleteUser] = useMutation(DELETE_USER, {
variables: { input: { id: userId } },
refetchQueries: ['GetUsers'],
});
const formatMessage = useFormatMessage();
const { enqueueSnackbar } = useSnackbar();
const handleConfirm = React.useCallback(async () => {
try {
await deleteUser();
setShowConfirmation(false);
enqueueSnackbar(formatMessage('deleteUserButton.successfullyDeleted'), {
variant: 'success',
});
} catch (error) {
throw new Error('Failed while deleting!');
}
}, [deleteUser]);
return (
<>
<IconButton onClick={() => setShowConfirmation(true)} size="small">
<DeleteIcon />
</IconButton>
<ConfirmationDialog
open={showConfirmation}
title={formatMessage('deleteUserButton.title')}
description={formatMessage('deleteUserButton.description')}
onClose={() => setShowConfirmation(false)}
onConfirm={handleConfirm}
cancelButtonChildren={formatMessage('deleteUserButton.cancel')}
confirmButtionChildren={formatMessage('deleteUserButton.confirm')}
/>
</>
);
}

View File

@@ -6,6 +6,7 @@ import type { PopoverProps } from '@mui/material/Popover';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import Can from 'components/Can';
import { DELETE_FLOW } from 'graphql/mutations/delete-flow'; import { DELETE_FLOW } from 'graphql/mutations/delete-flow';
import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow'; import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow';
import * as URLS from 'config/urls'; import * as URLS from 'config/urls';
@@ -72,13 +73,39 @@ export default function ContextMenu(
hideBackdrop={false} hideBackdrop={false}
anchorEl={anchorEl} anchorEl={anchorEl}
> >
<MenuItem component={Link} to={URLS.FLOW(flowId)}> <Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem
disabled={!allowed}
component={Link}
to={URLS.FLOW(flowId)}
>
{formatMessage('flow.view')} {formatMessage('flow.view')}
</MenuItem> </MenuItem>
)}
</Can>
<MenuItem onClick={onFlowDuplicate}>{formatMessage('flow.duplicate')}</MenuItem> <Can I="create" a="Flow" passThrough>
{(allowed) => (
<MenuItem
disabled={!allowed}
onClick={onFlowDuplicate}
>
{formatMessage('flow.duplicate')}
</MenuItem>
)}
</Can>
<MenuItem onClick={onFlowDelete}>{formatMessage('flow.delete')}</MenuItem> <Can I="delete" a="Flow" passThrough>
{(allowed) => (
<MenuItem
disabled={!allowed}
onClick={onFlowDelete}
>
{formatMessage('flow.delete')}
</MenuItem>
)}
</Can>
</Menu> </Menu>
); );
} }

View File

@@ -0,0 +1,142 @@
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import * as React from 'react';
import { useFormContext } from 'react-hook-form';
import { IPermissionCatalog } from '@automatisch/types';
import ControlledCheckbox from 'components/ControlledCheckbox';
import useFormatMessage from 'hooks/useFormatMessage';
type PermissionSettingsProps = {
onClose: () => void;
fieldPrefix: string;
subject: string;
actions: IPermissionCatalog['actions'];
conditions: IPermissionCatalog['conditions'];
}
export default function PermissionSettings(props: PermissionSettingsProps) {
const {
onClose,
fieldPrefix,
subject,
actions,
conditions,
} = props;
const formatMessage = useFormatMessage();
const { getValues, resetField } = useFormContext();
const cancel = () => {
for (const action of actions) {
for (const condition of conditions) {
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
resetField(fieldName);
}
}
onClose();
}
const apply = () => {
for (const action of actions) {
for (const condition of conditions) {
const fieldName = `${fieldPrefix}.${action.key}.conditions.${condition.key}`;
const value = getValues(fieldName);
resetField(fieldName, { defaultValue: value });
}
}
onClose();
}
return (
<Dialog open onClose={cancel}>
<DialogTitle>
{formatMessage('permissionSettings.title')}
</DialogTitle>
<DialogContent>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell component="th" />
{actions.map(action => (
<TableCell component="th" key={action.key}>
<Typography
variant="subtitle1"
align="center"
sx={{
color: 'text.secondary',
fontWeight: 700
}}
>
{action.label}
</Typography>
</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{conditions.map((condition) => (
<TableRow
key={condition.key}
sx={{ '&:last-child td': { border: 0 } }}
>
<TableCell scope="row">
<Typography
variant="subtitle2"
>
{condition.label}
</Typography>
</TableCell>
{actions.map((action) => (
<TableCell
key={`${action.key}.${condition.key}`}
align="center"
>
<Typography
variant="subtitle2"
>
{action.subjects.includes(subject) && (
<ControlledCheckbox
name={`${fieldPrefix}.${action.key}.conditions.${condition.key}`}
disabled={getValues(`${fieldPrefix}.${action.key}.value`) !== true}
/>
)}
{!action.subjects.includes(subject) && '-'}
</Typography>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={cancel}>{formatMessage('permissionSettings.cancel')}</Button>
<Button onClick={apply} color="error">
{formatMessage('permissionSettings.apply')}
</Button>
</DialogActions>
</Dialog>
)
}

View File

@@ -0,0 +1,122 @@
import SettingsIcon from '@mui/icons-material/Settings';
import IconButton from '@mui/material/IconButton';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import * as React from 'react';
import ControlledCheckbox from 'components/ControlledCheckbox';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
import PermissionSettings from './PermissionSettings.ee';
type PermissionCatalogFieldProps = {
name?: string;
disabled?: boolean;
};
const PermissionCatalogField = ({ name = 'permissions', disabled = false }: PermissionCatalogFieldProps) => {
const permissionCatalog = usePermissionCatalog();
const [dialogName, setDialogName] = React.useState<string>();
if (!permissionCatalog) return (<React.Fragment />);
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell component="th" />
{permissionCatalog.actions.map(action => (
<TableCell component="th" key={action.key}>
<Typography
variant="subtitle1"
align="center"
sx={{
color: 'text.secondary',
fontWeight: 700
}}
>
{action.label}
</Typography>
</TableCell>
))}
<TableCell component="th" />
</TableRow>
</TableHead>
<TableBody>
{permissionCatalog.subjects.map((subject) => (
<TableRow
key={subject.key}
sx={{ '&:last-child td': { border: 0 } }}
>
<TableCell scope="row">
<Typography
variant="subtitle2"
>
{subject.label}
</Typography>
</TableCell>
{permissionCatalog.actions.map((action) => (
<TableCell
key={`${subject.key}.${action.key}`}
align="center"
>
<Typography
variant="subtitle2"
>
{action.subjects.includes(subject.key) && (
<ControlledCheckbox
disabled={disabled}
name={`${name}.${subject.key}.${action.key}.value`}
/>
)}
{!action.subjects.includes(subject.key) && '-'}
</Typography>
</TableCell>
))}
<TableCell>
<Stack
direction="row"
gap={1}
justifyContent="right"
>
<IconButton
color="info"
size="small"
onClick={() => setDialogName(subject.key)}
disabled={disabled}
>
<SettingsIcon />
</IconButton>
{dialogName === subject.key && (
<PermissionSettings
onClose={() => setDialogName('')}
fieldPrefix={`${name}.${subject.key}`}
subject={subject.key}
actions={permissionCatalog.actions}
conditions={permissionCatalog.conditions}
/>
)}
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)
};
export default PermissionCatalogField;

View File

@@ -0,0 +1,84 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/Edit';
import DeleteRoleButton from 'components/DeleteRoleButton/index.ee';
import useFormatMessage from 'hooks/useFormatMessage';
import useRoles from 'hooks/useRoles.ee';
import * as URLS from 'config/urls';
// TODO: introduce loading bar
export default function RoleList(): React.ReactElement {
const formatMessage = useFormatMessage();
const { roles } = useRoles();
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell component="th">
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', fontWeight: 700 }}
>
{formatMessage('roleList.name')}
</Typography>
</TableCell>
<TableCell component="th">
<Typography
variant="subtitle1"
sx={{ color: 'text.secondary', fontWeight: 700 }}
>
{formatMessage('roleList.description')}
</Typography>
</TableCell>
<TableCell component="th" />
</TableRow>
</TableHead>
<TableBody>
{roles.map((role) => (
<TableRow
key={role.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell scope="row">
<Typography variant="subtitle2">{role.name}</Typography>
</TableCell>
<TableCell scope="row">
<Typography variant="subtitle2">{role.description}</Typography>
</TableCell>
<TableCell>
<Stack direction="row" gap={1} justifyContent="right">
<IconButton
size="small"
component={Link}
to={URLS.ROLE(role.id)}
>
<EditIcon />
</IconButton>
<DeleteRoleButton disabled={role.isAdmin} roleId={role.id} />
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

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