From 0deaa03218bff13efc935979c415768b09a804e4 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Tue, 18 Jul 2023 21:00:10 +0000 Subject: [PATCH] feat(auth): add user and role management --- packages/backend/bin/database/utils.ts | 40 ++--- packages/backend/package.json | 2 +- .../migrations/20230615200200_create_roles.ts | 16 +- .../20230615205857_create_permissions.ts | 39 ++++- ...20230615210510_create_roles_permissions.ts | 25 --- ...230702210636_create_saml_auth_providers.ts | 1 + ...30715214424_make_user_password_nullable.ts | 7 +- .../backend/src/graphql/mutation-resolvers.ts | 76 ++++++---- .../src/graphql/mutations/create-role.ee.ts | 34 +++++ .../src/graphql/mutations/create-user.ee.ts | 27 +++- .../mutations/delete-current-user.ee.ts | 22 +++ .../src/graphql/mutations/delete-role.ee.ts | 41 +++++ .../src/graphql/mutations/delete-user.ee.ts | 23 ++- .../src/graphql/mutations/register-user.ee.ts | 33 ++++ ...{update-user.ts => update-current-user.ts} | 4 +- .../src/graphql/mutations/update-role.ee.ts | 91 +++++++++++ .../src/graphql/mutations/update-user.ee.ts | 44 ++++++ .../backend/src/graphql/queries/get-app.ts | 10 +- .../src/graphql/queries/get-connected-apps.ts | 18 ++- .../src/graphql/queries/get-dynamic-data.ts | 9 +- .../src/graphql/queries/get-dynamic-fields.ts | 9 +- .../graphql/queries/get-execution-steps.ts | 9 +- .../src/graphql/queries/get-execution.ts | 9 +- .../src/graphql/queries/get-executions.ts | 10 +- .../backend/src/graphql/queries/get-flow.ts | 9 +- .../backend/src/graphql/queries/get-flows.ts | 9 +- .../queries/get-permission-catalog.ee.ts | 7 + .../src/graphql/queries/get-role.ee.ts | 23 +++ .../src/graphql/queries/get-roles.ee.ts | 10 ++ .../queries/get-saml-auth-providers.ee.ts | 2 +- .../queries/get-step-with-test-executions.ts | 16 +- .../backend/src/graphql/queries/get-user.ts | 23 +++ .../backend/src/graphql/queries/get-users.ts | 26 ++++ .../src/graphql/queries/test-connection.ts | 9 +- .../backend/src/graphql/query-resolvers.ts | 68 +++++---- packages/backend/src/graphql/schema.graphql | 139 +++++++++++++++-- .../backend/src/helpers/authentication.ts | 8 +- ...find-or-create-user-by-saml-identity.ee.ts | 4 +- .../src/helpers/permission-catalog.ee.ts | 72 +++++++++ packages/backend/src/helpers/user-ability.ts | 20 +++ packages/backend/src/models/permission.ts | 6 +- packages/backend/src/models/query-builder.ts | 17 ++- packages/backend/src/models/role.ts | 20 ++- .../src/models/saml-auth-provider.ee.ts | 4 +- packages/backend/src/models/user.ts | 65 +++++--- packages/types/index.d.ts | 24 +++ packages/web/package.json | 4 +- packages/web/src/adminSettingsRoutes.tsx | 87 +++++++++++ .../components/AccountDropdownMenu/index.tsx | 10 ++ .../src/components/AddAppConnection/index.tsx | 2 +- .../components/AdminSettingsLayout/index.tsx | 92 ++++++++++++ packages/web/src/components/Can/index.tsx | 22 +++ .../ConditionalIconButton/index.tsx | 2 + .../components/ConditionalIconButton/style.ts | 2 +- .../components/ConfirmationDialog/index.tsx | 58 +++++++ .../components/ControlledCheckbox/index.tsx | 57 +++++++ .../DeleteAccountDialog/index.ee.tsx | 39 ++--- .../components/DeleteRoleButton/index.ee.tsx | 56 +++++++ .../components/DeleteUserButton/index.ee.tsx | 46 ++++++ .../src/components/FlowContextMenu/index.tsx | 37 ++++- .../PermissionSettings.ee.tsx | 142 ++++++++++++++++++ .../PermissionCatalogField/index.ee.tsx | 122 +++++++++++++++ .../web/src/components/RoleList/index.ee.tsx | 96 ++++++++++++ .../src/components/SignUpForm/index.ee.tsx | 8 +- .../src/components/SsoProviders/index.ee.tsx | 8 +- .../components/TrialOverAlert/index.ee.tsx | 2 +- .../components/UpgradeFreeTrial/index.ee.tsx | 2 +- .../web/src/components/UserList/index.tsx | 93 ++++++++++++ .../src/components/WebhookUrlInfo/index.tsx | 2 +- .../src/components/WebhookUrlInfo/style.ts | 2 +- packages/web/src/config/urls.ts | 32 ++-- .../src/graphql/mutations/create-role.ee.ts | 12 ++ .../src/graphql/mutations/create-user.ee.ts | 4 + .../mutations/delete-current-user.ee.ts | 7 + .../src/graphql/mutations/delete-role.ee.ts | 7 + .../src/graphql/mutations/delete-user.ee.ts | 4 +- .../src/graphql/mutations/register-user.ee.ts | 11 ++ .../graphql/mutations/update-current-user.ts | 11 ++ .../src/graphql/mutations/update-role.ee.ts | 17 +++ .../{update-user.ts => update-user.ee.ts} | 2 +- .../src/graphql/queries/get-current-user.ts | 10 ++ .../queries/get-permission-catalog.ee.ts | 21 +++ .../web/src/graphql/queries/get-role.ee.ts | 19 +++ .../web/src/graphql/queries/get-roles.ee.ts | 13 ++ packages/web/src/graphql/queries/get-user.ts | 19 +++ packages/web/src/graphql/queries/get-users.ts | 29 ++++ .../web/src/helpers/computePermissions.ee.ts | 59 ++++++++ ...ation-values.tsx => translationValues.tsx} | 0 packages/web/src/helpers/userAbility.ts | 20 +++ .../web/src/hooks/useCurrentUserAbility.ts | 8 + .../web/src/hooks/usePermissionCatalog.ee.ts | 10 ++ packages/web/src/hooks/useRole.ee.ts | 28 ++++ packages/web/src/hooks/useRoles.ee.ts | 17 +++ packages/web/src/hooks/useUser.ts | 28 ++++ packages/web/src/hooks/useUsers.ts | 33 ++++ packages/web/src/locales/en.json | 40 ++++- packages/web/src/pages/Application/index.tsx | 45 +----- packages/web/src/pages/Applications/index.tsx | 42 +++--- .../web/src/pages/CreateRole/index.ee.tsx | 82 ++++++++++ packages/web/src/pages/CreateUser/index.tsx | 107 +++++++++++++ packages/web/src/pages/EditRole/index.ee.tsx | 103 +++++++++++++ packages/web/src/pages/EditUser/index.tsx | 106 +++++++++++++ packages/web/src/pages/Flows/index.tsx | 42 +++--- .../web/src/pages/ProfileSettings/index.tsx | 10 +- packages/web/src/pages/Roles/index.ee.tsx | 55 +++++++ packages/web/src/pages/Users/index.tsx | 55 +++++++ packages/web/src/routes.tsx | 5 +- yarn.lock | 13 +- 108 files changed, 2909 insertions(+), 388 deletions(-) delete mode 100644 packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts create mode 100644 packages/backend/src/graphql/mutations/create-role.ee.ts create mode 100644 packages/backend/src/graphql/mutations/delete-current-user.ee.ts create mode 100644 packages/backend/src/graphql/mutations/delete-role.ee.ts create mode 100644 packages/backend/src/graphql/mutations/register-user.ee.ts rename packages/backend/src/graphql/mutations/{update-user.ts => update-current-user.ts} (85%) create mode 100644 packages/backend/src/graphql/mutations/update-role.ee.ts create mode 100644 packages/backend/src/graphql/mutations/update-user.ee.ts create mode 100644 packages/backend/src/graphql/queries/get-permission-catalog.ee.ts create mode 100644 packages/backend/src/graphql/queries/get-role.ee.ts create mode 100644 packages/backend/src/graphql/queries/get-roles.ee.ts create mode 100644 packages/backend/src/graphql/queries/get-user.ts create mode 100644 packages/backend/src/graphql/queries/get-users.ts create mode 100644 packages/backend/src/helpers/permission-catalog.ee.ts create mode 100644 packages/backend/src/helpers/user-ability.ts create mode 100644 packages/web/src/adminSettingsRoutes.tsx create mode 100644 packages/web/src/components/AdminSettingsLayout/index.tsx create mode 100644 packages/web/src/components/Can/index.tsx create mode 100644 packages/web/src/components/ConfirmationDialog/index.tsx create mode 100644 packages/web/src/components/ControlledCheckbox/index.tsx create mode 100644 packages/web/src/components/DeleteRoleButton/index.ee.tsx create mode 100644 packages/web/src/components/DeleteUserButton/index.ee.tsx create mode 100644 packages/web/src/components/PermissionCatalogField/PermissionSettings.ee.tsx create mode 100644 packages/web/src/components/PermissionCatalogField/index.ee.tsx create mode 100644 packages/web/src/components/RoleList/index.ee.tsx create mode 100644 packages/web/src/components/UserList/index.tsx create mode 100644 packages/web/src/graphql/mutations/create-role.ee.ts create mode 100644 packages/web/src/graphql/mutations/delete-current-user.ee.ts create mode 100644 packages/web/src/graphql/mutations/delete-role.ee.ts create mode 100644 packages/web/src/graphql/mutations/register-user.ee.ts create mode 100644 packages/web/src/graphql/mutations/update-current-user.ts create mode 100644 packages/web/src/graphql/mutations/update-role.ee.ts rename packages/web/src/graphql/mutations/{update-user.ts => update-user.ee.ts} (100%) create mode 100644 packages/web/src/graphql/queries/get-permission-catalog.ee.ts create mode 100644 packages/web/src/graphql/queries/get-role.ee.ts create mode 100644 packages/web/src/graphql/queries/get-roles.ee.ts create mode 100644 packages/web/src/graphql/queries/get-user.ts create mode 100644 packages/web/src/graphql/queries/get-users.ts create mode 100644 packages/web/src/helpers/computePermissions.ee.ts rename packages/web/src/helpers/{translation-values.tsx => translationValues.tsx} (100%) create mode 100644 packages/web/src/helpers/userAbility.ts create mode 100644 packages/web/src/hooks/useCurrentUserAbility.ts create mode 100644 packages/web/src/hooks/usePermissionCatalog.ee.ts create mode 100644 packages/web/src/hooks/useRole.ee.ts create mode 100644 packages/web/src/hooks/useRoles.ee.ts create mode 100644 packages/web/src/hooks/useUser.ts create mode 100644 packages/web/src/hooks/useUsers.ts create mode 100644 packages/web/src/pages/CreateRole/index.ee.tsx create mode 100644 packages/web/src/pages/CreateUser/index.tsx create mode 100644 packages/web/src/pages/EditRole/index.ee.tsx create mode 100644 packages/web/src/pages/EditUser/index.tsx create mode 100644 packages/web/src/pages/Roles/index.ee.tsx create mode 100644 packages/web/src/pages/Users/index.tsx diff --git a/packages/backend/bin/database/utils.ts b/packages/backend/bin/database/utils.ts index 369dc3be..3f6fce51 100644 --- a/packages/backend/bin/database/utils.ts +++ b/packages/backend/bin/database/utils.ts @@ -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, diff --git a/packages/backend/package.json b/packages/backend/package.json index d257feed..c72f6dcf 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,7 +4,7 @@ "license": "See LICENSE file", "description": "The open source Zapier alternative. Build workflow automation without spending time and money.", "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", "build": "tsc && yarn copy-statics", "build:watch": "nodemon --watch 'src/**/*.ts' --watch 'bin/**/*.ts' --exec yarn build --ext ts", diff --git a/packages/backend/src/db/migrations/20230615200200_create_roles.ts b/packages/backend/src/db/migrations/20230615200200_create_roles.ts index 9189911a..85441512 100644 --- a/packages/backend/src/db/migrations/20230615200200_create_roles.ts +++ b/packages/backend/src/db/migrations/20230615200200_create_roles.ts @@ -16,13 +16,27 @@ export async function up(knex: Knex): Promise { .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', }); } } diff --git a/packages/backend/src/db/migrations/20230615205857_create_permissions.ts b/packages/backend/src/db/migrations/20230615205857_create_permissions.ts index db7328c2..eb2462b5 100644 --- a/packages/backend/src/db/migrations/20230615205857_create_permissions.ts +++ b/packages/backend/src/db/migrations/20230615205857_create_permissions.ts @@ -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 { 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); }); - 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']), - ]); + 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 { diff --git a/packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts b/packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts deleted file mode 100644 index 3d43b9cd..00000000 --- a/packages/backend/src/db/migrations/20230615210510_create_roles_permissions.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Knex } from 'knex'; - -export async function up(knex: Knex): Promise { - 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 { - return knex.schema.dropTable('roles_permissions'); -} diff --git a/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts b/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts index ab57067c..63d72128 100644 --- a/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts +++ b/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts @@ -13,6 +13,7 @@ export async function up(knex: Knex): Promise { 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); }); diff --git a/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts b/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts index 095a176d..4e73a3fa 100644 --- a/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts +++ b/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts @@ -6,9 +6,6 @@ export async function up(knex: Knex): Promise { }); } -export async function down(knex: Knex): Promise { - return await knex.schema.alterTable('users', table => { - // what do we do? passwords cannot be left empty - // table.string('password').notNullable().alter(); - }); +export async function down(): Promise { + // void } diff --git a/packages/backend/src/graphql/mutation-resolvers.ts b/packages/backend/src/graphql/mutation-resolvers.ts index f655f434..de67248d 100644 --- a/packages/backend/src/graphql/mutation-resolvers.ts +++ b/packages/backend/src/graphql/mutation-resolvers.ts @@ -1,47 +1,59 @@ 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 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 updateFlowStatus from './mutations/update-flow-status'; -import executeFlow from './mutations/execute-flow'; -import deleteFlow from './mutations/delete-flow'; -import duplicateFlow from './mutations/duplicate-flow'; -import createStep from './mutations/create-step'; +import updateRole from './mutations/update-role.ee'; import updateStep from './mutations/update-step'; -import deleteStep from './mutations/delete-step'; -import createUser from './mutations/create-user.ee'; -import deleteUser from './mutations/delete-user.ee'; -import updateUser from './mutations/update-user'; -import forgotPassword from './mutations/forgot-password.ee'; -import resetPassword from './mutations/reset-password.ee'; -import login from './mutations/login'; +import updateUser from './mutations/update-user.ee'; +import verifyConnection from './mutations/verify-connection'; const mutationResolvers = { createConnection, - generateAuthUrl, - updateConnection, - resetConnection, - verifyConnection, - deleteConnection, createFlow, + createRole, + createStep, + createUser, + deleteConnection, + deleteCurrentUser, + deleteFlow, + deleteRole, + deleteStep, + deleteUser, + duplicateFlow, + executeFlow, + forgotPassword, + generateAuthUrl, + login, + registerUser, + resetConnection, + resetPassword, + updateConnection, + updateCurrentUser, + updateUser, updateFlow, updateFlowStatus, - executeFlow, - deleteFlow, - duplicateFlow, - createStep, + updateRole, updateStep, - deleteStep, - createUser, - deleteUser, - updateUser, - forgotPassword, - resetPassword, - login, + verifyConnection, }; export default mutationResolvers; diff --git a/packages/backend/src/graphql/mutations/create-role.ee.ts b/packages/backend/src/graphql/mutations/create-role.ee.ts new file mode 100644 index 00000000..811eadc9 --- /dev/null +++ b/packages/backend/src/graphql/mutations/create-role.ee.ts @@ -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; diff --git a/packages/backend/src/graphql/mutations/create-user.ee.ts b/packages/backend/src/graphql/mutations/create-user.ee.ts index 9faace22..67cc8e14 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.ts +++ b/packages/backend/src/graphql/mutations/create-user.ee.ts @@ -1,15 +1,21 @@ 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; + 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 existingUser = await User.query().findOne({ email }); @@ -18,14 +24,23 @@ const createUser = async (_parent: unknown, params: Params) => { throw new Error('User already exists!'); } - const role = await Role.query().findOne({ key: 'user' }); - - const user = await User.query().insert({ + const userPayload: Partial = { fullName, email, password, - roleId: role.id, - }); + }; + + 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; }; diff --git a/packages/backend/src/graphql/mutations/delete-current-user.ee.ts b/packages/backend/src/graphql/mutations/delete-current-user.ee.ts new file mode 100644 index 00000000..84462445 --- /dev/null +++ b/packages/backend/src/graphql/mutations/delete-current-user.ee.ts @@ -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; diff --git a/packages/backend/src/graphql/mutations/delete-role.ee.ts b/packages/backend/src/graphql/mutations/delete-role.ee.ts new file mode 100644 index 00000000..55376610 --- /dev/null +++ b/packages/backend/src/graphql/mutations/delete-role.ee.ts @@ -0,0 +1,41 @@ +import Role from '../../models/role'; +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!'); + } + + // delete permissions first + await role.$relatedQuery('permissions').delete(); + await role.$query().delete(); + + return true; +}; + +export default deleteRole; diff --git a/packages/backend/src/graphql/mutations/delete-user.ee.ts b/packages/backend/src/graphql/mutations/delete-user.ee.ts index 3e894030..ff5a6b71 100644 --- a/packages/backend/src/graphql/mutations/delete-user.ee.ts +++ b/packages/backend/src/graphql/mutations/delete-user.ee.ts @@ -1,11 +1,24 @@ -import Context from '../../types/express/context'; -import deleteUserQueue from '../../queues/delete-user.ee'; 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) => { - const id = context.currentUser.id; +type Params = { + 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 jobPayload = { id }; diff --git a/packages/backend/src/graphql/mutations/register-user.ee.ts b/packages/backend/src/graphql/mutations/register-user.ee.ts new file mode 100644 index 00000000..0a7ede07 --- /dev/null +++ b/packages/backend/src/graphql/mutations/register-user.ee.ts @@ -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; diff --git a/packages/backend/src/graphql/mutations/update-user.ts b/packages/backend/src/graphql/mutations/update-current-user.ts similarity index 85% rename from packages/backend/src/graphql/mutations/update-user.ts rename to packages/backend/src/graphql/mutations/update-current-user.ts index 8e0a5de8..8235f062 100644 --- a/packages/backend/src/graphql/mutations/update-user.ts +++ b/packages/backend/src/graphql/mutations/update-current-user.ts @@ -8,7 +8,7 @@ type Params = { }; }; -const updateUser = async ( +const updateCurrentUser = async ( _parent: unknown, params: Params, context: Context @@ -22,4 +22,4 @@ const updateUser = async ( return user; }; -export default updateUser; +export default updateCurrentUser; diff --git a/packages/backend/src/graphql/mutations/update-role.ee.ts b/packages/backend/src/graphql/mutations/update-role.ee.ts new file mode 100644 index 00000000..5acd13a6 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-role.ee.ts @@ -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; diff --git a/packages/backend/src/graphql/mutations/update-user.ee.ts b/packages/backend/src/graphql/mutations/update-user.ee.ts new file mode 100644 index 00000000..1ba58381 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-user.ee.ts @@ -0,0 +1,44 @@ +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 = { + 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; diff --git a/packages/backend/src/graphql/queries/get-app.ts b/packages/backend/src/graphql/queries/get-app.ts index e09a5a10..c9ebaf52 100644 --- a/packages/backend/src/graphql/queries/get-app.ts +++ b/packages/backend/src/graphql/queries/get-app.ts @@ -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,16 @@ 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 .select('connections.*') .fullOuterJoinRelated('steps') .where({ diff --git a/packages/backend/src/graphql/queries/get-connected-apps.ts b/packages/backend/src/graphql/queries/get-connected-apps.ts index fe2f8a17..2b328e6a 100644 --- a/packages/backend/src/graphql/queries/get-connected-apps.ts +++ b/packages/backend/src/graphql/queries/get-connected-apps.ts @@ -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,25 @@ 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 .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 .withGraphJoined('steps') .orderBy('created_at', 'desc'); diff --git a/packages/backend/src/graphql/queries/get-dynamic-data.ts b/packages/backend/src/graphql/queries/get-dynamic-data.ts index 12b1092c..a5c40bb5 100644 --- a/packages/backend/src/graphql/queries/get-dynamic-data.ts +++ b/packages/backend/src/graphql/queries/get-dynamic-data.ts @@ -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,12 @@ 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 .withGraphFetched({ connection: true, flow: true, diff --git a/packages/backend/src/graphql/queries/get-dynamic-fields.ts b/packages/backend/src/graphql/queries/get-dynamic-fields.ts index 8b6e30fa..d4529a7e 100644 --- a/packages/backend/src/graphql/queries/get-dynamic-fields.ts +++ b/packages/backend/src/graphql/queries/get-dynamic-fields.ts @@ -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,12 @@ 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 .withGraphFetched({ connection: true, flow: true, diff --git a/packages/backend/src/graphql/queries/get-execution-steps.ts b/packages/backend/src/graphql/queries/get-execution-steps.ts index 01394dbe..da941058 100644 --- a/packages/backend/src/graphql/queries/get-execution-steps.ts +++ b/packages/backend/src/graphql/queries/get-execution-steps.ts @@ -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,12 @@ 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 .withSoftDeleted() .findById(params.executionId) .throwIfNotFound(); diff --git a/packages/backend/src/graphql/queries/get-execution.ts b/packages/backend/src/graphql/queries/get-execution.ts index 19ff929e..75f1440e 100644 --- a/packages/backend/src/graphql/queries/get-execution.ts +++ b/packages/backend/src/graphql/queries/get-execution.ts @@ -1,4 +1,5 @@ import Context from '../../types/express/context'; +import Execution from '../../models/execution'; type Params = { executionId: string; @@ -9,10 +10,12 @@ 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 .withGraphFetched({ flow: { steps: true, diff --git a/packages/backend/src/graphql/queries/get-executions.ts b/packages/backend/src/graphql/queries/get-executions.ts index 90d96517..142716f0 100644 --- a/packages/backend/src/graphql/queries/get-executions.ts +++ b/packages/backend/src/graphql/queries/get-executions.ts @@ -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,7 @@ const getExecutions = async ( as status `; - const executions = context.currentUser - .$relatedQuery('executions') + const executions = executionBaseQuery .joinRelated('executionSteps as execution_steps') .select('executions.*', raw(selectStatusStatement)) .withSoftDeleted() diff --git a/packages/backend/src/graphql/queries/get-flow.ts b/packages/backend/src/graphql/queries/get-flow.ts index 6d767118..156eb7e2 100644 --- a/packages/backend/src/graphql/queries/get-flow.ts +++ b/packages/backend/src/graphql/queries/get-flow.ts @@ -1,14 +1,17 @@ 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 .withGraphJoined('[steps.[connection]]') .orderBy('steps.position', 'asc') .findOne({ 'flows.id': params.id }) diff --git a/packages/backend/src/graphql/queries/get-flows.ts b/packages/backend/src/graphql/queries/get-flows.ts index b880dd43..251ab9cb 100644 --- a/packages/backend/src/graphql/queries/get-flows.ts +++ b/packages/backend/src/graphql/queries/get-flows.ts @@ -1,3 +1,4 @@ +import Flow from '../../models/flow'; import Context from '../../types/express/context'; import paginate from '../../helpers/pagination'; @@ -10,10 +11,12 @@ 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 .joinRelated({ steps: true, }) diff --git a/packages/backend/src/graphql/queries/get-permission-catalog.ee.ts b/packages/backend/src/graphql/queries/get-permission-catalog.ee.ts new file mode 100644 index 00000000..92f8cdc6 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-permission-catalog.ee.ts @@ -0,0 +1,7 @@ +import permissionCatalog from '../../helpers/permission-catalog.ee'; + +const getPermissionCatalog = async () => { + return permissionCatalog; +}; + +export default getPermissionCatalog; diff --git a/packages/backend/src/graphql/queries/get-role.ee.ts b/packages/backend/src/graphql/queries/get-role.ee.ts new file mode 100644 index 00000000..8192385e --- /dev/null +++ b/packages/backend/src/graphql/queries/get-role.ee.ts @@ -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; diff --git a/packages/backend/src/graphql/queries/get-roles.ee.ts b/packages/backend/src/graphql/queries/get-roles.ee.ts new file mode 100644 index 00000000..58cac55b --- /dev/null +++ b/packages/backend/src/graphql/queries/get-roles.ee.ts @@ -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; diff --git a/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts b/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts index e4179d00..39de490f 100644 --- a/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts +++ b/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts @@ -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; }; diff --git a/packages/backend/src/graphql/queries/get-step-with-test-executions.ts b/packages/backend/src/graphql/queries/get-step-with-test-executions.ts index ff79ce11..051fa807 100644 --- a/packages/backend/src/graphql/queries/get-step-with-test-executions.ts +++ b/packages/backend/src/graphql/queries/get-step-with-test-executions.ts @@ -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,16 @@ 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 .findOne({ 'steps.id': params.stepId }) .throwIfNotFound(); - const previousStepsWithCurrentStep = await context.currentUser - .$relatedQuery('steps') + const previousStepsWithCurrentStep = await stepBaseQuery .withGraphJoined('executionSteps') .where('flow_id', '=', step.flowId) .andWhere('position', '<', step.position) diff --git a/packages/backend/src/graphql/queries/get-user.ts b/packages/backend/src/graphql/queries/get-user.ts new file mode 100644 index 00000000..e474186a --- /dev/null +++ b/packages/backend/src/graphql/queries/get-user.ts @@ -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; diff --git a/packages/backend/src/graphql/queries/get-users.ts b/packages/backend/src/graphql/queries/get-users.ts new file mode 100644 index 00000000..ad0df2f9 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-users.ts @@ -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; diff --git a/packages/backend/src/graphql/queries/test-connection.ts b/packages/backend/src/graphql/queries/test-connection.ts index 6d7979d0..6b785cc3 100644 --- a/packages/backend/src/graphql/queries/test-connection.ts +++ b/packages/backend/src/graphql/queries/test-connection.ts @@ -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,12 @@ 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 .findOne({ id: params.id, }) diff --git a/packages/backend/src/graphql/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index 1af07484..b5f50222 100644 --- a/packages/backend/src/graphql/query-resolvers.ts +++ b/packages/backend/src/graphql/query-resolvers.ts @@ -1,49 +1,59 @@ -import getApps from './queries/get-apps'; 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 testConnection from './queries/test-connection'; -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 getCurrentUser from './queries/get-current-user'; import getDynamicData from './queries/get-dynamic-data'; import getDynamicFields from './queries/get-dynamic-fields'; -import getCurrentUser from './queries/get-current-user'; -import getPaymentPlans from './queries/get-payment-plans.ee'; -import getPaddleInfo from './queries/get-paddle-info.ee'; -import getBillingAndUsage from './queries/get-billing-and-usage.ee'; +import getExecution from './queries/get-execution'; +import getExecutionSteps from './queries/get-execution-steps'; +import getExecutions from './queries/get-executions'; +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 getAutomatischInfo from './queries/get-automatisch-info'; -import getTrialStatus from './queries/get-trial-status.ee'; -import getSubscriptionStatus from './queries/get-subscription-status.ee'; +import getPaddleInfo from './queries/get-paddle-info.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 getTrialStatus from './queries/get-trial-status.ee'; import healthcheck from './queries/healthcheck'; +import testConnection from './queries/test-connection'; const queryResolvers = { - getApps, getApp, + getApps, + getAutomatischInfo, + getBillingAndUsage, getConnectedApps, - testConnection, - getFlow, - getFlows, - getStepWithTestExecutions, + getCurrentUser, + getDynamicData, + getDynamicFields, getExecution, getExecutions, getExecutionSteps, - getDynamicData, - getDynamicFields, - getCurrentUser, - getPaymentPlans, - getPaddleInfo, - getBillingAndUsage, + getFlow, + getFlows, getInvoices, - getAutomatischInfo, - getTrialStatus, - getSubscriptionStatus, + getPaddleInfo, + getPaymentPlans, + getPermissionCatalog, + getRole, + getRoles, getSamlAuthProviders, + getStepWithTestExecutions, + getSubscriptionStatus, + getTrialStatus, + getUser, + getUsers, healthcheck, + testConnection, }; export default queryResolvers; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index add17ccb..a8dda7db 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -42,31 +42,45 @@ type Query { getTrialStatus: GetTrialStatus getSubscriptionStatus: GetSubscriptionStatus getSamlAuthProviders: [GetSamlAuthProviders] + getUsers( + limit: Int! + offset: Int! + ): UserConnection + getUser(id: String!): User + getRoles: [Role] + getRole(id: String!): Role + getPermissionCatalog: PermissionCatalog healthcheck: AppHealth } type Mutation { 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 + 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 updateFlowStatus(input: UpdateFlowStatusInput): Flow - executeFlow(input: ExecuteFlowInput): executeFlowType - deleteFlow(input: DeleteFlowInput): Boolean - duplicateFlow(input: DuplicateFlowInput): Flow - createStep(input: CreateStepInput): Step + updateRole(input: UpdateRoleInput): Role updateStep(input: UpdateStepInput): Step - deleteStep(input: DeleteStepInput): Step - createUser(input: CreateUserInput): User - deleteUser: Boolean updateUser(input: UpdateUserInput): User - forgotPassword(input: ForgotPasswordInput): Boolean - resetPassword(input: ResetPasswordInput): Boolean - login(input: LoginInput): Auth + verifyConnection(input: VerifyConnectionInput): Connection } """ @@ -278,6 +292,15 @@ type Execution { flow: Flow } +type UserConnection { + edges: [UserEdge] + pageInfo: PageInfo +} + +type UserEdge { + node: User +} + input CreateConnectionInput { key: String! formattedData: JSONObject! @@ -361,9 +384,31 @@ input CreateUserInput { fullName: String! email: String! password: String! + role: UserRoleInput! +} + +input UserRoleInput { + id: String } 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 password: String fullName: String @@ -383,6 +428,29 @@ input LoginInput { 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). """ @@ -454,11 +522,21 @@ type User { id: String fullName: String email: String - role: String + role: Role + permissions: [Permission] createdAt: String updatedAt: String } +type Role { + id: String + name: String + key: String + description: String + isAdmin: Boolean + permissions: [Permission] +} + type PageInfo { currentPage: Int! totalPages: Int! @@ -561,6 +639,35 @@ type GetSamlAuthProviders { 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 { query: Query mutation: Mutation diff --git a/packages/backend/src/helpers/authentication.ts b/packages/backend/src/helpers/authentication.ts index 5a39394e..b31df2c7 100644 --- a/packages/backend/src/helpers/authentication.ts +++ b/packages/backend/src/helpers/authentication.ts @@ -15,10 +15,12 @@ const isAuthenticated = rule()(async (_parent, _args, req) => { req.currentUser = await User .query() .findById(userId) - .joinRelated({ + .leftJoinRelated({ + role: true, permissions: true, }) .withGraphFetched({ + role: true, permissions: true, }); @@ -38,9 +40,9 @@ const authentication = shield( }, Mutation: { '*': isAuthenticated, - login: allow, - createUser: allow, + registerUser: allow, forgotPassword: allow, + login: allow, resetPassword: allow, }, }, diff --git a/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts index c930a021..0af8cc73 100644 --- a/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts +++ b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts @@ -22,7 +22,7 @@ const findOrCreateUserBySamlIdentity = async (userIdentity: Record) { + 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); +} diff --git a/packages/backend/src/models/permission.ts b/packages/backend/src/models/permission.ts index 393a4a76..8c5f2784 100644 --- a/packages/backend/src/models/permission.ts +++ b/packages/backend/src/models/permission.ts @@ -2,19 +2,23 @@ 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: ['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' } }, createdAt: { type: 'string' }, updatedAt: { type: 'string' }, }, diff --git a/packages/backend/src/models/query-builder.ts b/packages/backend/src/models/query-builder.ts index 921ba073..34263d23 100644 --- a/packages/backend/src/models/query-builder.ts +++ b/packages/backend/src/models/query-builder.ts @@ -1,6 +1,7 @@ import { Model, Page, + ModelClass, PartialModelObject, ForClassMethod, AnyQueryBuilder, @@ -8,6 +9,10 @@ import { const DELETED_COLUMN_NAME = 'deleted_at'; +const supportsSoftDeletion = (modelClass: ModelClass) => { + return modelClass.jsonSchema.properties.deletedAt; +} + const buildQueryBuidlerForClass = (): ForClassMethod => { return (modelClass) => { const qb: AnyQueryBuilder = Model.QueryBuilder.forClass.call( @@ -15,7 +20,7 @@ const buildQueryBuidlerForClass = (): ForClassMethod => { modelClass ); qb.onBuild((builder) => { - if (!builder.context().withSoftDeleted && qb.modelClass().jsonSchema.properties.deletedAt) { + if (!builder.context().withSoftDeleted && supportsSoftDeletion(qb.modelClass())) { builder.whereNull( `${qb.modelClass().tableName}.${DELETED_COLUMN_NAME}` ); @@ -38,9 +43,13 @@ class ExtendedQueryBuilder extends Model.QueryBuilder< static forClass: ForClassMethod = buildQueryBuidlerForClass(); delete() { - return this.patch({ - [DELETED_COLUMN_NAME]: new Date().toISOString(), - } as unknown as PartialModelObject); + if (supportsSoftDeletion(this.modelClass())) { + return this.patch({ + [DELETED_COLUMN_NAME]: new Date().toISOString(), + } as unknown as PartialModelObject); + } + + return super.delete(); } hardDelete() { diff --git a/packages/backend/src/models/role.ts b/packages/backend/src/models/role.ts index 0c5d2d2f..c2a3666d 100644 --- a/packages/backend/src/models/role.ts +++ b/packages/backend/src/models/role.ts @@ -1,4 +1,5 @@ import Base from './base'; +import Permission from './permission'; import User from './user'; class Role extends Base { @@ -7,6 +8,7 @@ class Role extends Base { key: string; description: string; users?: User[]; + permissions?: Permission[]; static tableName = 'roles'; @@ -18,12 +20,16 @@ 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' }, }, }; + static get virtualAttributes() { + return ['isAdmin']; + } + static relationMappings = () => ({ users: { relation: Base.HasManyRelation, @@ -33,7 +39,19 @@ class Role extends Base { 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; diff --git a/packages/backend/src/models/saml-auth-provider.ee.ts b/packages/backend/src/models/saml-auth-provider.ee.ts index 480d976f..7eb80c47 100644 --- a/packages/backend/src/models/saml-auth-provider.ee.ts +++ b/packages/backend/src/models/saml-auth-provider.ee.ts @@ -16,6 +16,7 @@ class SamlAuthProvider extends Base { emailAttributeName: string; roleAttributeName: string; defaultRoleId: string; + active: boolean; static tableName = 'saml_auth_providers'; @@ -45,7 +46,8 @@ class SamlAuthProvider extends Base { 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' }, }, }; diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index a2341199..b0917dfd 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -1,22 +1,25 @@ -import crypto from 'node:crypto'; -import { QueryContext, ModelOptions } from 'objection'; import bcrypt from 'bcrypt'; import { DateTime } from 'luxon'; -import { Ability } 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 +151,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: { @@ -292,23 +291,43 @@ class User extends Base { } } - get ability() { - if (!this.permissions) { - throw new Error('User.permissions must be fetched!'); + async $afterFind(): Promise { + const hasValidLicense = await checkLicense(); + + if (hasValidLicense) return this; + + if (Array.isArray(this.permissions)) { + this.permissions = this.permissions.filter((permission) => { + const isRolePermission = permission.subject === 'Role'; + + return !isRolePermission; + }); } - return new Ability(this.permissions); + return this; } - can(action: string, subject: Subject) { + get ability(): ReturnType { + 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 = 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!'); diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 069bf542..09213341 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -95,6 +95,30 @@ export interface IUser { connections: IConnection[]; flows: IFlow[]; 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 { diff --git a/packages/web/package.json b/packages/web/package.json index 5a7c3636..ea3aadd2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -6,6 +6,8 @@ "dependencies": { "@apollo/client": "^3.6.9", "@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", diff --git a/packages/web/src/adminSettingsRoutes.tsx b/packages/web/src/adminSettingsRoutes.tsx new file mode 100644 index 00000000..a0dd5452 --- /dev/null +++ b/packages/web/src/adminSettingsRoutes.tsx @@ -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 ( + <> + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + + + + + + } + /> + + } + /> + +); diff --git a/packages/web/src/components/AccountDropdownMenu/index.tsx b/packages/web/src/components/AccountDropdownMenu/index.tsx index e34cd045..849b93c0 100644 --- a/packages/web/src/components/AccountDropdownMenu/index.tsx +++ b/packages/web/src/components/AccountDropdownMenu/index.tsx @@ -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,6 +55,15 @@ function AccountDropdownMenu( {formatMessage('accountDropdownMenu.settings')} + + + {formatMessage('accountDropdownMenu.adminSettings')} + + + {formatMessage('accountDropdownMenu.logout')} diff --git a/packages/web/src/components/AddAppConnection/index.tsx b/packages/web/src/components/AddAppConnection/index.tsx index bf5c3b7b..201c0aca 100644 --- a/packages/web/src/components/AddAppConnection/index.tsx +++ b/packages/web/src/components/AddAppConnection/index.tsx @@ -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 = { diff --git a/packages/web/src/components/AdminSettingsLayout/index.tsx b/packages/web/src/components/AdminSettingsLayout/index.tsx new file mode 100644 index 00000000..69d0dfe4 --- /dev/null +++ b/packages/web/src/components/AdminSettingsLayout/index.tsx @@ -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 ( + <> + + + + + + + + + {children} + + + + ); +} diff --git a/packages/web/src/components/Can/index.tsx b/packages/web/src/components/Can/index.tsx new file mode 100644 index 00000000..08957059 --- /dev/null +++ b/packages/web/src/components/Can/index.tsx @@ -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 (); +}; diff --git a/packages/web/src/components/ConditionalIconButton/index.tsx b/packages/web/src/components/ConditionalIconButton/index.tsx index dd8bcaf4..ec981391 100644 --- a/packages/web/src/components/ConditionalIconButton/index.tsx +++ b/packages/web/src/components/ConditionalIconButton/index.tsx @@ -19,6 +19,8 @@ export default function ConditionalIconButton(props: any): React.ReactElement { type={buttonProps.type} size={buttonProps.size} component={buttonProps.component} + to={buttonProps.to} + disabled={buttonProps.disabled} > {icon} diff --git a/packages/web/src/components/ConditionalIconButton/style.ts b/packages/web/src/components/ConditionalIconButton/style.ts index 28243e21..57acd404 100644 --- a/packages/web/src/components/ConditionalIconButton/style.ts +++ b/packages/web/src/components/ConditionalIconButton/style.ts @@ -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}; diff --git a/packages/web/src/components/ConfirmationDialog/index.tsx b/packages/web/src/components/ConfirmationDialog/index.tsx new file mode 100644 index 00000000..a53c26af --- /dev/null +++ b/packages/web/src/components/ConfirmationDialog/index.tsx @@ -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 ( + + {title && ( + + {title} + + )} + {description && ( + + + {description} + + + )} + + + {(cancelButtonChildren && onClose) && ( + + )} + + {(confirmButtionChildren && onConfirm) && ( + + )} + + + ); +} diff --git a/packages/web/src/components/ControlledCheckbox/index.tsx b/packages/web/src/components/ControlledCheckbox/index.tsx new file mode 100644 index 00000000..3e16f4b8 --- /dev/null +++ b/packages/web/src/components/ControlledCheckbox/index.tsx @@ -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 ( + { + return ( + { + controllerOnChange(...args); + onChange?.(...args); + }} + onBlur={(...args) => { + controllerOnBlur(); + onBlur?.(...args); + }} + inputRef={ref} + /> + )}} + /> + ); +} diff --git a/packages/web/src/components/DeleteAccountDialog/index.ee.tsx b/packages/web/src/components/DeleteAccountDialog/index.ee.tsx index d7310de5..74b823fc 100644 --- a/packages/web/src/components/DeleteAccountDialog/index.ee.tsx +++ b/packages/web/src/components/DeleteAccountDialog/index.ee.tsx @@ -1,16 +1,11 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; 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 ConfirmationDialog from 'components/ConfirmationDialog'; 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 useFormatMessage from 'hooks/useFormatMessage'; import useCurrentUser from 'hooks/useCurrentUser'; @@ -20,37 +15,29 @@ type DeleteAccountDialogProps = { } export default function DeleteAccountDialog(props: DeleteAccountDialogProps) { - const [deleteUser] = useMutation(DELETE_USER); + const [deleteCurrentUser] = useMutation(DELETE_CURRENT_USER); const formatMessage = useFormatMessage(); const currentUser = useCurrentUser(); const authentication = useAuthentication(); const navigate = useNavigate(); const handleConfirm = React.useCallback(async () => { - await deleteUser(); + await deleteCurrentUser(); authentication.updateToken(''); await apolloClient.clearStore(); navigate(URLS.LOGIN); - }, [deleteUser, currentUser]); + }, [deleteCurrentUser, currentUser]); return ( - - - {formatMessage('deleteAccountDialog.title')} - - - - {formatMessage('deleteAccountDialog.description')} - - - - - - - + ); } diff --git a/packages/web/src/components/DeleteRoleButton/index.ee.tsx b/packages/web/src/components/DeleteRoleButton/index.ee.tsx new file mode 100644 index 00000000..19a06e22 --- /dev/null +++ b/packages/web/src/components/DeleteRoleButton/index.ee.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { useMutation } from '@apollo/client'; +import IconButton from '@mui/material/IconButton'; +import DeleteIcon from '@mui/icons-material/Delete'; + +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 handleConfirm = React.useCallback(async () => { + await deleteRole(); + + setShowConfirmation(false); + }, [deleteRole]); + + return ( + <> + + {allowed => ( + setShowConfirmation(true)} + size="small" + > + + + )} + + + setShowConfirmation(false)} + onConfirm={handleConfirm} + cancelButtonChildren={formatMessage('deleteRoleButton.cancel')} + confirmButtionChildren={formatMessage('deleteRoleButton.confirm')} + /> + + ); +} diff --git a/packages/web/src/components/DeleteUserButton/index.ee.tsx b/packages/web/src/components/DeleteUserButton/index.ee.tsx new file mode 100644 index 00000000..428cb953 --- /dev/null +++ b/packages/web/src/components/DeleteUserButton/index.ee.tsx @@ -0,0 +1,46 @@ +import * as React from 'react'; +import { useMutation } from '@apollo/client'; +import IconButton from '@mui/material/IconButton'; +import DeleteIcon from '@mui/icons-material/Delete'; + +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 handleConfirm = React.useCallback(async () => { + await deleteUser(); + + setShowConfirmation(false); + }, [deleteUser]); + + return ( + <> + setShowConfirmation(true)} size="small"> + + + + setShowConfirmation(false)} + onConfirm={handleConfirm} + cancelButtonChildren={formatMessage('deleteUserButton.cancel')} + confirmButtionChildren={formatMessage('deleteUserButton.confirm')} + /> + + ); +} diff --git a/packages/web/src/components/FlowContextMenu/index.tsx b/packages/web/src/components/FlowContextMenu/index.tsx index dbb45afc..adaf3273 100644 --- a/packages/web/src/components/FlowContextMenu/index.tsx +++ b/packages/web/src/components/FlowContextMenu/index.tsx @@ -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} > - - {formatMessage('flow.view')} - + + {(allowed) => ( + + {formatMessage('flow.view')} + + )} + - {formatMessage('flow.duplicate')} + + {(allowed) => ( + + {formatMessage('flow.duplicate')} + + )} + - {formatMessage('flow.delete')} + + {(allowed) => ( + + {formatMessage('flow.delete')} + + )} + ); } diff --git a/packages/web/src/components/PermissionCatalogField/PermissionSettings.ee.tsx b/packages/web/src/components/PermissionCatalogField/PermissionSettings.ee.tsx new file mode 100644 index 00000000..6c91defe --- /dev/null +++ b/packages/web/src/components/PermissionCatalogField/PermissionSettings.ee.tsx @@ -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 ( + + + {formatMessage('permissionSettings.title')} + + + + + + + + + + {actions.map(action => ( + + + {action.label} + + + ))} + + + + {conditions.map((condition) => ( + + + + {condition.label} + + + + {actions.map((action) => ( + + + {action.subjects.includes(subject) && ( + + )} + + {!action.subjects.includes(subject) && '-'} + + + ))} + + ))} + +
+
+
+ + + + + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/components/PermissionCatalogField/index.ee.tsx b/packages/web/src/components/PermissionCatalogField/index.ee.tsx new file mode 100644 index 00000000..b3ace5e1 --- /dev/null +++ b/packages/web/src/components/PermissionCatalogField/index.ee.tsx @@ -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(); + + if (!permissionCatalog) return (); + + return ( + + + + + + + {permissionCatalog.actions.map(action => ( + + + {action.label} + + + ))} + + + + + + {permissionCatalog.subjects.map((subject) => ( + + + + {subject.label} + + + + {permissionCatalog.actions.map((action) => ( + + + {action.subjects.includes(subject.key) && ( + + )} + + {!action.subjects.includes(subject.key) && '-'} + + + ))} + + + + setDialogName(subject.key)} + disabled={disabled} + > + + + + {dialogName === subject.key && ( + setDialogName('')} + fieldPrefix={`${name}.${subject.key}`} + subject={subject.key} + actions={permissionCatalog.actions} + conditions={permissionCatalog.conditions} + /> + )} + + + + ))} + +
+
+ ) +}; + +export default PermissionCatalogField; diff --git a/packages/web/src/components/RoleList/index.ee.tsx b/packages/web/src/components/RoleList/index.ee.tsx new file mode 100644 index 00000000..4b009b64 --- /dev/null +++ b/packages/web/src/components/RoleList/index.ee.tsx @@ -0,0 +1,96 @@ +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 interaction feedback upon deletion (successful + failure) +// TODO: introduce loading bar +export default function RoleList(): React.ReactElement { + const formatMessage = useFormatMessage(); + const { roles } = useRoles(); + + return ( + + + + + + + {formatMessage('roleList.name')} + + + + + + {formatMessage('roleList.description')} + + + + + + + + {roles.map((role) => ( + + + + {role.name} + + + + + + {role.description} + + + + + + + + + + + + + + ))} + +
+
+ ); +} diff --git a/packages/web/src/components/SignUpForm/index.ee.tsx b/packages/web/src/components/SignUpForm/index.ee.tsx index ac8c6aca..45ef3d34 100644 --- a/packages/web/src/components/SignUpForm/index.ee.tsx +++ b/packages/web/src/components/SignUpForm/index.ee.tsx @@ -9,7 +9,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import useAuthentication from 'hooks/useAuthentication'; import * as URLS from 'config/urls'; -import { CREATE_USER } from 'graphql/mutations/create-user.ee'; +import { REGISTER_USER } from 'graphql/mutations/register-user.ee'; import Form from 'components/Form'; import TextField from 'components/TextField'; import { LOGIN } from 'graphql/mutations/login'; @@ -40,7 +40,7 @@ function SignUpForm() { const navigate = useNavigate(); const authentication = useAuthentication(); const formatMessage = useFormatMessage(); - const [createUser, { loading: createUserLoading }] = useMutation(CREATE_USER); + const [registerUser, { loading: registerUserLoading }] = useMutation(REGISTER_USER); const [login, { loading: loginLoading }] = useMutation(LOGIN); React.useEffect(() => { @@ -51,7 +51,7 @@ function SignUpForm() { const handleSubmit = async (values: any) => { const { fullName, email, password } = values; - await createUser({ + await registerUser({ variables: { input: { fullName, email, password }, }, @@ -165,7 +165,7 @@ function SignUpForm() { variant="contained" color="primary" sx={{ boxShadow: 2, mt: 3 }} - loading={createUserLoading || loginLoading} + loading={registerUserLoading || loginLoading} fullWidth data-test="signUp-button" > diff --git a/packages/web/src/components/SsoProviders/index.ee.tsx b/packages/web/src/components/SsoProviders/index.ee.tsx index da47b588..405f8d20 100644 --- a/packages/web/src/components/SsoProviders/index.ee.tsx +++ b/packages/web/src/components/SsoProviders/index.ee.tsx @@ -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() { ))} diff --git a/packages/web/src/components/TrialOverAlert/index.ee.tsx b/packages/web/src/components/TrialOverAlert/index.ee.tsx index 8b6974f5..4e1ffa91 100644 --- a/packages/web/src/components/TrialOverAlert/index.ee.tsx +++ b/packages/web/src/components/TrialOverAlert/index.ee.tsx @@ -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'; diff --git a/packages/web/src/components/UpgradeFreeTrial/index.ee.tsx b/packages/web/src/components/UpgradeFreeTrial/index.ee.tsx index 9a0cfa90..eef0eee9 100644 --- a/packages/web/src/components/UpgradeFreeTrial/index.ee.tsx +++ b/packages/web/src/components/UpgradeFreeTrial/index.ee.tsx @@ -58,7 +58,7 @@ export default function UpgradeFreeTrial() { alignItems="stretch" > - +
diff --git a/packages/web/src/components/UserList/index.tsx b/packages/web/src/components/UserList/index.tsx new file mode 100644 index 00000000..6b2e6af3 --- /dev/null +++ b/packages/web/src/components/UserList/index.tsx @@ -0,0 +1,93 @@ +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 DeleteUserButton from 'components/DeleteUserButton/index.ee'; +import useUsers from 'hooks/useUsers'; +import useFormatMessage from 'hooks/useFormatMessage'; +import * as URLS from 'config/urls'; + +// 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(); + + return ( + +
+ + + + + {formatMessage('userList.fullName')} + + + + + + {formatMessage('userList.email')} + + + + + + + + {users.map((user) => ( + + + + {user.fullName} + + + + + + {user.email} + + + + + + + + + + + + + + ))} + +
+
+ ); +} diff --git a/packages/web/src/components/WebhookUrlInfo/index.tsx b/packages/web/src/components/WebhookUrlInfo/index.tsx index 82a1a3c1..f510a75c 100644 --- a/packages/web/src/components/WebhookUrlInfo/index.tsx +++ b/packages/web/src/components/WebhookUrlInfo/index.tsx @@ -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'; diff --git a/packages/web/src/components/WebhookUrlInfo/style.ts b/packages/web/src/components/WebhookUrlInfo/style.ts index b74a3834..1396d12b 100644 --- a/packages/web/src/components/WebhookUrlInfo/style.ts +++ b/packages/web/src/components/WebhookUrlInfo/style.ts @@ -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%', diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index c2212e42..6d534654 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -1,36 +1,39 @@ +import appConfig from './app'; + export const CONNECTIONS = '/connections'; export const EXECUTIONS = '/executions'; export const EXECUTION_PATTERN = '/executions/:executionId'; -export const EXECUTION = (executionId: string): string => +export const EXECUTION = (executionId: string) => `/executions/${executionId}`; 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'; export const APPS = '/apps'; export const NEW_APP_CONNECTION = '/apps/new'; -export const APP = (appKey: string): string => `/app/${appKey}`; +export const APP = (appKey: string) => `/app/${appKey}`; export const APP_PATTERN = '/app/:appKey'; -export const APP_CONNECTIONS = (appKey: string): string => +export const APP_CONNECTIONS = (appKey: string) => `/app/${appKey}/connections`; export const APP_CONNECTIONS_PATTERN = '/app/:appKey/connections'; -export const APP_ADD_CONNECTION = (appKey: string): string => +export const APP_ADD_CONNECTION = (appKey: string) => `/app/${appKey}/connections/add`; export const APP_ADD_CONNECTION_PATTERN = '/app/:appKey/connections/add'; export const APP_RECONNECT_CONNECTION = ( appKey: string, connectionId: string -): string => `/app/${appKey}/connections/${connectionId}/reconnect`; +) => `/app/${appKey}/connections/${connectionId}/reconnect`; export const APP_RECONNECT_CONNECTION_PATTERN = '/app/:appKey/connections/:connectionId/reconnect'; -export const APP_FLOWS = (appKey: string): string => `/app/${appKey}/flows`; +export const APP_FLOWS = (appKey: string) => `/app/${appKey}/flows`; export const APP_FLOWS_FOR_CONNECTION = ( appKey: string, connectionId: string -): string => `/app/${appKey}/flows?connectionId=${connectionId}`; +) => `/app/${appKey}/flows?connectionId=${connectionId}`; export const APP_FLOWS_PATTERN = '/app/:appKey/flows'; export const EDITOR = '/editor'; @@ -55,11 +58,11 @@ export const CREATE_FLOW_WITH_APP_AND_CONNECTION = ( return `/editor/create?${searchParams}`; }; -export const FLOW_EDITOR = (flowId: string): string => `/editor/${flowId}`; +export const FLOW_EDITOR = (flowId: string) => `/editor/${flowId}`; export const FLOWS = '/flows'; // TODO: revert this back to /flows/:flowId once we have a proper single flow page -export const FLOW = (flowId: string): string => `/editor/${flowId}`; +export const FLOW = (flowId: string) => `/editor/${flowId}`; export const FLOW_PATTERN = '/flows/:flowId'; export const SETTINGS = '/settings'; @@ -72,6 +75,17 @@ export const SETTINGS_PROFILE = `${SETTINGS}/${PROFILE}`; export const SETTINGS_BILLING_AND_USAGE = `${SETTINGS}/${BILLING_AND_USAGE}`; export const SETTINGS_PLAN_UPGRADE = `${SETTINGS_BILLING_AND_USAGE}/${PLAN_UPGRADE}`; +export const ADMIN_SETTINGS = '/admin-settings'; +export const ADMIN_SETTINGS_DASHBOARD = ADMIN_SETTINGS; +export const USERS = `${ADMIN_SETTINGS}/users`; +export const USER = (userId: string) => `${USERS}/${userId}`; +export const USER_PATTERN = `${USERS}/:userId`; +export const CREATE_USER = `${USERS}/create`; +export const ROLES = `${ADMIN_SETTINGS}/roles`; +export const ROLE = (roleId: string) => `${ROLES}/${roleId}`; +export const ROLE_PATTERN = `${ROLES}/:roleId`; +export const CREATE_ROLE = `${ROLES}/create`; + export const DASHBOARD = FLOWS; // External links diff --git a/packages/web/src/graphql/mutations/create-role.ee.ts b/packages/web/src/graphql/mutations/create-role.ee.ts new file mode 100644 index 00000000..51df0d57 --- /dev/null +++ b/packages/web/src/graphql/mutations/create-role.ee.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const CREATE_ROLE = gql` + mutation CreateRole($input: CreateRoleInput) { + createRole(input: $input) { + id + key + name + description + } + } +`; diff --git a/packages/web/src/graphql/mutations/create-user.ee.ts b/packages/web/src/graphql/mutations/create-user.ee.ts index 5dc9f419..6efc3d88 100644 --- a/packages/web/src/graphql/mutations/create-user.ee.ts +++ b/packages/web/src/graphql/mutations/create-user.ee.ts @@ -3,8 +3,12 @@ import { gql } from '@apollo/client'; export const CREATE_USER = gql` mutation CreateUser($input: CreateUserInput) { createUser(input: $input) { + id email fullName + role { + id + } } } `; diff --git a/packages/web/src/graphql/mutations/delete-current-user.ee.ts b/packages/web/src/graphql/mutations/delete-current-user.ee.ts new file mode 100644 index 00000000..9d10258e --- /dev/null +++ b/packages/web/src/graphql/mutations/delete-current-user.ee.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const DELETE_CURRENT_USER = gql` + mutation DeleteCurrentUser { + deleteCurrentUser + } +`; diff --git a/packages/web/src/graphql/mutations/delete-role.ee.ts b/packages/web/src/graphql/mutations/delete-role.ee.ts new file mode 100644 index 00000000..9e5bf257 --- /dev/null +++ b/packages/web/src/graphql/mutations/delete-role.ee.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const DELETE_ROLE = gql` + mutation DeleteRole($input: DeleteRoleInput) { + deleteRole(input: $input) + } +`; diff --git a/packages/web/src/graphql/mutations/delete-user.ee.ts b/packages/web/src/graphql/mutations/delete-user.ee.ts index 462174eb..ed187077 100644 --- a/packages/web/src/graphql/mutations/delete-user.ee.ts +++ b/packages/web/src/graphql/mutations/delete-user.ee.ts @@ -1,7 +1,7 @@ import { gql } from '@apollo/client'; export const DELETE_USER = gql` - mutation DeleteUser { - deleteUser + mutation DeleteUser($input: DeleteUserInput) { + deleteUser(input: $input) } `; diff --git a/packages/web/src/graphql/mutations/register-user.ee.ts b/packages/web/src/graphql/mutations/register-user.ee.ts new file mode 100644 index 00000000..5f006150 --- /dev/null +++ b/packages/web/src/graphql/mutations/register-user.ee.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const REGISTER_USER = gql` + mutation RegisterUser($input: RegisterUserInput) { + registerUser(input: $input) { + id + email + fullName + } + } +`; diff --git a/packages/web/src/graphql/mutations/update-current-user.ts b/packages/web/src/graphql/mutations/update-current-user.ts new file mode 100644 index 00000000..87ab807f --- /dev/null +++ b/packages/web/src/graphql/mutations/update-current-user.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_CURRENT_USER = gql` + mutation UpdateCurrentUser($input: UpdateCurrentUserInput) { + updateCurrentUser(input: $input) { + id + fullName + email + } + } +`; diff --git a/packages/web/src/graphql/mutations/update-role.ee.ts b/packages/web/src/graphql/mutations/update-role.ee.ts new file mode 100644 index 00000000..fa6ecebf --- /dev/null +++ b/packages/web/src/graphql/mutations/update-role.ee.ts @@ -0,0 +1,17 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_ROLE = gql` + mutation UpdateRole($input: UpdateRoleInput) { + updateRole(input: $input) { + id + name + description + permissions { + id + action + subject + conditions + } + } + } +`; diff --git a/packages/web/src/graphql/mutations/update-user.ts b/packages/web/src/graphql/mutations/update-user.ee.ts similarity index 100% rename from packages/web/src/graphql/mutations/update-user.ts rename to packages/web/src/graphql/mutations/update-user.ee.ts index 74324829..8a943dc5 100644 --- a/packages/web/src/graphql/mutations/update-user.ts +++ b/packages/web/src/graphql/mutations/update-user.ee.ts @@ -4,8 +4,8 @@ export const UPDATE_USER = gql` mutation UpdateUser($input: UpdateUserInput) { updateUser(input: $input) { id - fullName email + fullName } } `; diff --git a/packages/web/src/graphql/queries/get-current-user.ts b/packages/web/src/graphql/queries/get-current-user.ts index 9a17de1f..cf77f9d7 100644 --- a/packages/web/src/graphql/queries/get-current-user.ts +++ b/packages/web/src/graphql/queries/get-current-user.ts @@ -6,6 +6,16 @@ export const GET_CURRENT_USER = gql` id fullName email + role { + id + isAdmin + } + permissions { + id + action + subject + conditions + } } } `; diff --git a/packages/web/src/graphql/queries/get-permission-catalog.ee.ts b/packages/web/src/graphql/queries/get-permission-catalog.ee.ts new file mode 100644 index 00000000..03ca5db0 --- /dev/null +++ b/packages/web/src/graphql/queries/get-permission-catalog.ee.ts @@ -0,0 +1,21 @@ +import { gql } from '@apollo/client'; + +export const GET_PERMISSION_CATALOG = gql` + query GetPermissionCatalog { + getPermissionCatalog { + subjects { + key + label + } + conditions { + key + label + } + actions { + label + key + subjects + } + } + } +`; diff --git a/packages/web/src/graphql/queries/get-role.ee.ts b/packages/web/src/graphql/queries/get-role.ee.ts new file mode 100644 index 00000000..422d006f --- /dev/null +++ b/packages/web/src/graphql/queries/get-role.ee.ts @@ -0,0 +1,19 @@ +import { gql } from '@apollo/client'; + +export const GET_ROLE = gql` + query GetRole($id: String!) { + getRole(id: $id) { + id + key + name + description + isAdmin + permissions { + id + action + subject + conditions + } + } + } +`; diff --git a/packages/web/src/graphql/queries/get-roles.ee.ts b/packages/web/src/graphql/queries/get-roles.ee.ts new file mode 100644 index 00000000..66a53180 --- /dev/null +++ b/packages/web/src/graphql/queries/get-roles.ee.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const GET_ROLES = gql` + query GetRoles { + getRoles { + id + key + name + description + isAdmin + } + } +`; diff --git a/packages/web/src/graphql/queries/get-user.ts b/packages/web/src/graphql/queries/get-user.ts new file mode 100644 index 00000000..f3b5dfad --- /dev/null +++ b/packages/web/src/graphql/queries/get-user.ts @@ -0,0 +1,19 @@ +import { gql } from '@apollo/client'; + +export const GET_USER = gql` + query GetUser($id: String!) { + getUser(id: $id) { + id + fullName + email + role { + id + key + name + isAdmin + } + createdAt + updatedAt + } + } +`; diff --git a/packages/web/src/graphql/queries/get-users.ts b/packages/web/src/graphql/queries/get-users.ts new file mode 100644 index 00000000..08612857 --- /dev/null +++ b/packages/web/src/graphql/queries/get-users.ts @@ -0,0 +1,29 @@ +import { gql } from '@apollo/client'; + +export const GET_USERS = gql` + query GetUsers( + $limit: Int! + $offset: Int! + ) { + getUsers( + limit: $limit + offset: $offset + ) { + pageInfo { + currentPage + totalPages + } + edges { + node { + id + fullName + email + role { + id + name + } + } + } + } + } +`; diff --git a/packages/web/src/helpers/computePermissions.ee.ts b/packages/web/src/helpers/computePermissions.ee.ts new file mode 100644 index 00000000..f57b4cd8 --- /dev/null +++ b/packages/web/src/helpers/computePermissions.ee.ts @@ -0,0 +1,59 @@ +import { IRole, IPermission } from '@automatisch/types'; + +type ComputeAction = { + conditions: Record; + value: boolean; +} +type ComputedActions = Record; +type ComputedPermissions = Record; +export type RoleWithComputedPermissions = IRole & { computedPermissions: ComputedPermissions }; + +export function getRoleWithComputedPermissions(role: IRole): RoleWithComputedPermissions { + const computedPermissions = role.permissions.reduce((computedPermissions, permission) => ({ + ...computedPermissions, + [permission.subject]: { + ...(computedPermissions[permission.subject] || {}), + [permission.action]: { + conditions: Object.fromEntries(permission + .conditions + .map(condition => [condition, true])), + value: true, + }, + } + }), {} as ComputedPermissions); + + return { + ...role, + computedPermissions, + }; +} + +export function getPermissions(computedPermissions?: ComputedPermissions) { + if (!computedPermissions) return []; + + return Object + .entries(computedPermissions) + .reduce((permissions, computedPermissionEntry) => { + const [subject, actionsWithConditions] = computedPermissionEntry; + + for (const action in actionsWithConditions) { + const { + value: permitted, + conditions = {}, + } = actionsWithConditions[action]; + + if (permitted) { + permissions.push({ + action, + subject, + conditions: Object + .entries(conditions) + .filter(([, enabled]) => enabled) + .map(([condition]) => condition), + }) + } + } + + return permissions; + }, [] as Partial[]); +} diff --git a/packages/web/src/helpers/translation-values.tsx b/packages/web/src/helpers/translationValues.tsx similarity index 100% rename from packages/web/src/helpers/translation-values.tsx rename to packages/web/src/helpers/translationValues.tsx diff --git a/packages/web/src/helpers/userAbility.ts b/packages/web/src/helpers/userAbility.ts new file mode 100644 index 00000000..87677707 --- /dev/null +++ b/packages/web/src/helpers/userAbility.ts @@ -0,0 +1,20 @@ +import { PureAbility, fieldPatternMatcher, mongoQueryMatcher } from '@casl/ability'; +import { IUser } from '@automatisch/types'; + +// Must be kept in sync with `packages/backend/src/helpers/user-ability.ts`! +export default function userAbility(user: IUser) { + 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); +} diff --git a/packages/web/src/hooks/useCurrentUserAbility.ts b/packages/web/src/hooks/useCurrentUserAbility.ts new file mode 100644 index 00000000..d27b02a4 --- /dev/null +++ b/packages/web/src/hooks/useCurrentUserAbility.ts @@ -0,0 +1,8 @@ +import userAbility from 'helpers/userAbility'; +import useCurrentUser from 'hooks/useCurrentUser'; + +export default function useCurrentUserAbility() { + const currentUser = useCurrentUser(); + + return userAbility(currentUser); +} diff --git a/packages/web/src/hooks/usePermissionCatalog.ee.ts b/packages/web/src/hooks/usePermissionCatalog.ee.ts new file mode 100644 index 00000000..d9496ceb --- /dev/null +++ b/packages/web/src/hooks/usePermissionCatalog.ee.ts @@ -0,0 +1,10 @@ +import { useQuery } from '@apollo/client'; +import { IPermissionCatalog } from '@automatisch/types'; + +import { GET_PERMISSION_CATALOG } from 'graphql/queries/get-permission-catalog.ee'; + +export default function usePermissionCatalog(): IPermissionCatalog { + const { data } = useQuery(GET_PERMISSION_CATALOG); + + return data?.getPermissionCatalog; +} diff --git a/packages/web/src/hooks/useRole.ee.ts b/packages/web/src/hooks/useRole.ee.ts new file mode 100644 index 00000000..5263254d --- /dev/null +++ b/packages/web/src/hooks/useRole.ee.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { useLazyQuery } from '@apollo/client'; +import { IRole } from '@automatisch/types'; + +import { GET_ROLE } from 'graphql/queries/get-role.ee'; + +type QueryResponse = { + getRole: IRole; +} + +export default function useRole(roleId?: string) { + const [getRole, { data, loading }] = useLazyQuery(GET_ROLE); + + React.useEffect(() => { + if (roleId) { + getRole({ + variables: { + id: roleId + } + }); + } + }, [roleId]); + + return { + role: data?.getRole, + loading + }; +} diff --git a/packages/web/src/hooks/useRoles.ee.ts b/packages/web/src/hooks/useRoles.ee.ts new file mode 100644 index 00000000..34549191 --- /dev/null +++ b/packages/web/src/hooks/useRoles.ee.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@apollo/client'; +import { IRole } from '@automatisch/types'; + +import { GET_ROLES } from 'graphql/queries/get-roles.ee'; + +type QueryResponse = { + getRoles: IRole[]; +} + +export default function useRoles() { + const { data, loading } = useQuery(GET_ROLES, { context: { autoSnackbar: false } }); + + return { + roles: data?.getRoles || [], + loading + }; +} diff --git a/packages/web/src/hooks/useUser.ts b/packages/web/src/hooks/useUser.ts new file mode 100644 index 00000000..3bfbb233 --- /dev/null +++ b/packages/web/src/hooks/useUser.ts @@ -0,0 +1,28 @@ +import * as React from 'react'; +import { useLazyQuery } from '@apollo/client'; +import { IUser } from '@automatisch/types'; + +import { GET_USER } from 'graphql/queries/get-user'; + +type QueryResponse = { + getUser: IUser; +} + +export default function useUser(userId?: string) { + const [getUser, { data, loading }] = useLazyQuery(GET_USER); + + React.useEffect(() => { + if (userId) { + getUser({ + variables: { + id: userId + } + }); + } + }, [userId]); + + return { + user: data?.getUser, + loading + }; +} diff --git a/packages/web/src/hooks/useUsers.ts b/packages/web/src/hooks/useUsers.ts new file mode 100644 index 00000000..66970992 --- /dev/null +++ b/packages/web/src/hooks/useUsers.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@apollo/client'; +import { IUser } from '@automatisch/types'; + +import { GET_USERS } from 'graphql/queries/get-users'; + +type Edge = { + node: IUser +} + +type QueryResponse = { + getUsers: { + pageInfo: { + currentPage: number; + totalPages: number; + } + edges: Edge[] + } +} + +export default function useUsers() { + const { data, loading } = useQuery(GET_USERS, { + variables: { + limit: 100, + offset: 0 + } + }); + const users = data?.getUsers.edges.map(({ node }) => node) || []; + + return { + users, + loading + }; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 05ec3d74..1202f311 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -2,6 +2,7 @@ "brandText": "Automatisch", "searchPlaceholder": "Search", "accountDropdownMenu.settings": "Settings", + "accountDropdownMenu.adminSettings": "Admin", "accountDropdownMenu.logout": "Logout", "drawer.dashboard": "Dashboard", "drawer.flows": "Flows", @@ -12,6 +13,9 @@ "settingsDrawer.goBack": "Go to the dashboard", "settingsDrawer.notifications": "Notifications", "settingsDrawer.billingAndUsage": "Billing and usage", + "adminSettingsDrawer.users": "Users", + "adminSettingsDrawer.roles": "Roles", + "adminSettingsDrawer.goBack": "Go to the dashboard", "app.connectionCount": "{count} connections", "app.flowCount": "{count} flows", "app.addConnection": "Add connection", @@ -130,6 +134,7 @@ "loginForm.noAccount": "Don't have an Automatisch account yet?", "loginForm.signUp": "Sign up", "loginPage.divider": "OR", + "ssoProviders.loginWithProvider": "Login with {providerName}", "forgotPasswordForm.title": "Forgot password", "forgotPasswordForm.submit": "Send reset instructions", "forgotPasswordForm.instructionsSent": "The instructions have been sent!", @@ -165,5 +170,38 @@ "checkoutCompletedAlert.text": "Thank you for upgrading your subscription and supporting our self-funded business!", "subscriptionCancelledAlert.text": "Your subscription is cancelled, but you can continue using Automatisch until {date}.", "customAutocomplete.noOptions": "No options available.", - "powerInputSuggestions.noOptions": "No options available." + "powerInputSuggestions.noOptions": "No options available.", + "usersPage.title": "User management", + "usersPage.createUser": "Create user", + "deleteUserButton.title": "Delete user", + "deleteUserButton.description": "This will permanently delete the user and all the associated data with it.", + "deleteUserButton.cancel": "Cancel", + "deleteUserButton.confirm": "Delete", + "editUserPage.title": "Edit user", + "createUserPage.title": "Create user", + "userForm.fullName": "Full name", + "userForm.email": "Email", + "userForm.role": "Role", + "userForm.password": "Password", + "createUser.submit": "Create", + "editUser.submit": "Update", + "userList.fullName": "Full name", + "userList.email": "Email", + "rolesPage.title": "Role management", + "rolesPage.createRole": "Create role", + "deleteRoleButton.title": "Delete role", + "deleteRoleButton.description": "This will permanently delete the role.", + "deleteRoleButton.cancel": "Cancel", + "deleteRoleButton.confirm": "Delete", + "editRolePage.title": "Edit role", + "createRolePage.title": "Create role", + "roleForm.name": "Name", + "roleForm.description": "Description", + "createRole.submit": "Create", + "editRole.submit": "Update", + "roleList.name": "Name", + "roleList.description": "Description", + "permissionSettings.cancel": "Cancel", + "permissionSettings.apply": "Apply", + "permissionSettings.title": "Conditions" } diff --git a/packages/web/src/pages/Application/index.tsx b/packages/web/src/pages/Application/index.tsx index 2bee8da6..d0ea7cca 100644 --- a/packages/web/src/pages/Application/index.tsx +++ b/packages/web/src/pages/Application/index.tsx @@ -10,7 +10,6 @@ import { useMatch, useNavigate, } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import Box from '@mui/material/Box'; @@ -67,41 +66,6 @@ export default function Application(): React.ReactElement | null { const goToApplicationPage = () => navigate('connections'); const app = data?.getApp || {}; - const NewConnectionLink = React.useMemo( - () => - React.forwardRef>( - function InlineLink(linkProps, ref) { - return ( - - ); - } - ), - [appKey] - ); - - const NewFlowLink = React.useMemo( - () => - React.forwardRef>( - function InlineLink(linkProps, ref) { - return ( - - ); - } - ), - [appKey, connectionId] - ); - if (loading) return null; return ( @@ -131,7 +95,11 @@ export default function Application(): React.ReactElement | null { variant="contained" color="primary" size="large" - component={NewFlowLink} + component={Link} + to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION( + appKey, + connectionId + )} fullWidth icon={} > @@ -148,7 +116,8 @@ export default function Application(): React.ReactElement | null { variant="contained" color="primary" size="large" - component={NewConnectionLink} + component={Link} + to={URLS.APP_ADD_CONNECTION(appKey)} fullWidth icon={} data-test="add-connection-button" diff --git a/packages/web/src/pages/Applications/index.tsx b/packages/web/src/pages/Applications/index.tsx index 97618a07..fcb08cf6 100644 --- a/packages/web/src/pages/Applications/index.tsx +++ b/packages/web/src/pages/Applications/index.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Link, Routes, Route, useNavigate } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; import { useQuery } from '@apollo/client'; import Box from '@mui/material/Box'; import Grid from '@mui/material/Grid'; @@ -9,6 +8,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import AddIcon from '@mui/icons-material/Add'; import type { IApp } from '@automatisch/types'; +import Can from 'components/Can'; import NoResultFound from 'components/NoResultFound'; import ConditionalIconButton from 'components/ConditionalIconButton'; import Container from 'components/Container'; @@ -39,16 +39,6 @@ export default function Applications(): React.ReactElement { navigate(URLS.APPS); }, [navigate]); - const NewAppConnectionLink = React.useMemo( - () => - React.forwardRef>( - function InlineLink(linkProps, ref) { - return ; - } - ), - [] - ); - return ( @@ -69,18 +59,24 @@ export default function Applications(): React.ReactElement { alignItems="center" order={{ xs: 1, sm: 2 }} > - } - data-test="add-connection-button" - > - {formatMessage('apps.addConnection')} - + + {(allowed) => ( + } + data-test="add-connection-button" + > + {formatMessage('apps.addConnection')} + + )} + diff --git a/packages/web/src/pages/CreateRole/index.ee.tsx b/packages/web/src/pages/CreateRole/index.ee.tsx new file mode 100644 index 00000000..8c11a2d6 --- /dev/null +++ b/packages/web/src/pages/CreateRole/index.ee.tsx @@ -0,0 +1,82 @@ +import { useMutation } from '@apollo/client'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import PermissionCatalogField from 'components/PermissionCatalogField/index.ee'; + +import Form from 'components/Form'; +import PageTitle from 'components/PageTitle'; +import TextField from 'components/TextField'; +import * as URLS from 'config/urls'; +import { CREATE_ROLE } from 'graphql/mutations/create-role.ee'; +import { + RoleWithComputedPermissions, + getPermissions, +} from 'helpers/computePermissions.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; + +export default function CreateRole(): React.ReactElement { + const navigate = useNavigate(); + const formatMessage = useFormatMessage(); + const [createRole, { loading }] = useMutation(CREATE_ROLE); + + const handleRoleCreation = async (roleData: Partial) => { + const permissions = getPermissions(roleData.computedPermissions); + + await createRole({ + variables: { + input: { + name: roleData.name, + description: roleData.description, + permissions, + } + } + }); + + navigate(URLS.ROLES); + }; + + return ( + + + + {formatMessage('createRolePage.title')} + + + +
+ + + + + + + + + {formatMessage('createRole.submit')} + + +
+
+
+
+ ); +} diff --git a/packages/web/src/pages/CreateUser/index.tsx b/packages/web/src/pages/CreateUser/index.tsx new file mode 100644 index 00000000..6c358e7b --- /dev/null +++ b/packages/web/src/pages/CreateUser/index.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useMutation } from '@apollo/client'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import MuiTextField from '@mui/material/TextField'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { IUser, IRole } from '@automatisch/types'; + +import { CREATE_USER } from 'graphql/mutations/create-user.ee'; +import * as URLS from 'config/urls'; +import Can from 'components/Can'; +import useRoles from 'hooks/useRoles.ee'; +import PageTitle from 'components/PageTitle'; +import Form from 'components/Form'; +import ControlledAutocomplete from 'components/ControlledAutocomplete'; +import TextField from 'components/TextField'; +import useFormatMessage from 'hooks/useFormatMessage'; + +function generateRoleOptions(roles: IRole[]) { + return roles?.map(({ name: label, id: value }) => ({ label, value })); +} + +export default function CreateUser(): React.ReactElement { + const navigate = useNavigate(); + const formatMessage = useFormatMessage(); + const [createUser, { loading }] = useMutation(CREATE_USER); + const { roles, loading: rolesLoading } = useRoles(); + + const handleUserCreation = async (userData: Partial) => { + await createUser({ + variables: { + input: { + fullName: userData.fullName, + password: userData.password, + email: userData.email, + role: { + id: userData.role?.id + } + } + } + }); + + navigate(URLS.USERS); + }; + + return ( + + + + {formatMessage('createUserPage.title')} + + + +
+ + + + + + + + + } + loading={rolesLoading} + /> + + + + {formatMessage('createUser.submit')} + + +
+
+
+
+ ); +} diff --git a/packages/web/src/pages/EditRole/index.ee.tsx b/packages/web/src/pages/EditRole/index.ee.tsx new file mode 100644 index 00000000..fdd10bb3 --- /dev/null +++ b/packages/web/src/pages/EditRole/index.ee.tsx @@ -0,0 +1,103 @@ +import { useMutation } from '@apollo/client'; +import LoadingButton from '@mui/lab/LoadingButton'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import * as React from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + +import Form from 'components/Form'; +import PageTitle from 'components/PageTitle'; +import PermissionCatalogField from 'components/PermissionCatalogField/index.ee'; +import TextField from 'components/TextField'; +import * as URLS from 'config/urls'; +import { UPDATE_ROLE } from 'graphql/mutations/update-role.ee'; +import { + RoleWithComputedPermissions, + getPermissions, + getRoleWithComputedPermissions, +} from 'helpers/computePermissions.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; +import useRole from 'hooks/useRole.ee'; + +type EditRoleParams = { + roleId: string; +} + +// TODO: introduce interaction feedback upon deletion (successful + failure) +// TODO: introduce loading bar +export default function EditRole(): React.ReactElement { + const formatMessage = useFormatMessage(); + const [updateRole, { loading }] = useMutation(UPDATE_ROLE); + const navigate = useNavigate(); + const { roleId } = useParams(); + const { role, loading: roleLoading } = useRole(roleId); + + const handleRoleUpdate = async (roleData: Partial) => { + const newPermissions = getPermissions(roleData.computedPermissions); + + await updateRole({ + variables: { + input: { + id: roleId, + name: roleData.name, + description: roleData.description, + permissions: newPermissions, + } + }, + }); + + navigate(URLS.ROLES); + }; + + if (roleLoading || !role) return ; + + const roleWithComputedPermissions = getRoleWithComputedPermissions(role); + + return ( + + + + {formatMessage('editRolePage.title')} + + + +
+ + + + + + + + + {formatMessage('editRole.submit')} + + +
+
+
+
+ ); +} diff --git a/packages/web/src/pages/EditUser/index.tsx b/packages/web/src/pages/EditUser/index.tsx new file mode 100644 index 00000000..e7310018 --- /dev/null +++ b/packages/web/src/pages/EditUser/index.tsx @@ -0,0 +1,106 @@ +import * as React from 'react'; +import { useParams } from 'react-router-dom'; +import { useMutation } from '@apollo/client'; +import Container from '@mui/material/Container'; +import Grid from '@mui/material/Grid'; +import Stack from '@mui/material/Stack'; +import MuiTextField from '@mui/material/TextField'; +import LoadingButton from '@mui/lab/LoadingButton'; +import { IUser, IRole } from '@automatisch/types'; + +import { UPDATE_USER } from 'graphql/mutations/update-user.ee'; +import Can from 'components/Can'; +import useUser from 'hooks/useUser'; +import useRoles from 'hooks/useRoles.ee'; +import PageTitle from 'components/PageTitle'; +import Form from 'components/Form'; +import ControlledAutocomplete from 'components/ControlledAutocomplete'; +import TextField from 'components/TextField'; +import useFormatMessage from 'hooks/useFormatMessage'; + +type EditUserParams = { + userId: string; +} + +function generateRoleOptions(roles: IRole[]) { + return roles?.map(({ name: label, id: value }) => ({ label, value })); +} + +// TODO: introduce interaction feedback upon deletion (successful + failure) +// TODO: introduce loading bar +export default function EditUser(): React.ReactElement { + const formatMessage = useFormatMessage(); + const [updateUser, { loading }] = useMutation(UPDATE_USER); + const { userId } = useParams(); + const { user, loading: userLoading } = useUser(userId); + const { roles, loading: rolesLoading } = useRoles(); + + const handleUserUpdate = (userDataToUpdate: Partial) => { + updateUser({ + variables: { + input: { + id: userId, + fullName: userDataToUpdate.fullName, + email: userDataToUpdate.email, + role: { + id: userDataToUpdate.role?.id + } + } + } + }); + }; + + if (userLoading) return ; + + return ( + + + + {formatMessage('editUserPage.title')} + + + +
+ + + + + + + } + loading={rolesLoading} + /> + + + + {formatMessage('editUser.submit')} + + +
+
+
+
+ ); +} diff --git a/packages/web/src/pages/Flows/index.tsx b/packages/web/src/pages/Flows/index.tsx index 19bb7fe8..9c6ade5b 100644 --- a/packages/web/src/pages/Flows/index.tsx +++ b/packages/web/src/pages/Flows/index.tsx @@ -1,6 +1,5 @@ import * as React from 'react'; import { Link, useSearchParams } from 'react-router-dom'; -import type { LinkProps } from 'react-router-dom'; import { useLazyQuery } from '@apollo/client'; import debounce from 'lodash/debounce'; import Box from '@mui/material/Box'; @@ -12,6 +11,7 @@ import Pagination from '@mui/material/Pagination'; import PaginationItem from '@mui/material/PaginationItem'; import type { IFlow } from '@automatisch/types'; +import Can from 'components/Can'; import FlowRow from 'components/FlowRow'; import NoResultFound from 'components/NoResultFound'; import ConditionalIconButton from 'components/ConditionalIconButton'; @@ -88,16 +88,6 @@ export default function Flows(): React.ReactElement { setFlowName(event.target.value); }, []); - const CreateFlowLink = React.useMemo( - () => - React.forwardRef>( - function InlineLink(linkProps, ref) { - return ; - } - ), - [] - ); - return ( @@ -118,18 +108,24 @@ export default function Flows(): React.ReactElement { alignItems="center" order={{ xs: 1, sm: 2 }} > - } - data-test="create-flow-button" - > - {formatMessage('flows.create')} - + + {(allowed) => ( + } + to={URLS.CREATE_FLOW} + data-test="create-flow-button" + > + {formatMessage('flows.create')} + + )} + diff --git a/packages/web/src/pages/ProfileSettings/index.tsx b/packages/web/src/pages/ProfileSettings/index.tsx index 4cda68e6..13526530 100644 --- a/packages/web/src/pages/ProfileSettings/index.tsx +++ b/packages/web/src/pages/ProfileSettings/index.tsx @@ -15,7 +15,7 @@ import Container from 'components/Container'; import Form from 'components/Form'; import TextField from 'components/TextField'; import DeleteAccountDialog from 'components/DeleteAccountDialog/index.ee'; -import { UPDATE_USER } from 'graphql/mutations/update-user'; +import { UPDATE_CURRENT_USER } from 'graphql/mutations/update-current-user'; import useFormatMessage from 'hooks/useFormatMessage'; import useCurrentUser from 'hooks/useCurrentUser'; @@ -47,7 +47,7 @@ function ProfileSettings() { const { enqueueSnackbar } = useSnackbar(); const currentUser = useCurrentUser(); const formatMessage = useFormatMessage(); - const [updateUser] = useMutation(UPDATE_USER); + const [updateCurrentUser] = useMutation(UPDATE_CURRENT_USER); const handleProfileSettingsUpdate = async (data: any) => { const { fullName, password, email } = data; @@ -61,12 +61,12 @@ function ProfileSettings() { mutationInput.password = password; } - await updateUser({ + await updateCurrentUser({ variables: { input: mutationInput, }, optimisticResponse: { - updateUser: { + updateCurrentUser: { __typename: 'User', id: currentUser.id, fullName, @@ -89,7 +89,7 @@ function ProfileSettings() { - + + + + {formatMessage('rolesPage.title')} + + + + } + data-test="create-role" + > + {formatMessage('rolesPage.createRole')} + + + + + + + + + + ); +} + +export default RolesPage; diff --git a/packages/web/src/pages/Users/index.tsx b/packages/web/src/pages/Users/index.tsx new file mode 100644 index 00000000..e1a94619 --- /dev/null +++ b/packages/web/src/pages/Users/index.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import Grid from '@mui/material/Grid'; +import AddIcon from '@mui/icons-material/Add'; + +import * as URLS from 'config/urls'; +import PageTitle from 'components/PageTitle'; +import Container from 'components/Container'; +import UserList from 'components/UserList'; +import ConditionalIconButton from 'components/ConditionalIconButton'; +import useFormatMessage from 'hooks/useFormatMessage'; + +function UsersPage() { + const formatMessage = useFormatMessage(); + + return ( + + + + + {formatMessage('usersPage.title')} + + + + } + data-test="create-user" + > + {formatMessage('usersPage.createUser')} + + + + + + + + + + ); +} + +export default UsersPage; diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index 79860faf..23274fc9 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -15,6 +15,7 @@ import ResetPassword from 'pages/ResetPassword/index.ee'; import EditorRoutes from 'pages/Editor/routes'; import * as URLS from 'config/urls'; import settingsRoutes from './settingsRoutes'; +import adminSettingsRoutes from './adminSettingsRoutes'; import Notifications from 'pages/Notifications'; export default ( @@ -127,7 +128,9 @@ export default ( } /> - {settingsRoutes} + {settingsRoutes} + + {adminSettingsRoutes}