diff --git a/packages/backend/package.json b/packages/backend/package.json index ba044e75..9644ae2a 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/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/db/migrations/20230720143341_add_conditions_in_permissions.ts b/packages/backend/src/db/migrations/20230720143341_add_conditions_in_permissions.ts new file mode 100644 index 00000000..d995f9f5 --- /dev/null +++ b/packages/backend/src/db/migrations/20230720143341_add_conditions_in_permissions.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.alterTable('permissions', (table) => { + table.jsonb('conditions').notNullable().defaultTo([]); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable('permissions', (table) => { + table.dropColumn('conditions'); + }); +} 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..abcc3374 --- /dev/null +++ b/packages/backend/src/graphql/mutations/create-role.ee.ts @@ -0,0 +1,32 @@ +import kebabCase from 'lodash/kebabCase'; +import Role from '../../models/role'; +import Permission from '../../models/permission'; + +type Params = { + input: { + name: string; + description: string; + permissions: Permission[]; + }; +}; + +// TODO: access +const createRole = async (_parent: unknown, params: Params) => { + 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..80673ee5 100644 --- a/packages/backend/src/graphql/mutations/create-user.ee.ts +++ b/packages/backend/src/graphql/mutations/create-user.ee.ts @@ -1,16 +1,17 @@ import User from '../../models/user'; -import Role from '../../models/role'; type Params = { input: { fullName: string; email: string; password: string; + roleId: string; }; }; +// TODO: access const createUser = async (_parent: unknown, params: Params) => { - const { fullName, email, password } = params.input; + const { fullName, email, password, roleId } = params.input; const existingUser = await User.query().findOne({ email }); @@ -18,13 +19,11 @@ 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({ fullName, email, password, - roleId: role.id, + roleId, }); 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..585522ca --- /dev/null +++ b/packages/backend/src/graphql/mutations/delete-current-user.ee.ts @@ -0,0 +1,23 @@ +import { Duration } from 'luxon'; +import Context from '../../types/express/context'; +import deleteUserQueue from '../../queues/delete-user.ee'; + +// TODO: access +const deleteUser = async (_parent: unknown, params: never, context: Context) => { + const 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 deleteUser; 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..6ddd1539 --- /dev/null +++ b/packages/backend/src/graphql/mutations/delete-role.ee.ts @@ -0,0 +1,31 @@ +import Role from '../../models/role'; +import Context from '../../types/express/context'; + +type Params = { + input: { + id: string; + }; +}; + +// TODO: access +const deleteRole = async ( + _parent: unknown, + params: Params, + context: Context +) => { + const role = await Role.query().findById(params.input.id).throwIfNotFound(); + + if (role.isAdmin) { + throw new Error('Admin role cannot be deleted!'); + } + + /** + * TODO: consider migrations for users that still have the role or + * do not let the role get deleted if there are still associated users + */ + 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..ddbd3890 100644 --- a/packages/backend/src/graphql/mutations/delete-user.ee.ts +++ b/packages/backend/src/graphql/mutations/delete-user.ee.ts @@ -1,11 +1,23 @@ -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(); +// TODO: access +const deleteUser = async ( + _parent: unknown, + params: Params, + context: Context +) => { + 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..e24ff6e2 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-role.ee.ts @@ -0,0 +1,44 @@ +import Context from '../../types/express/context'; +import Role from '../../models/role'; +import Permission from '../../models/permission'; + +type Params = { + input: { + id: string; + name: string; + description: string; + permissions: Permission[]; + }; +}; + +// TODO: access +const updateUser = async ( + _parent: unknown, + params: Params, + context: Context +) => { + const { + id, + name, + description, + permissions, + } = params.input; + + const role = await Role.query().findById(id).throwIfNotFound(); + + // TODO: delete the unrelated items! + await role.$relatedQuery('permissions').unrelate(); + + // TODO: possibly assert that given permissions do actually exist in catalog + // TODO: possibly optimize it with patching the different permissions compared to current ones + return await role.$query() + .patchAndFetch( + { + name, + description, + permissions, + } + ); +}; + +export default updateUser; 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..8ddb5012 --- /dev/null +++ b/packages/backend/src/graphql/mutations/update-user.ee.ts @@ -0,0 +1,34 @@ +import Context from '../../types/express/context'; +import User from '../../models/user'; + +type Params = { + input: { + id: string; + email: string; + fullName: string; + role: { + id: string; + }; + }; +}; + +// TODO: access +const updateUser = async ( + _parent: unknown, + params: Params, + context: Context +) => { + const user = await User.query() + .patchAndFetchById( + params.input.id, + { + email: params.input.email, + fullName: params.input.fullName, + roleId: params.input.role.id, + } + ); + + return user; +}; + +export default updateUser; diff --git a/packages/backend/src/graphql/queries/get-permissions.ee.ts b/packages/backend/src/graphql/queries/get-permissions.ee.ts new file mode 100644 index 00000000..bcda2e27 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-permissions.ee.ts @@ -0,0 +1,76 @@ +const getPermissions = async () => { + const Connection = { + label: 'Connection', + key: 'Connection', + }; + + const Flow = { + label: 'Flow', + key: 'Flow', + }; + + const Execution = { + label: 'Execution', + key: 'Execution', + }; + + const permissions = { + conditions: [ + { + key: 'isCreator', + label: 'Is creator' + } + ], + actions: [ + { + label: 'Create', + action: 'create', + subjects: [ + Connection.key, + Flow.key, + ] + }, + { + label: 'Read', + action: 'read', + subjects: [ + Connection.key, + Execution.key, + Flow.key, + ] + }, + { + label: 'Update', + action: 'update', + subjects: [ + Connection.key, + Flow.key, + ] + }, + { + label: 'Delete', + action: 'delete', + subjects: [ + Connection.key, + Flow.key, + ] + }, + { + label: 'Publish', + action: 'publish', + subjects: [ + Flow.key, + ] + } + ], + subjects: [ + Connection, + Flow, + Execution + ] + }; + + return permissions; +}; + +export default getPermissions; 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..a5697019 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-role.ee.ts @@ -0,0 +1,13 @@ +import Context from '../../types/express/context'; +import Role from '../../models/role'; + +type Params = { + id: string +}; + +// TODO: access +const getRole = async (_parent: unknown, params: Params, context: Context) => { + return await Role.query().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..1d9fb2d0 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-roles.ee.ts @@ -0,0 +1,9 @@ +import Context from '../../types/express/context'; +import Role from '../../models/role'; + +// TODO: access +const getRoles = async (_parent: unknown, params: unknown, context: Context) => { + return await Role.query(); +}; + +export default getRoles; 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..25084d88 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-user.ts @@ -0,0 +1,22 @@ +import Context from '../../types/express/context'; +import User from '../../models/user'; + +type Params = { + id: string +}; + +// TODO: access +const getUser = async (_parent: unknown, params: Params, context: Context) => { + 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..12117138 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-users.ts @@ -0,0 +1,25 @@ +import Context from '../../types/express/context'; +import paginate from '../../helpers/pagination'; +import User from '../../models/user'; + +type Params = { + limit: number; + offset: number; +}; + +// TODO: access +const getUsers = async (_parent: unknown, params: Params, context: Context) => { + 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/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index 1af07484..e539a54c 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 getPermissions from './queries/get-permissions.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, + getPermissions, + 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..aa30d6bd 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 + getPermissions: Permissions 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 { + action: String + subject: String + conditions: [String] +} + +# TODO: emphasize it's a catalog item +type Permissions { + actions: [Action] + subjects: [Subject] + conditions: [Condition] +} + +type Action { + label: String + action: 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) => { + 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..62bd0e08 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'; @@ -24,6 +26,10 @@ class Role extends Base { }, }; + static get virtualAttributes() { + return ['isAdmin']; + } + static relationMappings = () => ({ users: { relation: Base.HasManyRelation, @@ -33,7 +39,23 @@ class Role extends Base { to: 'users.role_id', }, }, + permissions: { + relation: Base.ManyToManyRelation, + modelClass: Permission, + join: { + from: 'roles.id', + through: { + from: 'roles_permissions.role_id', + to: 'roles_permissions.permission_id', + }, + to: 'permissions.id', + }, + }, }); + + get isAdmin() { + return this.key === 'admin'; + } } export default Role; diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index a2341199..0c77d496 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -2,7 +2,7 @@ import crypto from 'node:crypto'; import { QueryContext, ModelOptions } from 'objection'; import bcrypt from 'bcrypt'; import { DateTime } from 'luxon'; -import { Ability } from '@casl/ability'; +import { PureAbility, fieldPatternMatcher, mongoQueryMatcher } from '@casl/ability'; import type { Subject } from '@casl/ability'; import appConfig from '../config/app'; @@ -297,7 +297,11 @@ class User extends Base { throw new Error('User.permissions must be fetched!'); } - return new Ability(this.permissions); + // We're not using mongo, but our fields, conditions match + return new PureAbility(this.permissions, { + conditionsMatcher: mongoQueryMatcher, + fieldMatcher: fieldPatternMatcher + }); } can(action: string, subject: Subject) { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 06643d92..f1660463 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -95,6 +95,15 @@ export interface IUser { connections: IConnection[]; flows: IFlow[]; steps: IStep[]; + role: IRole; +} + +export interface IRole { + id: string; + key: string; + name: string; + description: string; + isAdmin: boolean; } export interface IFieldDropdown { diff --git a/packages/web/src/adminSettingsRoutes.tsx b/packages/web/src/adminSettingsRoutes.tsx new file mode 100644 index 00000000..cb35f588 --- /dev/null +++ b/packages/web/src/adminSettingsRoutes.tsx @@ -0,0 +1,73 @@ +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'; + +export default ( + <> + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + } + /> + +); diff --git a/packages/web/src/components/AccountDropdownMenu/index.tsx b/packages/web/src/components/AccountDropdownMenu/index.tsx index e34cd045..b9d2b461 100644 --- a/packages/web/src/components/AccountDropdownMenu/index.tsx +++ b/packages/web/src/components/AccountDropdownMenu/index.tsx @@ -54,6 +54,10 @@ function AccountDropdownMenu( {formatMessage('accountDropdownMenu.settings')} + + {formatMessage('accountDropdownMenu.adminSettings')} + + {formatMessage('accountDropdownMenu.logout')} diff --git a/packages/web/src/components/AdminSettingsLayout/index.tsx b/packages/web/src/components/AdminSettingsLayout/index.tsx new file mode 100644 index 00000000..897be1f6 --- /dev/null +++ b/packages/web/src/components/AdminSettingsLayout/index.tsx @@ -0,0 +1,81 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Toolbar from '@mui/material/Toolbar'; +import { useTheme } from '@mui/material/styles'; +import useMediaQuery from '@mui/material/useMediaQuery'; +import GroupIcon from '@mui/icons-material/Group'; +import GroupsIcon from '@mui/icons-material/Groups'; +import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; + +import * as URLS from 'config/urls'; +import useAutomatischInfo from 'hooks/useAutomatischInfo'; +import AppBar from 'components/AppBar'; +import Drawer from 'components/Drawer'; + +type SettingsLayoutProps = { + children: React.ReactNode; +}; + +function createDrawerLinks({ isCloud }: { isCloud: boolean }) { + const items = [ + { + Icon: GroupIcon, + primary: 'adminSettingsDrawer.users', + to: URLS.USERS, + }, + { + Icon: GroupsIcon, + primary: 'adminSettingsDrawer.roles', + to: URLS.ROLES, + } + ] + + return items; +} + +const drawerBottomLinks = [ + { + Icon: ArrowBackIosNewIcon, + primary: 'adminSettingsDrawer.goBack', + to: '/', + }, +]; + +export default function SettingsLayout({ + children, +}: SettingsLayoutProps): React.ReactElement { + const { isCloud } = useAutomatischInfo(); + const theme = useTheme(); + const matchSmallScreens = useMediaQuery(theme.breakpoints.down('lg')); + const [isDrawerOpen, setDrawerOpen] = React.useState(!matchSmallScreens); + + const openDrawer = () => setDrawerOpen(true); + const closeDrawer = () => setDrawerOpen(false); + const drawerLinks = createDrawerLinks({ isCloud }); + + return ( + <> + + + + + + + + + {children} + + + + ); +} diff --git a/packages/web/src/components/ConditionalIconButton/index.tsx b/packages/web/src/components/ConditionalIconButton/index.tsx index dd8bcaf4..36bfc784 100644 --- a/packages/web/src/components/ConditionalIconButton/index.tsx +++ b/packages/web/src/components/ConditionalIconButton/index.tsx @@ -19,6 +19,7 @@ export default function ConditionalIconButton(props: any): React.ReactElement { type={buttonProps.type} size={buttonProps.size} component={buttonProps.component} + to={buttonProps.to} > {icon} 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/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..ad3f366f --- /dev/null +++ b/packages/web/src/components/DeleteRoleButton/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_ROLE } from 'graphql/mutations/delete-role.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; + +type DeleteRoleButtonProps = { + roleId: string; +} + +export default function DeleteRoleButton(props: DeleteRoleButtonProps) { + const { 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 ( + <> + 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/RoleList/index.ee.tsx b/packages/web/src/components/RoleList/index.ee.tsx new file mode 100644 index 00000000..6b74731e --- /dev/null +++ b/packages/web/src/components/RoleList/index.ee.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 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/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..2315ad8f --- /dev/null +++ b/packages/web/src/components/UserList/index.tsx @@ -0,0 +1,94 @@ +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 translation entries +// TODO: introduce interaction feedback upon deletion (successful + failure) +// TODO: introduce loading bar +export default function UserList(): React.ReactElement { + const formatMessage = useFormatMessage(); + const { users, loading } = useUsers(); + + return ( + +
+ + + + + {formatMessage('userList.fullName')} + + + + + + {formatMessage('userList.email')} + + + + + + + + {users.map((user) => ( + + + + {user.fullName} + + + + + + {user.email} + + + + + + + + + + + + + + ))} + +
+
+ ); +} diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index c2212e42..dbf07e9b 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -1,7 +1,7 @@ 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'; @@ -12,25 +12,25 @@ 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 +55,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 +72,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..d7f22a5e --- /dev/null +++ b/packages/web/src/graphql/mutations/update-role.ee.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_ROLE = gql` + mutation UpdateRole($input: UpdateRoleInput) { + updateRole(input: $input) { + id + name + description + } + } +`; 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..dcbd26a7 100644 --- a/packages/web/src/graphql/queries/get-current-user.ts +++ b/packages/web/src/graphql/queries/get-current-user.ts @@ -6,6 +6,14 @@ export const GET_CURRENT_USER = gql` id fullName email + role { + isAdmin + } + permissions { + action + subject + conditions + } } } `; diff --git a/packages/web/src/graphql/queries/get-permissions.ee.ts b/packages/web/src/graphql/queries/get-permissions.ee.ts new file mode 100644 index 00000000..5cdd04ee --- /dev/null +++ b/packages/web/src/graphql/queries/get-permissions.ee.ts @@ -0,0 +1,21 @@ +import { gql } from '@apollo/client'; + +export const GET_PERMISSIONS = gql` + query GetPermissions { + getPermissions { + subjects { + key + label + } + conditions { + key + label + } + actions { + label + action + 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..344508af --- /dev/null +++ b/packages/web/src/graphql/queries/get-role.ee.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const GET_ROLE = gql` + query GetRole($id: String!) { + getRole(id: $id) { + id + key + name + description + } + } +`; 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..88e5692f --- /dev/null +++ b/packages/web/src/graphql/queries/get-roles.ee.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const GET_ROLES = gql` + query GetRoles { + getRoles { + id + key + name + description + } + } +`; 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/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..03ecc5cc --- /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); + + 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..9ef271f9 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", @@ -165,5 +169,35 @@ "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" } 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..37aa6bef --- /dev/null +++ b/packages/web/src/pages/CreateRole/index.ee.tsx @@ -0,0 +1,74 @@ +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 LoadingButton from '@mui/lab/LoadingButton'; +import { IRole } from '@automatisch/types'; + +import { CREATE_ROLE } from 'graphql/mutations/create-role.ee'; +import * as URLS from 'config/urls'; +import PageTitle from 'components/PageTitle'; +import Form from 'components/Form'; +import TextField from 'components/TextField'; +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) => { + await createRole({ + variables: { + input: { + name: roleData.name, + description: roleData.description, + } + } + }); + + 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..44516359 --- /dev/null +++ b/packages/web/src/pages/CreateUser/index.tsx @@ -0,0 +1,104 @@ +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 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..e64aff02 --- /dev/null +++ b/packages/web/src/pages/EditRole/index.ee.tsx @@ -0,0 +1,82 @@ +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 LoadingButton from '@mui/lab/LoadingButton'; +import { IRole } from '@automatisch/types'; + +import { UPDATE_ROLE } from 'graphql/mutations/update-role.ee'; +import useRole from 'hooks/useRole.ee'; +import PageTitle from 'components/PageTitle'; +import Form from 'components/Form'; +import TextField from 'components/TextField'; +import useFormatMessage from 'hooks/useFormatMessage'; + +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 { roleId } = useParams(); + const { role, loading: roleLoading } = useRole(roleId); + + const handleRoleUpdate = (roleData: Partial) => { + updateRole({ + variables: { + input: { + id: roleId, + name: roleData.name, + description: roleData.description, + } + } + }); + }; + + if (roleLoading) return ; + + 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..06be358c --- /dev/null +++ b/packages/web/src/pages/EditUser/index.tsx @@ -0,0 +1,103 @@ +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 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/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}