Compare commits

..

44 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
Faruk AYDIN
067ec2eb9c Release v0.8.0 2023-08-02 15:07:27 +02:00
Faruk AYDIN
2d332b32d9 chore: Update version to 0.8.0 in Dockerfiles 2023-08-02 15:06:49 +02:00
Ömer Faruk Aydın
1d9ad2ba86 Merge pull request #1184 from automatisch/notion-find-database-item
feat(notion): add find database item action
2023-08-01 13:54:09 +02:00
Ömer Faruk Aydın
a28e2177f7 Merge pull request #1183 from automatisch/notion-create-page
feat(notion): add create page action
2023-08-01 13:44:47 +02:00
Ömer Faruk Aydın
18fe0df691 Merge pull request #1185 from automatisch/email-case-insensitive-login
fix(auth): allow login with case insensitive email
2023-07-31 17:05:01 +03:00
Ömer Faruk Aydın
8e21a06d99 Merge pull request #1186 from automatisch/gitlab-use-user-projects
fix(gitlab/list-projects): list projects the user has membership
2023-07-31 17:03:25 +03:00
Ali BARIN
2daf5473bb fix(gitlab/list-projects): list projects the user has membership 2023-07-31 16:00:27 +02:00
Ömer Faruk Aydın
928ff53adf Merge pull request #1187 from automatisch/fix-gitlab-github-names
fix: GitHub and GitLab app names
2023-07-31 16:54:20 +03:00
Faruk AYDIN
a71e95e6e5 fix: GitHub and GitLab app names 2023-07-31 15:47:06 +02:00
Ali BARIN
cb4a54b5cc fix(auth): allow login with case insensitive email 2023-07-30 14:59:16 +00:00
Rıdvan Akca
37e4524156 feat(notion): add find database item action 2023-07-27 14:39:57 +03:00
dependabot[bot]
9ac24ee051 chore(deps): bump word-wrap from 1.2.3 to 1.2.4
Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4.
- [Release notes](https://github.com/jonschlinkert/word-wrap/releases)
- [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4)

---
updated-dependencies:
- dependency-name: word-wrap
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-07-26 22:51:23 +02:00
Ömer Faruk Aydın
f28ccd559a Merge pull request #1177 from automatisch/notion-create-database-item
feat(notion): add create database item action
2023-07-25 14:04:08 +03:00
Ömer Faruk Aydın
8e84a93d8e Merge pull request #1166 from automatisch/create-worksheet
feat(google-sheets): add create worksheet action
2023-07-25 13:58:41 +03:00
Ömer Faruk Aydın
d871dec1b7 Merge pull request #1179 from automatisch/add-http-proxy-agent
fix(axios): incorporate http(s)-proxy-agents
2023-07-24 15:40:22 +03:00
Ömer Faruk Aydın
b133e1a197 Merge pull request #1176 from automatisch/compute-params
fix: allow colon while computing step parameters
2023-07-24 15:33:16 +03:00
Rıdvan Akca
9346a037b9 feat(notion): add create page action 2023-07-24 14:50:53 +03:00
Ali BARIN
89facbcddd fix(axios): incorporate http(s)-proxy-agents 2023-07-17 22:23:48 +00:00
Faruk AYDIN
53fef35638 fix: Allow colon while computing step parameters 2023-07-17 18:19:54 +02:00
Rıdvan Akca
bfe496a09b feat(notion): add create database item action 2023-07-17 16:23:00 +03:00
Rıdvan Akca
0dd444d50b feat(google-sheets): add create worksheet action 2023-06-30 19:03:54 +03:00
118 changed files with 2261 additions and 744 deletions

View File

@@ -4,7 +4,7 @@ WORKDIR /automatisch
RUN \
apk --no-cache add --virtual build-dependencies python3 build-base && \
yarn global add @automatisch/cli@0.7.1 --network-timeout 1000000 && \
yarn global add @automatisch/cli@0.8.0 --network-timeout 1000000 && \
rm -rf /usr/local/share/.cache/ && \
apk del build-dependencies

View File

@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1
FROM automatischio/automatisch:0.7.1
FROM automatischio/automatisch:0.8.0
WORKDIR /automatisch
RUN apk add --no-cache openssl dos2unix

View File

@@ -2,7 +2,7 @@
"packages": [
"packages/*"
],
"version": "0.7.1",
"version": "0.8.0",
"npmClient": "yarn",
"useWorkspaces": true,
"command": {

View File

@@ -3,36 +3,16 @@ import logger from '../../src/helpers/logger';
import client from './client';
import User from '../../src/models/user';
import Role from '../../src/models/role';
import Permission from '../../src/models/permission';
import '../../src/config/orm';
async function seedPermissionsIfNeeded() {
const existingPermissions = await Permission.query().limit(1).first();
if (!existingPermissions) return;
const getPermission = (subject: string, actions: string[]) => actions.map(action => ({ subject, action }));
await Permission.query().insert([
...getPermission('Connection', ['create', 'read', 'delete', 'update']),
...getPermission('Execution', ['read']),
...getPermission('Flow', ['create', 'delete', 'publish', 'read', 'update']),
...getPermission('Role', ['create', 'delete', 'read', 'update']),
...getPermission('User', ['create', 'delete', 'read', 'update']),
])
}
async function createOrFetchRole() {
const role = await Role.query().limit(1).first();
if (!role) {
const createdRole = await Role.query().insertAndFetch({
name: 'Admin',
key: 'admin',
});
return createdRole;
}
async function fetchAdminRole() {
const role = await Role
.query()
.where({
key: 'admin'
})
.limit(1)
.first();
return role;
}
@@ -43,9 +23,7 @@ export async function createUser(
) {
const UNIQUE_VIOLATION_CODE = '23505';
await seedPermissionsIfNeeded();
const role = await createOrFetchRole();
const role = await fetchAdminRole();
const userParams = {
email,
password,

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/backend",
"version": "0.7.1",
"version": "0.8.0",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"scripts": {
@@ -22,7 +22,7 @@
"prebuild": "rm -rf ./dist"
},
"dependencies": {
"@automatisch/web": "^0.7.1",
"@automatisch/web": "^0.8.0",
"@bull-board/express": "^3.10.1",
"@casl/ability": "^6.5.0",
"@graphql-tools/graphql-file-loader": "^7.3.4",
@@ -53,6 +53,8 @@
"graphql-type-json": "^0.3.2",
"handlebars": "^4.7.7",
"http-errors": "~1.6.3",
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.0",
"lodash.get": "^4.4.2",
@@ -106,7 +108,7 @@
"url": "https://github.com/automatisch/automatisch/issues"
},
"devDependencies": {
"@automatisch/types": "^0.7.1",
"@automatisch/types": "^0.8.0",
"@types/bcrypt": "^5.0.0",
"@types/bull": "^3.15.8",
"@types/cors": "^2.8.12",

View File

@@ -6,7 +6,7 @@ import actions from './actions';
import dynamicData from './dynamic-data';
export default defineApp({
name: 'Github',
name: 'GitHub',
key: 'github',
baseUrl: 'https://github.com',
apiBaseUrl: 'https://api.github.com',

View File

@@ -9,11 +9,11 @@ export default {
// ref:
// - https://docs.gitlab.com/ee/api/projects.html#list-all-projects
// - https://docs.gitlab.com/ee/api/rest/index.html#keyset-based-pagination
const firstPageRequest = $.http.get('/api/v4/projects', {
params: {
simple: true,
pagination: 'keyset',
membership: true,
order_by: 'id',
sort: 'asc',
},

View File

@@ -6,7 +6,7 @@ import triggers from './triggers';
import dynamicData from './dynamic-data';
export default defineApp({
name: 'Gitlab',
name: 'GitLab',
key: 'gitlab',
baseUrl: 'https://gitlab.com',
apiBaseUrl: 'https://gitlab.com',

View File

@@ -0,0 +1,191 @@
import { IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
type THeaders = {
__id: string;
header: string;
}[];
type TSheetsResponse = {
sheets: {
properties: {
sheetId: string;
title: string;
};
}[];
};
type TBody = {
requests: IJSONObject[];
};
export default defineAction({
name: 'Create worksheet',
key: 'createWorksheet',
description:
'Create a blank worksheet with a title. Optionally, provide headers.',
arguments: [
{
label: 'Drive',
key: 'driveId',
type: 'dropdown' as const,
required: false,
description:
'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.',
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listDrives',
},
],
},
},
{
label: 'Spreadsheet',
key: 'spreadsheetId',
type: 'dropdown' as const,
required: true,
dependsOn: ['parameters.driveId'],
variables: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listSpreadsheets',
},
{
name: 'parameters.driveId',
value: '{parameters.driveId}',
},
],
},
},
{
label: 'Title',
key: 'title',
type: 'string' as const,
required: true,
description: '',
variables: true,
},
{
label: 'Headers',
key: 'headers',
type: 'dynamic' as const,
required: false,
fields: [
{
label: 'Header',
key: 'header',
type: 'string' as const,
required: true,
variables: true,
},
],
},
{
label: 'Overwrite',
key: 'overwrite',
type: 'dropdown' as const,
required: false,
value: false,
description:
'If a worksheet with the specified title exists, its content would be lost. Please, use with caution.',
variables: true,
options: [
{
label: 'Yes',
value: 'true',
},
{
label: 'No',
value: 'false',
},
],
},
],
async run($) {
const {
data: { sheets },
} = await $.http.get<TSheetsResponse>(
`/v4/spreadsheets/${$.step.parameters.spreadsheetId}`
);
const selectedSheet = sheets.find(
(sheet) => sheet.properties.title === $.step.parameters.title
);
const headers = $.step.parameters.headers as THeaders;
const values = headers.map((entry) => entry.header);
const body: TBody = {
requests: [
{
addSheet: {
properties: {
title: $.step.parameters.title,
},
},
},
],
};
if ($.step.parameters.overwrite === 'true' && selectedSheet) {
body.requests.unshift({
deleteSheet: {
sheetId: selectedSheet.properties.sheetId,
},
});
}
const { data } = await $.http.post(
`https://sheets.googleapis.com/v4/spreadsheets/${$.step.parameters.spreadsheetId}:batchUpdate`,
body
);
if (values.length) {
const body = {
requests: [
{
updateCells: {
rows: [
{
values: values.map((header) => ({
userEnteredValue: { stringValue: header },
})),
},
],
fields: '*',
start: {
sheetId:
data.replies[data.replies.length - 1].addSheet.properties
.sheetId,
rowIndex: 0,
columnIndex: 0,
},
},
},
],
};
const { data: response } = await $.http.post(
`https://sheets.googleapis.com/v4/spreadsheets/${$.step.parameters.spreadsheetId}:batchUpdate`,
body
);
$.setActionItem({
raw: response,
});
return;
}
$.setActionItem({
raw: data,
});
},
});

View File

@@ -1,4 +1,5 @@
import createSpreadsheet from './create-spreadsheet';
import createSpreadsheetRow from './create-spreadsheet-row';
import createWorksheet from './create-worksheet';
export default [createSpreadsheet, createSpreadsheetRow];
export default [createSpreadsheet, createSpreadsheetRow, createWorksheet];

View File

@@ -0,0 +1,100 @@
import { IJSONArray, IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
type TBody = {
parent: IJSONObject;
properties: IJSONObject;
children: IJSONArray;
};
export default defineAction({
name: 'Create database item',
key: 'createDatabaseItem',
description: 'Creates an item in a database.',
arguments: [
{
label: 'Database',
key: 'databaseId',
type: 'dropdown' as const,
required: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listDatabases',
},
],
},
},
{
label: 'Name',
key: 'name',
type: 'string' as const,
required: false,
description:
'This field has a 2000 character limit. Any characters beyond 2000 will not be included.',
variables: true,
},
{
label: 'Content',
key: 'content',
type: 'string' as const,
required: false,
description:
'The text to add to the page body. The max length for this field is 2000 characters. Any characters beyond 2000 will not be included.',
variables: true,
},
],
async run($) {
const name = $.step.parameters.name as string;
const truncatedName = name.slice(0, 2000);
const content = $.step.parameters.content as string;
const truncatedContent = content.slice(0, 2000);
const body: TBody = {
parent: {
database_id: $.step.parameters.databaseId,
},
properties: {},
children: [],
};
if (name) {
body.properties.Name = {
title: [
{
text: {
content: truncatedName,
},
},
],
};
}
if (content) {
body.children = [
{
object: 'block',
paragraph: {
rich_text: [
{
text: {
content: truncatedContent,
},
},
],
},
},
];
}
const { data } = await $.http.post('/v1/pages', body);
$.setActionItem({
raw: data,
});
},
});

View File

@@ -0,0 +1,104 @@
import { IJSONArray, IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
type TBody = {
parent: IJSONObject;
properties: IJSONObject;
children: IJSONArray;
};
export default defineAction({
name: 'Create page',
key: 'createPage',
description: 'Creates a page inside a parent page',
arguments: [
{
label: 'Parent page',
key: 'parentPageId',
type: 'dropdown' as const,
required: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listParentPages',
},
],
},
},
{
label: 'Title',
key: 'title',
type: 'string' as const,
required: false,
description:
'This field has a 2000 character limit. Any characters beyond 2000 will not be included.',
variables: true,
},
{
label: 'Content',
key: 'content',
type: 'string' as const,
required: false,
description:
'The text to add to the page body. The max length for this field is 2000 characters. Any characters beyond 2000 will not be included.',
variables: true,
},
],
async run($) {
const parentPageId = $.step.parameters.parentPageId as string;
const title = $.step.parameters.title as string;
const truncatedTitle = title.slice(0, 2000);
const content = $.step.parameters.content as string;
const truncatedContent = content.slice(0, 2000);
const body: TBody = {
parent: {
page_id: parentPageId,
},
properties: {},
children: [],
};
if (title) {
body.properties.title = {
type: 'title',
title: [
{
text: {
content: truncatedTitle,
},
},
],
};
}
if (content) {
body.children = [
{
object: 'block',
type: 'paragraph',
paragraph: {
rich_text: [
{
type: 'text',
text: {
content: truncatedContent,
},
},
],
},
},
];
}
const { data } = await $.http.post('/v1/pages', body);
$.setActionItem({
raw: data,
});
},
});

View File

@@ -0,0 +1,70 @@
import { IJSONArray, IJSONObject } from '@automatisch/types';
import defineAction from '../../../../helpers/define-action';
type TBody = {
filter: IJSONObject;
sorts: IJSONArray;
};
export default defineAction({
name: 'Find database item',
key: 'findDatabaseItem',
description: 'Searches for an item in a database by property.',
arguments: [
{
label: 'Database',
key: 'databaseId',
type: 'dropdown' as const,
required: true,
source: {
type: 'query',
name: 'getDynamicData',
arguments: [
{
name: 'key',
value: 'listDatabases',
},
],
},
},
{
label: 'Name',
key: 'name',
type: 'string' as const,
required: false,
description:
'This field has a 2000 character limit. Any characters beyond 2000 will not be included.',
variables: true,
},
],
async run($) {
const databaseId = $.step.parameters.databaseId as string;
const name = $.step.parameters.name as string;
const truncatedName = name.slice(0, 2000);
const body: TBody = {
filter: {
property: 'Name',
rich_text: {
equals: truncatedName,
},
},
sorts: [
{
timestamp: 'last_edited_time',
direction: 'descending',
},
],
};
const { data } = await $.http.post(
`/v1/databases/${databaseId}/query`,
body
);
$.setActionItem({
raw: data.results[0],
});
},
});

View File

@@ -0,0 +1,5 @@
import createDatabaseItem from './create-database-item';
import createPage from './create-page';
import findDatabaseItem from './find-database-item';
export default [createDatabaseItem, createPage, findDatabaseItem];

View File

@@ -1,3 +1,4 @@
import listDatabases from './list-databases';
import listParentPages from './list-parent-pages';
export default [listDatabases];
export default [listDatabases, listParentPages];

View File

@@ -0,0 +1,70 @@
import { IGlobalVariable, IJSONObject } from '@automatisch/types';
type Page = {
id: string;
properties: {
title: {
title: [
{
plain_text: string;
}
];
};
};
parent: {
workspace: boolean;
};
};
type ResponseData = {
results: Page[];
next_cursor?: string;
};
type Payload = {
filter: {
value: 'page';
property: 'object';
};
start_cursor?: string;
};
export default {
name: 'List parent pages',
key: 'listParentPages',
async run($: IGlobalVariable) {
const parentPages: {
data: IJSONObject[];
error: IJSONObject | null;
} = {
data: [],
error: null,
};
const payload: Payload = {
filter: {
value: 'page',
property: 'object',
},
};
do {
const response = await $.http.post<ResponseData>('/v1/search', payload);
payload.start_cursor = response.data.next_cursor;
const topLevelPages = response.data.results.filter(
(page) => page.parent.workspace
);
for (const pages of topLevelPages) {
parentPages.data.push({
value: pages.id as string,
name: pages.properties.title.title[0].plain_text as string,
});
}
} while (payload.start_cursor);
return parentPages;
},
};

View File

@@ -3,6 +3,7 @@ import addAuthHeader from './common/add-auth-header';
import addNotionVersionHeader from './common/add-notion-version-header';
import auth from './auth';
import triggers from './triggers';
import actions from './actions';
import dynamicData from './dynamic-data';
export default defineApp({
@@ -14,11 +15,9 @@ export default defineApp({
authDocUrl: 'https://automatisch.io/docs/apps/notion/connection',
primaryColor: '000000',
supportsConnections: true,
beforeRequest: [
addAuthHeader,
addNotionVersionHeader,
],
beforeRequest: [addAuthHeader, addNotionVersionHeader],
auth,
triggers,
actions,
dynamicData,
});

View File

@@ -16,13 +16,27 @@ export async function up(knex: Knex): Promise<void> {
.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: lowerCase(role),
key: lowerCaseRole,
});
}
if (shouldCreateAdminRole) {
await knex('roles').insert({
name: 'Admin',
key: 'admin',
});
}
}

View File

@@ -1,23 +1,46 @@
import { Knex } from 'knex';
const getPermission = (subject: string, actions: string[]) => actions.map(action => ({ subject, action }));
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([
...getPermission('Connection', ['create', 'read', 'delete', 'update']),
...getPermission('Execution', ['read']),
...getPermission('Flow', ['create', 'delete', 'publish', 'read', 'update']),
...getPermission('Role', ['create', 'delete', 'read', 'update']),
...getPermission('User', ['create', 'delete', 'read', 'update']),
...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> {

View File

@@ -1,25 +0,0 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('roles_permissions', (table) => {
table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('role_id').references('id').inTable('roles');
table.uuid('permission_id').references('id').inTable('permissions');
});
const roles = await knex('roles').select('id');
const permissions = await knex('permissions').select('id');
for (const role of roles) {
for (const permission of permissions) {
await knex('roles_permissions').insert({
role_id: role.id,
permission_id: permission.id,
});
}
}
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.dropTable('roles_permissions');
}

View File

@@ -13,6 +13,7 @@ export async function up(knex: Knex): Promise<void> {
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);
});

View File

@@ -1,13 +0,0 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
return knex.schema.alterTable('permissions', (table) => {
table.jsonb('conditions').notNullable().defaultTo([]);
});
}
export async function down(knex: Knex): Promise<void> {
return knex.schema.alterTable('permissions', (table) => {
table.dropColumn('conditions');
});
}

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

@@ -25,6 +25,8 @@ import updateRole from './mutations/update-role.ee';
import updateStep from './mutations/update-step';
import updateUser from './mutations/update-user.ee';
import verifyConnection from './mutations/verify-connection';
import createSamlAuthProvider from './mutations/create-saml-auth-provider.ee';
import updateSamlAuthProvider from './mutations/update-saml-auth-provider.ee';
const mutationResolvers = {
createConnection,
@@ -54,6 +56,8 @@ const mutationResolvers = {
updateRole,
updateStep,
verifyConnection,
createSamlAuthProvider,
updateSamlAuthProvider,
};
export default mutationResolvers;

View File

@@ -1,6 +1,7 @@
import kebabCase from 'lodash/kebabCase';
import Role from '../../models/role';
import Permission from '../../models/permission';
import Role from '../../models/role';
import Context from '../../types/express/context';
type Params = {
input: {
@@ -10,8 +11,9 @@ type Params = {
};
};
// TODO: access
const createRole = async (_parent: unknown, params: Params) => {
const createRole = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('create', 'Role');
const { name, description, permissions } = params.input;
const key = kebabCase(name);

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

@@ -1,17 +1,22 @@
import User from '../../models/user';
import Role from '../../models/role';
import Context from '../../types/express/context';
type Params = {
input: {
fullName: string;
email: string;
password: string;
roleId: string;
role: {
id: string;
};
};
};
// TODO: access
const createUser = async (_parent: unknown, params: Params) => {
const { fullName, email, password, roleId } = params.input;
const createUser = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('create', 'User');
const { fullName, email, password } = params.input;
const existingUser = await User.query().findOne({ email });
@@ -19,12 +24,23 @@ const createUser = async (_parent: unknown, params: Params) => {
throw new Error('User already exists!');
}
const user = await User.query().insert({
const userPayload: Partial<User> = {
fullName,
email,
password,
roleId,
});
};
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;
};

View File

@@ -2,8 +2,7 @@ import { Duration } from 'luxon';
import Context from '../../types/express/context';
import deleteUserQueue from '../../queues/delete-user.ee';
// TODO: access
const deleteUser = async (_parent: unknown, params: never, context: Context) => {
const deleteCurrentUser = async (_parent: unknown, params: never, context: Context) => {
const id = context.currentUser.id;
await context.currentUser.$query().delete();
@@ -20,4 +19,4 @@ const deleteUser = async (_parent: unknown, params: never, context: Context) =>
return true;
};
export default deleteUser;
export default deleteCurrentUser;

View File

@@ -1,4 +1,5 @@
import Role from '../../models/role';
import SamlAuthProvider from '../../models/saml-auth-provider.ee';
import Context from '../../types/express/context';
type Params = {
@@ -7,22 +8,37 @@ type Params = {
};
};
// TODO: access
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!');
}
/**
* TODO: consider migrations for users that still have the role or
* do not let the role get deleted if there are still associated users
*/
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;

View File

@@ -9,12 +9,13 @@ type Params = {
};
};
// TODO: access
const deleteUser = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('delete', 'User');
const id = params.input.id;
await User.query().deleteById(id);

View File

@@ -13,7 +13,7 @@ const TOKEN_EXPIRES_IN = '14d';
const login = async (_parent: unknown, params: Params) => {
const user = await User.query().findOne({
email: params.input.email,
email: params.input.email.toLowerCase(),
});
if (user && (await user.login(params.input.password))) {

View File

@@ -1,6 +1,7 @@
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: {
@@ -11,12 +12,13 @@ type Params = {
};
};
// TODO: access
const updateUser = async (
const updateRole = async (
_parent: unknown,
params: Params,
context: Context
) => {
context.currentUser.can('update', 'Role');
const {
id,
name,
@@ -24,21 +26,66 @@ const updateUser = async (
permissions,
} = params.input;
const role = await Role.query().findById(id).throwIfNotFound();
const role = await Role
.query()
.findById(id)
.throwIfNotFound();
// TODO: delete the unrelated items!
await role.$relatedQuery('permissions').unrelate();
try {
const updatedRole = await Role.transaction(async (trx) => {
await role.$relatedQuery('permissions', trx).delete();
// TODO: possibly assert that given permissions do actually exist in catalog
// TODO: possibly optimize it with patching the different permissions compared to current ones
return await role.$query()
.patchAndFetch(
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,
permissions,
}
);
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 updateUser;
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

@@ -12,20 +12,29 @@ type Params = {
};
};
// TODO: access
const updateUser = async (
_parent: unknown,
params: Params,
context: Context
) => {
const user = await User.query()
.patchAndFetchById(
params.input.id,
{
context.currentUser.can('update', 'User');
const userPayload: Partial<User> = {
email: params.input.email,
fullName: params.input.fullName,
roleId: params.input.role.id,
};
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;

View File

@@ -1,4 +1,5 @@
import App from '../../models/app';
import Connection from '../../models/connection';
import Context from '../../types/express/context';
type Params = {
@@ -6,13 +7,17 @@ type Params = {
};
const getApp = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'Connection');
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);
if (context.currentUser) {
const connections = await context.currentUser
.$relatedQuery('connections')
const connections = await connectionBaseQuery
.clone()
.select('connections.*')
.fullOuterJoinRelated('steps')
.where({

View File

@@ -1,6 +1,8 @@
import { IConnection } from '@automatisch/types';
import App from '../../models/app';
import Context from '../../types/express/context';
import Flow from '../../models/flow';
import Connection from '../../models/connection';
type Params = {
name: string;
@@ -11,19 +13,27 @@ const getConnectedApps = async (
params: Params,
context: Context
) => {
context.currentUser.can('read', 'Connection');
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);
const connections = await context.currentUser
.$relatedQuery('connections')
const connections = await connectionBaseQuery
.clone()
.select('connections.key')
.where({ draft: false })
.count('connections.id as count')
.groupBy('connections.key');
const flows = await context.currentUser
.$relatedQuery('flows')
const flows = await flowBaseQuery
.clone()
.withGraphJoined('steps')
.orderBy('created_at', 'desc');

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { raw } from 'objection';
import Context from '../../types/express/context';
import Execution from '../../models/execution';
import paginate from '../../helpers/pagination';
type Params = {
@@ -12,7 +13,11 @@ const getExecutions = async (
params: Params,
context: Context
) => {
context.currentUser.can('read', 'Execution');
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 = `
case
@@ -23,8 +28,8 @@ const getExecutions = async (
as status
`;
const executions = context.currentUser
.$relatedQuery('executions')
const executions = executionBaseQuery
.clone()
.joinRelated('executionSteps as execution_steps')
.select('executions.*', raw(selectStatusStatement))
.withSoftDeleted()

View File

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

View File

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

@@ -1,76 +0,0 @@
const getPermissions = async () => {
const Connection = {
label: 'Connection',
key: 'Connection',
};
const Flow = {
label: 'Flow',
key: 'Flow',
};
const Execution = {
label: 'Execution',
key: 'Execution',
};
const permissions = {
conditions: [
{
key: 'isCreator',
label: 'Is creator'
}
],
actions: [
{
label: 'Create',
action: 'create',
subjects: [
Connection.key,
Flow.key,
]
},
{
label: 'Read',
action: 'read',
subjects: [
Connection.key,
Execution.key,
Flow.key,
]
},
{
label: 'Update',
action: 'update',
subjects: [
Connection.key,
Flow.key,
]
},
{
label: 'Delete',
action: 'delete',
subjects: [
Connection.key,
Flow.key,
]
},
{
label: 'Publish',
action: 'publish',
subjects: [
Flow.key,
]
}
],
subjects: [
Connection,
Flow,
Execution
]
};
return permissions;
};
export default getPermissions;

View File

@@ -5,9 +5,19 @@ type Params = {
id: string
};
// TODO: access
const getRole = async (_parent: unknown, params: Params, context: Context) => {
return await Role.query().findById(params.id).throwIfNotFound();
context.currentUser.can('read', 'Role');
return await Role
.query()
.leftJoinRelated({
permissions: true
})
.withGraphFetched({
permissions: true
})
.findById(params.id)
.throwIfNotFound();
};
export default getRole;

View File

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

View File

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

View File

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

View File

@@ -5,8 +5,9 @@ type Params = {
id: string
};
// TODO: access
const getUser = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'User');
return await User
.query()
.leftJoinRelated({

View File

@@ -7,8 +7,9 @@ type Params = {
offset: number;
};
// TODO: access
const getUsers = async (_parent: unknown, params: Params, context: Context) => {
context.currentUser.can('read', 'User');
const usersQuery = User
.query()
.leftJoinRelated({

View File

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

View File

@@ -16,7 +16,7 @@ import getUsers from './queries/get-users';
import getInvoices from './queries/get-invoices.ee';
import getPaddleInfo from './queries/get-paddle-info.ee';
import getPaymentPlans from './queries/get-payment-plans.ee';
import getPermissions from './queries/get-permissions.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';
@@ -43,7 +43,7 @@ const queryResolvers = {
getInvoices,
getPaddleInfo,
getPaymentPlans,
getPermissions,
getPermissionCatalog,
getRole,
getRoles,
getSamlAuthProviders,

View File

@@ -42,14 +42,11 @@ type Query {
getTrialStatus: GetTrialStatus
getSubscriptionStatus: GetSubscriptionStatus
getSamlAuthProviders: [GetSamlAuthProviders]
getUsers(
limit: Int!
offset: Int!
): UserConnection
getUsers(limit: Int!, offset: Int!): UserConnection
getUser(id: String!): User
getRoles: [Role]
getRole(id: String!): Role
getPermissions: Permissions
getPermissionCatalog: PermissionCatalog
healthcheck: AppHealth
}
@@ -81,6 +78,8 @@ type Mutation {
updateStep(input: UpdateStepInput): Step
updateUser(input: UpdateUserInput): User
verifyConnection(input: VerifyConnectionInput): Connection
createSamlAuthProvider(input: CreateSamlAuthProviderInput): SamlAuthProvider
updateSamlAuthProvider(input: UpdateSamlAuthProviderInput): SamlAuthProvider
}
"""
@@ -292,6 +291,20 @@ type Execution {
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
@@ -323,6 +336,34 @@ input VerifyConnectionInput {
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 {
id: String!
}
@@ -640,13 +681,13 @@ type GetSamlAuthProviders {
}
type Permission {
id: String
action: String
subject: String
conditions: [String]
}
# TODO: emphasize it's a catalog item
type Permissions {
type PermissionCatalog {
actions: [Action]
subjects: [Subject]
conditions: [Condition]
@@ -654,7 +695,7 @@ type Permissions {
type Action {
label: String
action: String
key: String
subjects: [String]
}

View File

@@ -0,0 +1,24 @@
import axios, { AxiosRequestConfig } from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { HttpProxyAgent } from 'http-proxy-agent';
const config: AxiosRequestConfig = {};
const httpProxyUrl = process.env.http_proxy;
const httpsProxyUrl = process.env.https_proxy;
const supportsProxy = httpProxyUrl || httpsProxyUrl;
if (supportsProxy) {
if (httpProxyUrl) {
config.httpAgent = new HttpProxyAgent(process.env.http_proxy);
}
if (httpsProxyUrl) {
config.httpsAgent = new HttpsProxyAgent(process.env.https_proxy);
}
config.proxy = false;
}
const axiosWithProxyInstance = axios.create(config);
export default axiosWithProxyInstance;

View File

@@ -1,3 +1,4 @@
// TODO: replace with axios-with-proxy when needed
import axios from 'axios';
import appConfig from '../../config/app';
import { DateTime } from 'luxon';

View File

@@ -1,3 +1,4 @@
// TODO: replace with axios-with-proxy
import axios from 'axios';
import appConfig from '../config/app';
import memoryCache from 'memory-cache';

View File

@@ -3,7 +3,7 @@ import ExecutionStep from '../models/execution-step';
import get from 'lodash.get';
// INFO: don't remove space in allowed character group!
const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[\da-zA-Z-_ ]+)+}})/g;
const variableRegExp = /({{step\.[\da-zA-Z-]+(?:\.[\da-zA-Z-_ :]+)+}})/g;
export default function computeParameters(
parameters: Step['parameters'],
@@ -42,7 +42,7 @@ export default function computeParameters(
if (Array.isArray(value)) {
return {
...result,
[key]: value.map(item => computeParameters(item, executionSteps)),
[key]: value.map((item) => computeParameters(item, executionSteps)),
};
}

View File

@@ -1,8 +1,10 @@
import axios, { AxiosRequestConfig } from 'axios';
export { AxiosInstance as IHttpClient } from 'axios';
import { IHttpClientParams } from '@automatisch/types';
import { URL } from 'url';
import { AxiosRequestConfig } from 'axios';
import { URL } from 'node:url';
export { AxiosInstance as IHttpClient } from 'axios';
import HttpError from '../../errors/http';
import axios from '../axios-with-proxy';
const removeBaseUrlForAbsoluteUrls = (
requestConfig: AxiosRequestConfig

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

@@ -2,6 +2,7 @@ import Base from './base';
class Permission extends Base {
id: string;
roleId: string;
action: string;
subject: string;
conditions: string[];
@@ -10,10 +11,11 @@ class Permission extends Base {
static jsonSchema = {
type: 'object',
required: ['action', 'subject'],
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' } },

View File

@@ -20,7 +20,7 @@ class Role extends Base {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
key: { type: 'string', minLength: 1 },
description: { type: ['string', 'null'], minLength: 1, maxLength: 255 },
description: { type: ['string', 'null'], maxLength: 255 },
createdAt: { type: 'string' },
updatedAt: { type: 'string' },
},
@@ -40,15 +40,11 @@ class Role extends Base {
},
},
permissions: {
relation: Base.ManyToManyRelation,
relation: Base.HasManyRelation,
modelClass: Permission,
join: {
from: 'roles.id',
through: {
from: 'roles_permissions.role_id',
to: 'roles_permissions.permission_id',
},
to: 'permissions.id',
to: 'permissions.role_id',
},
},
});

View File

@@ -8,7 +8,7 @@ class SamlAuthProvider extends Base {
id!: string;
name: string;
certificate: string;
signatureAlgorithm: SamlConfig["signatureAlgorithm"];
signatureAlgorithm: SamlConfig['signatureAlgorithm'];
issuer: string;
entryPoint: string;
firstnameAttributeName: string;
@@ -16,6 +16,7 @@ class SamlAuthProvider extends Base {
emailAttributeName: string;
roleAttributeName: string;
defaultRoleId: string;
active: boolean;
static tableName = 'saml_auth_providers';
@@ -38,14 +39,18 @@ class SamlAuthProvider extends Base {
id: { type: 'string', format: 'uuid' },
name: { type: 'string', minLength: 1 },
certificate: { type: 'string', minLength: 1 },
signatureAlgorithm: { type: 'string', enum: ['sha1', 'sha256', 'sha512'] },
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' }
defaultRoleId: { type: 'string', format: 'uuid' },
active: { type: 'boolean' },
},
};
@@ -72,7 +77,7 @@ class SamlAuthProvider extends Base {
entryPoint: this.entryPoint,
issuer: this.issuer,
signatureAlgorithm: this.signatureAlgorithm,
}
};
}
}

View File

@@ -1,22 +1,22 @@
import crypto from 'node:crypto';
import { QueryContext, ModelOptions } from 'objection';
import bcrypt from 'bcrypt';
import { DateTime } from 'luxon';
import { PureAbility, fieldPatternMatcher, mongoQueryMatcher } from '@casl/ability';
import type { Subject } from '@casl/ability';
import crypto from 'node:crypto';
import { ModelOptions, QueryContext } from 'objection';
import appConfig from '../config/app';
import checkLicense from '../helpers/check-license.ee';
import userAbility from '../helpers/user-ability';
import Base from './base';
import ExtendedQueryBuilder from './query-builder';
import Connection from './connection';
import Flow from './flow';
import Step from './step';
import Role from './role';
import Permission from './permission';
import Execution from './execution';
import Flow from './flow';
import Identity from './identity.ee';
import UsageData from './usage-data.ee';
import Permission from './permission';
import ExtendedQueryBuilder from './query-builder';
import Role from './role';
import Step from './step';
import Subscription from './subscription.ee';
import UsageData from './usage-data.ee';
class User extends Base {
id!: string;
@@ -148,15 +148,11 @@ class User extends Base {
},
},
permissions: {
relation: Base.ManyToManyRelation,
relation: Base.HasManyRelation,
modelClass: Permission,
join: {
from: 'users.role_id',
through: {
from: 'roles_permissions.role_id',
to: 'roles_permissions.permission_id',
},
to: 'permissions.id',
to: 'permissions.role_id',
},
},
identities: {
@@ -165,8 +161,8 @@ class User extends Base {
join: {
from: 'identities.user_id',
to: 'users.id',
}
}
},
},
});
login(password: string) {
@@ -292,27 +288,44 @@ class User extends Base {
}
}
get ability() {
if (!this.permissions) {
throw new Error('User.permissions must be fetched!');
}
async $afterFind(): Promise<any> {
const hasValidLicense = await checkLicense();
// We're not using mongo, but our fields, conditions match
return new PureAbility(this.permissions, {
conditionsMatcher: mongoQueryMatcher,
fieldMatcher: fieldPatternMatcher
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;
});
}
can(action: string, subject: Subject) {
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!');
return can;
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: Subject) {
cannot(action: string, subject: string) {
const cannot = this.ability.cannot(action, subject);
if (cannot) throw new Error('Not authorized!');

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/cli",
"version": "0.7.1",
"version": "0.8.0",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"contributors": [
@@ -33,7 +33,7 @@
"version": "oclif readme && git add README.md"
},
"dependencies": {
"@automatisch/backend": "^0.7.1",
"@automatisch/backend": "^0.8.0",
"@oclif/core": "^1",
"@oclif/plugin-help": "^5",
"@oclif/plugin-plugins": "^2.0.1",

View File

@@ -15593,11 +15593,6 @@ winston@^3.6.0, winston@^3.7.1:
triple-beam "^1.3.0"
winston-transport "^4.5.0"
word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
workbox-background-sync@6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz#3141afba3cc8aa2ae14c24d0f6811374ba8ff6a9"

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/docs",
"version": "0.7.1",
"version": "0.8.0",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"private": true,

View File

@@ -157,6 +157,7 @@ export default defineConfig({
collapsed: true,
items: [
{ text: 'Triggers', link: '/apps/notion/triggers' },
{ text: 'Actions', link: '/apps/notion/actions' },
{ text: 'Connection', link: '/apps/notion/connection' },
],
},

View File

@@ -5,6 +5,8 @@ items:
desc: Create a blank spreadsheet or duplicate an existing spreadsheet. Optionally, provide headers.
- name: Create spreadsheet row
desc: Creates a new row in a specific spreadsheet.
- name: Create worksheet
desc: Create a blank worksheet with a title. Optionally, provide headers.
---
<script setup>

View File

@@ -0,0 +1,16 @@
---
favicon: /favicons/notion.svg
items:
- name: Create database item
desc: Creates an item in a database.
- name: Create page
desc: Creates a page inside a parent page.
- name: Find database item
desc: Searches for an item in a database by property.
---
<script setup>
import CustomListing from '../../components/CustomListing.vue'
</script>
<CustomListing />

View File

@@ -1,9 +1,5 @@
# 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.
- [DeepL](/apps/deepl/actions)

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/e2e-tests",
"version": "0.7.1",
"version": "0.8.0",
"license": "See LICENSE file",
"private": true,
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",

View File

@@ -96,6 +96,7 @@ export interface IUser {
flows: IFlow[];
steps: IStep[];
role: IRole;
permissions: IPermission[];
}
export interface IRole {
@@ -104,6 +105,20 @@ export interface IRole {
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 {
@@ -417,6 +432,11 @@ declare module 'axios' {
interface AxiosRequestConfig {
additionalProperties?: Record<string, unknown>;
}
// ref: https://github.com/axios/axios/issues/5095
interface AxiosInstance {
create(config?: CreateAxiosDefaults): AxiosInstance;
}
}
export interface IRequest extends Request {

View File

@@ -1,6 +1,6 @@
{
"name": "@automatisch/types",
"version": "0.7.1",
"version": "0.8.0",
"license": "See LICENSE file",
"description": "Type definitions for automatisch",
"homepage": "https://github.com/automatisch/automatisch",

View File

@@ -1,11 +1,13 @@
{
"name": "@automatisch/web",
"version": "0.7.1",
"version": "0.8.0",
"license": "See LICENSE file",
"description": "The open source Zapier alternative. Build workflow automation without spending time and money.",
"dependencies": {
"@apollo/client": "^3.6.9",
"@automatisch/types": "^0.7.1",
"@automatisch/types": "^0.8.0",
"@casl/ability": "^6.5.0",
"@casl/react": "^3.1.0",
"@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0",
"@hookform/resolvers": "^2.8.8",
@@ -31,7 +33,7 @@
"notistack": "^2.0.2",
"react": "^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-json-tree": "^0.16.2",
"react-router-dom": "^6.0.2",

View File

@@ -8,60 +8,74 @@ 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>
}
/>

View File

@@ -4,6 +4,7 @@ import MenuItem from '@mui/material/MenuItem';
import Menu, { MenuProps } from '@mui/material/Menu';
import { Link } from 'react-router-dom';
import Can from 'components/Can';
import apolloClient from 'graphql/client';
import * as URLS from 'config/urls';
import useAuthentication from 'hooks/useAuthentication';
@@ -54,9 +55,14 @@ function AccountDropdownMenu(
{formatMessage('accountDropdownMenu.settings')}
</MenuItem>
<MenuItem component={Link} to={URLS.ADMIN_SETTINGS_DASHBOARD}>
<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">
{formatMessage('accountDropdownMenu.logout')}

View File

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

View File

@@ -1,34 +1,42 @@
import * as React from 'react';
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 GroupIcon from '@mui/icons-material/Group';
import GroupsIcon from '@mui/icons-material/Groups';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import * as React from 'react';
import * as URLS from 'config/urls';
import useAutomatischInfo from 'hooks/useAutomatischInfo';
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;
};
function createDrawerLinks({ isCloud }: { isCloud: boolean }) {
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;
}
@@ -44,14 +52,17 @@ const drawerBottomLinks = [
export default function SettingsLayout({
children,
}: SettingsLayoutProps): React.ReactElement {
const { isCloud } = useAutomatischInfo();
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({ isCloud });
const drawerLinks = createDrawerLinks({
canReadUser: currentUserAbility.can('read', 'User'),
canReadRole: currentUserAbility.can('read', 'Role'),
});
return (
<>

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

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

View File

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

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

@@ -2,35 +2,54 @@ 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 { roleId } = props;
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 (
<>
<IconButton onClick={() => setShowConfirmation(true)} size="small">
<Can I="delete" a="Role" passThrough>
{(allowed) => (
<IconButton
disabled={!allowed || disabled}
onClick={() => setShowConfirmation(true)}
size="small"
>
<DeleteIcon />
</IconButton>
)}
</Can>
<ConfirmationDialog
open={showConfirmation}

View File

@@ -2,6 +2,7 @@ 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';
@@ -19,11 +20,19 @@ export default function DeleteUserButton(props: DeleteUserButtonProps) {
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 (

View File

@@ -6,6 +6,7 @@ import type { PopoverProps } from '@mui/material/Popover';
import MenuItem from '@mui/material/MenuItem';
import { useSnackbar } from 'notistack';
import Can from 'components/Can';
import { DELETE_FLOW } from 'graphql/mutations/delete-flow';
import { DUPLICATE_FLOW } from 'graphql/mutations/duplicate-flow';
import * as URLS from 'config/urls';
@@ -72,13 +73,39 @@ export default function ContextMenu(
hideBackdrop={false}
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')}
</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>
);
}

View File

@@ -1,47 +0,0 @@
import {
IconButton,
Skeleton,
Stack,
TableCell,
TableRow,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
type ListLoaderProps = {
rowsNumber: number;
cellNumber: number;
};
const ListLoader = ({ rowsNumber, cellNumber }: ListLoaderProps) => {
return (
<>
{[...Array(rowsNumber)].map((row, index) => (
<TableRow
key={index}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
{[...Array(cellNumber)].map((cell, index) => (
<TableCell key={index} scope="row">
<Skeleton />
</TableCell>
))}
<TableCell>
<Stack direction="row" gap={1} justifyContent="right">
<IconButton size="small">
<EditIcon />
</IconButton>
<IconButton size="small">
<DeleteIcon />
</IconButton>
</Stack>
</TableCell>
</TableRow>
))}
</>
);
};
export default ListLoader;

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

@@ -13,15 +13,14 @@ import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/Edit';
import DeleteRoleButton from 'components/DeleteRoleButton/index.ee';
import ListLoader from 'components/ListLoader';
import useFormatMessage from 'hooks/useFormatMessage';
import useRoles from 'hooks/useRoles.ee';
import * as URLS from 'config/urls';
// TODO: introduce interaction feedback upon deletion (successful + failure)
// TODO: introduce loading bar
export default function RoleList(): React.ReactElement {
const formatMessage = useFormatMessage();
const { roles, loading } = useRoles();
const { roles } = useRoles();
return (
<TableContainer component={Paper}>
@@ -50,10 +49,7 @@ export default function RoleList(): React.ReactElement {
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<ListLoader rowsNumber={3} cellNumber={2} />
) : (
roles.map((role) => (
{roles.map((role) => (
<TableRow
key={role.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
@@ -63,9 +59,7 @@ export default function RoleList(): React.ReactElement {
</TableCell>
<TableCell scope="row">
<Typography variant="subtitle2">
{role.description}
</Typography>
<Typography variant="subtitle2">{role.description}</Typography>
</TableCell>
<TableCell>
@@ -78,12 +72,11 @@ export default function RoleList(): React.ReactElement {
<EditIcon />
</IconButton>
<DeleteRoleButton roleId={role.id} />
<DeleteRoleButton disabled={role.isAdmin} roleId={role.id} />
</Stack>
</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</TableContainer>

View File

@@ -4,7 +4,7 @@ import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import Divider from '@mui/material/Divider';
import appConfig from 'config/app';
import * as URLS from 'config/urls';
import useSamlAuthProviders from 'hooks/useSamlAuthProviders.ee';
import useFormatMessage from 'hooks/useFormatMessage';
@@ -24,10 +24,12 @@ function SsoProviders() {
<Button
key={provider.id}
component="a"
href={`${appConfig.apiUrl}/login/saml/${provider.issuer}`}
href={URLS.SSO_LOGIN(provider.issuer)}
variant="outlined"
>
{provider.name}
{formatMessage('ssoProviders.loginWithProvider', {
providerName: provider.name
})}
</Button>
))}
</Stack>

View File

@@ -3,7 +3,7 @@ import Alert from '@mui/material/Alert';
import Typography from '@mui/material/Typography';
import * as URLS from 'config/urls';
import { generateInternalLink } from 'helpers/translation-values';
import { generateInternalLink } from 'helpers/translationValues';
import useTrialStatus from 'hooks/useTrialStatus.ee';
import useFormatMessage from 'hooks/useFormatMessage';

View File

@@ -13,13 +13,11 @@ import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/Edit';
import DeleteUserButton from 'components/DeleteUserButton/index.ee';
import ListLoader from 'components/ListLoader';
import useUsers from 'hooks/useUsers';
import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls';
// TODO: introduce translation entries
// TODO: introduce interaction feedback upon deletion (successful + failure)
// TODO: introduce loading bar
export default function UserList(): React.ReactElement {
const formatMessage = useFormatMessage();
const { users, loading } = useUsers();
@@ -51,10 +49,7 @@ export default function UserList(): React.ReactElement {
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<ListLoader rowsNumber={3} cellNumber={2} />
) : (
users.map((user) => (
{users.map((user) => (
<TableRow
key={user.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
@@ -81,8 +76,7 @@ export default function UserList(): React.ReactElement {
</Stack>
</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</TableContainer>

View File

@@ -3,7 +3,7 @@ import { FormattedMessage } from 'react-intl';
import Typography from '@mui/material/Typography';
import type { AlertProps } from '@mui/material/Alert';
import { generateExternalLink } from '../../helpers/translation-values';
import { generateExternalLink } from '../../helpers/translationValues';
import { WEBHOOK_DOCS } from '../../config/urls';
import TextField from '../TextField';
import { Alert } from './style';

View File

@@ -1,7 +1,7 @@
import { styled } from '@mui/material/styles';
import MuiAlert, { alertClasses } from '@mui/material/Alert';
export const Alert = styled(MuiAlert)(({ theme }) => ({
export const Alert = styled(MuiAlert)(() => ({
[`&.${alertClasses.root}`]: {
fontWeight: 300,
width: '100%',

View File

@@ -17,7 +17,9 @@ const config: Config = {
supportEmailAddress: 'support@automatisch.io'
};
if (!config.apiUrl) {
if (!config.apiUrl && !config.graphqlUrl) {
config.apiUrl = '/';
} else if (!config.apiUrl) {
config.apiUrl = (new URL(config.graphqlUrl)).origin;
}

View File

@@ -1,3 +1,5 @@
import appConfig from './app';
export const CONNECTIONS = '/connections';
export const EXECUTIONS = '/executions';
export const EXECUTION_PATTERN = '/executions/:executionId';
@@ -6,6 +8,7 @@ export const EXECUTION = (executionId: string) =>
export const LOGIN = '/login';
export const LOGIN_CALLBACK = `${LOGIN}/callback`;
export const SSO_LOGIN = (issuer: string) => `${appConfig.apiUrl}/login/saml/${issuer}`;
export const SIGNUP = '/sign-up';
export const FORGOT_PASSWORD = '/forgot-password';
export const RESET_PASSWORD = '/reset-password';

View File

@@ -6,6 +6,12 @@ export const UPDATE_ROLE = gql`
id
name
description
permissions {
id
action
subject
conditions
}
}
}
`;

View File

@@ -7,9 +7,11 @@ export const GET_CURRENT_USER = gql`
fullName
email
role {
id
isAdmin
}
permissions {
id
action
subject
conditions

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