diff --git a/backend/core-api/src/apollo/apolloServer.ts b/backend/core-api/src/apollo/apolloServer.ts index 0349ca5092..1169234166 100644 --- a/backend/core-api/src/apollo/apolloServer.ts +++ b/backend/core-api/src/apollo/apolloServer.ts @@ -3,15 +3,15 @@ import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer'; import { buildSubgraphSchema } from '@apollo/subgraph'; import * as dotenv from 'dotenv'; +import { IMainContext } from 'erxes-api-shared/core-types'; import { - generateApolloContext, apolloCommonTypes, - wrapApolloMutations, + generateApolloContext, + wrapApolloResolvers, } from 'erxes-api-shared/utils'; import { gql } from 'graphql-tag'; import { generateModels } from '../connectionResolvers'; import resolvers from './resolvers'; -import { IMainContext } from 'erxes-api-shared/core-types'; import * as typeDefDetails from './schema/schema'; // load environment variables @@ -42,10 +42,7 @@ export const initApolloServer = async (app, httpServer) => { schema: buildSubgraphSchema([ { typeDefs: await typeDefs(), - resolvers: { - ...resolvers, - Mutation: wrapApolloMutations(resolvers?.Mutation || {}, ['login']), - }, + resolvers: wrapApolloResolvers(resolvers), }, ]), plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], diff --git a/backend/core-api/src/apollo/resolvers/mutations.ts b/backend/core-api/src/apollo/resolvers/mutations.ts index d6b0787610..5d003f196f 100644 --- a/backend/core-api/src/apollo/resolvers/mutations.ts +++ b/backend/core-api/src/apollo/resolvers/mutations.ts @@ -18,6 +18,7 @@ import { relationsMutations } from '@/relations/graphql/mutations'; import { segmentMutations } from '@/segments/graphql/resolvers/mutations'; import { tagMutations } from '@/tags/graphql/mutations'; import { notificationMutations } from '~/modules/notifications/graphql/resolver/mutations'; +import { roleMutations } from '~/modules/permissions/graphql/resolvers/mutations/role'; export const mutations = { ...contactMutations, @@ -40,4 +41,5 @@ export const mutations = { ...automationMutations, ...notificationMutations, ...internalNoteMutations, + ...roleMutations, }; diff --git a/backend/core-api/src/apollo/resolvers/queries.ts b/backend/core-api/src/apollo/resolvers/queries.ts index 2d54705159..aca318a42d 100644 --- a/backend/core-api/src/apollo/resolvers/queries.ts +++ b/backend/core-api/src/apollo/resolvers/queries.ts @@ -20,6 +20,7 @@ import { segmentQueries } from '@/segments/graphql/resolvers'; import { tagQueries } from '@/tags/graphql/queries'; import { notificationQueries } from '@/notifications/graphql/resolver/queries'; +import { roleQueries } from '@/permissions/graphql/resolvers/queries/role'; export const queries = { ...contactQueries, @@ -43,4 +44,5 @@ export const queries = { ...logQueries, ...notificationQueries, ...internalNoteQueries, + ...roleQueries, }; diff --git a/backend/core-api/src/apollo/resolvers/resolvers.ts b/backend/core-api/src/apollo/resolvers/resolvers.ts index 1490ab4709..1666e88e13 100644 --- a/backend/core-api/src/apollo/resolvers/resolvers.ts +++ b/backend/core-api/src/apollo/resolvers/resolvers.ts @@ -3,13 +3,14 @@ import contactResolvers from '@/contacts/graphql/resolvers/customResolvers'; import documentResolvers from '@/documents/graphql/customResolvers'; import internalNoteResolvers from '@/internalNote/graphql/customResolvers'; import logResolvers from '@/logs/graphql/resolvers/customResolvers'; +import notificationResolvers from '@/notifications/graphql/customResolvers'; import brandResolvers from '@/organization/brand/graphql/customResolver/brand'; import structureResolvers from '@/organization/structure/graphql/resolvers/customResolvers'; import userResolvers from '@/organization/team-member/graphql/customResolver'; +import permissionResolvers from '@/permissions/graphql/resolvers/customResolver'; import productResolvers from '@/products/graphql/resolvers/customResolvers'; import segmentResolvers from '@/segments/graphql/resolvers/customResolvers'; import tagResolvers from '@/tags/graphql/customResolvers'; -import notificationResolvers from '@/notifications/graphql/customResolvers'; export const customResolvers = { ...contactResolvers, @@ -24,4 +25,5 @@ export const customResolvers = { ...notificationResolvers, ...documentResolvers, ...internalNoteResolvers, + ...permissionResolvers, }; diff --git a/backend/core-api/src/apollo/schema/schema.ts b/backend/core-api/src/apollo/schema/schema.ts index 30060b2c8e..dd75e2a632 100644 --- a/backend/core-api/src/apollo/schema/schema.ts +++ b/backend/core-api/src/apollo/schema/schema.ts @@ -142,15 +142,21 @@ import { } from '@/logs/graphql/schema'; import { - mutations as NotificationsMutations, - queries as NotificationsQueries, - types as NotificationsTypes, -} from '@/notifications/graphql/schema'; -import{ mutations as InternalNoteMutations, queries as InternalNoteQueries, types as InternalNoteTypes, } from '@/internalNote/graphql/schemas'; +import { + mutations as NotificationsMutations, + queries as NotificationsQueries, + types as NotificationsTypes, +} from '@/notifications/graphql/schema'; + +import { + mutations as RoleMutations, + queries as RoleQueries, + types as RoleTypes, +} from '@/permissions/graphql/schemas/role'; export const types = ` enum CacheControlScope { @@ -192,6 +198,7 @@ export const types = ` ${LogsTypes} ${NotificationsTypes} ${InternalNoteTypes} + ${RoleTypes} `; export const queries = ` @@ -221,6 +228,7 @@ export const queries = ` ${LogsQueries} ${NotificationsQueries} ${InternalNoteQueries} + ${RoleQueries} `; export const mutations = ` @@ -249,6 +257,7 @@ export const mutations = ` ${AutomationsMutations} ${NotificationsMutations} ${InternalNoteMutations} + ${RoleMutations} `; export default { types, queries, mutations }; diff --git a/backend/core-api/src/connectionResolvers.ts b/backend/core-api/src/connectionResolvers.ts index 1242ba77fa..17dd371bbe 100644 --- a/backend/core-api/src/connectionResolvers.ts +++ b/backend/core-api/src/connectionResolvers.ts @@ -77,6 +77,7 @@ import { IProductDocument, IProductsConfigDocument, IRelationDocument, + IRoleDocument, ITagDocument, IUomDocument, IUserDocument, @@ -145,6 +146,10 @@ import { INotificationDocument, notificationSchema, } from 'erxes-api-shared/core-modules'; +import { + IRoleModel, + loadRoleClass, +} from '~/modules/permissions/db/models/Roles'; import { IAutomationModel, loadClass as loadAutomationClass, @@ -162,6 +167,7 @@ export interface IModels { UserMovements: IUserMovemmentModel; Configs: IConfigModel; Permissions: IPermissionModel; + Roles: IRoleModel; UsersGroups: IUserGroupModel; Tags: ITagModel; InternalNotes: IInternalNoteModel; @@ -244,6 +250,11 @@ export const loadClasses = ( loadPermissionClass(models), ); + models.Roles = db.model( + 'roles', + loadRoleClass(models), + ); + models.UsersGroups = db.model( 'user_groups', loadUserGroupClass(models), diff --git a/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts b/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts index 712a29be49..7d33d870ee 100644 --- a/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts +++ b/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts @@ -1,13 +1,14 @@ +import { WorkOS } from '@workos-inc/node'; import { authCookieOptions, getEnv, logHandler, + markResolvers, redis, updateSaasOrganization, } from 'erxes-api-shared/utils'; -import { IContext } from '~/connectionResolvers'; -import { WorkOS } from '@workos-inc/node'; import * as jwt from 'jsonwebtoken'; +import { IContext } from '~/connectionResolvers'; import { getCallbackRedirectUrl, isValidEmail, @@ -239,3 +240,7 @@ export const authMutations = { return 'success'; }, }; + +markResolvers(authMutations, { + skipPermission: true, +}); diff --git a/backend/core-api/src/modules/auth/graphql/resolvers/queries.ts b/backend/core-api/src/modules/auth/graphql/resolvers/queries.ts index a6394e38e0..c918e3d5d3 100644 --- a/backend/core-api/src/modules/auth/graphql/resolvers/queries.ts +++ b/backend/core-api/src/modules/auth/graphql/resolvers/queries.ts @@ -1,3 +1,4 @@ +import { markResolvers } from 'erxes-api-shared/utils/apollo/wrapperResolvers'; import { IContext } from '~/connectionResolvers'; export const authQueries = { @@ -16,3 +17,7 @@ export const authQueries = { return result; }, }; + +markResolvers(authQueries, { + skipPermission: true, +}); diff --git a/backend/core-api/src/modules/organization/team-member/db/models/Users.ts b/backend/core-api/src/modules/organization/team-member/db/models/Users.ts index bb2277761b..ee2e2f19e6 100644 --- a/backend/core-api/src/modules/organization/team-member/db/models/Users.ts +++ b/backend/core-api/src/modules/organization/team-member/db/models/Users.ts @@ -25,6 +25,7 @@ import { import { USER_MOVEMENT_STATUSES } from 'erxes-api-shared/core-modules'; import { title } from 'process'; +import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; const SALT_WORK_FACTOR = 10; @@ -234,7 +235,7 @@ export const loadUserClass = (models: IModels, subdomain: string) => { this.checkPassword(password); } - return models.Users.create({ + const user = await models.Users.create({ isOwner, username, email, @@ -246,6 +247,13 @@ export const loadUserClass = (models: IModels, subdomain: string) => { password: notUsePassword ? '' : await this.generatePassword(password), code: await this.generateUserCode(), }); + + models.Roles.create({ + userId: user._id, + role: PERMISSION_ROLES.MEMBER, + }); + + return user; } /** @@ -329,7 +337,7 @@ export const loadUserClass = (models: IModels, subdomain: string) => { this.checkPassword(password); - await models.Users.create({ + const user = await models.Users.create({ email, groupIds: [groupId], isActive: true, @@ -341,6 +349,11 @@ export const loadUserClass = (models: IModels, subdomain: string) => { brandIds, }); + models.Roles.create({ + userId: user._id, + role: PERMISSION_ROLES.MEMBER, + }); + return token; } @@ -795,7 +808,7 @@ export const loadUserClass = (models: IModels, subdomain: string) => { } } - if (user.isOwner && !user.lastSeenAt) { + if (!user.lastSeenAt) { const pluginNames = await getPlugins(); for (const pluginName of pluginNames) { diff --git a/backend/core-api/src/modules/organization/team-member/graphql/customResolver/customResolvers.ts b/backend/core-api/src/modules/organization/team-member/graphql/customResolver/customResolvers.ts index e16b894caf..a2022ccf8c 100644 --- a/backend/core-api/src/modules/organization/team-member/graphql/customResolver/customResolvers.ts +++ b/backend/core-api/src/modules/organization/team-member/graphql/customResolver/customResolvers.ts @@ -1,6 +1,6 @@ +import { getUserActionsMap, USER_ROLES } from 'erxes-api-shared/core-modules'; import { IUserDocument } from 'erxes-api-shared/core-types'; import { IContext } from '~/connectionResolvers'; -import { getUserActionsMap, USER_ROLES } from 'erxes-api-shared/core-modules'; export default { __resolveReference: async ({ _id }, { models }: IContext) => { @@ -21,6 +21,12 @@ export default { return 'Verified'; }, + async role(user: IUserDocument, _args: undefined, { models }: IContext) { + const { role } = await models.Roles.getRole(user._id); + + return role; + }, + // async currentOrganization(_user, _args, { subdomain, models }: IContext) { // const organization = await getOrganizationDetail({ subdomain, models }); diff --git a/backend/core-api/src/modules/organization/team-member/graphql/mutations.ts b/backend/core-api/src/modules/organization/team-member/graphql/mutations.ts index b1e284ae95..79bf4f7b8c 100644 --- a/backend/core-api/src/modules/organization/team-member/graphql/mutations.ts +++ b/backend/core-api/src/modules/organization/team-member/graphql/mutations.ts @@ -1,17 +1,19 @@ -import { IContext } from '~/connectionResolvers'; import { - IUser, IDetail, - ILink, IEmailSignature, + ILink, + IUser, + Resolver, } from 'erxes-api-shared/core-types'; +import { IContext } from '~/connectionResolvers'; +import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; export interface IUsersEdit extends IUser { channelIds?: string[]; _id: string; } -export const userMutations = { +export const userMutations: Record = { async usersCreateOwner( _parent: undefined, { @@ -48,7 +50,12 @@ export const userMutations = { }, }; - await models.Users.createUser(doc); + const user = await models.Users.createUser(doc); + + models.Roles.create({ + userId: user._id, + role: PERMISSION_ROLES.OWNER, + }); if (subscribeEmail && process.env.NODE_ENV === 'production') { await fetch('https://erxes.io/subscribe', { @@ -138,14 +145,14 @@ export const userMutations = { details, links, employeeId, - positionIds + positionIds, }: { username: string; email: string; details: IDetail; links: ILink; employeeId: string; - positionIds: string[] + positionIds: string[]; }, { user, models }: IContext, ) { @@ -158,7 +165,7 @@ export const userMutations = { }, links, employeeId, - positionIds + positionIds, }; const updatedUser = await models.Users.editProfile(user._id, doc); @@ -351,3 +358,5 @@ export const userMutations = { return; }, }; + +userMutations.usersCreateOwner.skipPermission = true; diff --git a/backend/core-api/src/modules/organization/team-member/graphql/schema.ts b/backend/core-api/src/modules/organization/team-member/graphql/schema.ts index 029f3a0b4f..ae272bd165 100644 --- a/backend/core-api/src/modules/organization/team-member/graphql/schema.ts +++ b/backend/core-api/src/modules/organization/team-member/graphql/schema.ts @@ -96,6 +96,7 @@ export const types = ` customFieldsData: JSON isOwner: Boolean + role: String permissionActions: JSON configs: JSON configsConstants: [JSON] diff --git a/backend/core-api/src/modules/permissions/db/constants.ts b/backend/core-api/src/modules/permissions/db/constants.ts new file mode 100644 index 0000000000..f9386df788 --- /dev/null +++ b/backend/core-api/src/modules/permissions/db/constants.ts @@ -0,0 +1,6 @@ +export const PERMISSION_ROLES = { + OWNER: 'owner', + ADMIN: 'admin', + MEMBER: 'member', + ALL: ['owner', 'admin', 'member'], +}; diff --git a/backend/core-api/src/modules/permissions/db/definitions/roles.ts b/backend/core-api/src/modules/permissions/db/definitions/roles.ts new file mode 100644 index 0000000000..167d8d0f96 --- /dev/null +++ b/backend/core-api/src/modules/permissions/db/definitions/roles.ts @@ -0,0 +1,20 @@ +import { Schema } from 'mongoose'; +import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; + +export const roleSchema = new Schema( + { + userId: { type: String, label: 'User', index: true, required: true }, + role: { + type: String, + enum: PERMISSION_ROLES.ALL, + label: 'Role', + index: true, + required: true, + }, + }, + { + timestamps: true, + }, +); + +roleSchema.index({ userId: 1, role: 1 }, { unique: true }); diff --git a/backend/core-api/src/modules/permissions/db/models/Roles.ts b/backend/core-api/src/modules/permissions/db/models/Roles.ts new file mode 100644 index 0000000000..b5a84f0261 --- /dev/null +++ b/backend/core-api/src/modules/permissions/db/models/Roles.ts @@ -0,0 +1,116 @@ +import { + IRole, + IRoleDocument, + IUserDocument, +} from 'erxes-api-shared/core-types'; +import { Model } from 'mongoose'; +import { IModels } from '~/connectionResolvers'; +import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; +import { roleSchema } from '../definitions/roles'; + +export interface IRoleModel extends Model { + getRole(userId: string): Promise; + createRole(doc: IRole, user: IUserDocument): Promise; + updateRole(doc: IRole, user: IUserDocument): Promise; +} + +export const loadRoleClass = (models: IModels) => { + class Role { + public static async validateRole(doc: IRole, user: IUserDocument) { + const { userId, role } = doc || {}; + + if (!PERMISSION_ROLES.ALL.includes(role)) { + throw new Error('Invalid role'); + } + + const userRole = await models.Roles.findOne({ + userId: user._id, + }).lean(); + + if (!userRole) { + throw new Error('Role not found for user.'); + } + + if (userRole.role === PERMISSION_ROLES.OWNER) { + if (role === PERMISSION_ROLES.OWNER) { + throw new Error('Access denied'); + } + + return; + } + + if (userRole.role === PERMISSION_ROLES.MEMBER) { + throw new Error('Access denied'); + } + + if (userRole.role === PERMISSION_ROLES.ADMIN) { + const isOwner = await models.Roles.findOne({ + userId, + role: PERMISSION_ROLES.OWNER, + }).lean(); + + if (isOwner) { + throw new Error('Access denied'); + } + + const isMember = await models.Roles.findOne({ + userId, + role: PERMISSION_ROLES.MEMBER, + }).lean(); + + if (isMember && role === PERMISSION_ROLES.OWNER) { + throw new Error('Access denied'); + } + } + } + + public static async getRole(userId: string) { + const role = await models.Roles.findOne({ userId }).lean(); + + if (!role) { + const user = await models.Users.getUser(userId); + + const userRole = { + userId, + role: PERMISSION_ROLES.MEMBER, + }; + + if (user.isOwner) { + userRole.role = PERMISSION_ROLES.OWNER; + } + + return await models.Roles.create(userRole); + } + + return role; + } + + public static async createRole(doc: IRole, user: IUserDocument) { + await this.validateRole(doc, user); + + const { userId } = doc; + + const role = await models.Roles.findOne({ userId }).lean(); + + if (role) { + throw new Error('Role already exists'); + } + + return await models.Roles.create(doc); + } + + public static async updateRole(doc: IRole, user: IUserDocument) { + await this.validateRole(doc, user); + + const { userId } = doc; + + return await models.Roles.findOneAndUpdate({ userId }, doc, { + new: true, + }); + } + } + + roleSchema.loadClass(Role); + + return roleSchema; +}; diff --git a/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/index.ts b/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/index.ts new file mode 100644 index 0000000000..68d7925de4 --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/index.ts @@ -0,0 +1,5 @@ +import Role from './role'; + +export default { + Role, +}; diff --git a/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts b/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts new file mode 100644 index 0000000000..13142032ff --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts @@ -0,0 +1,21 @@ +import { IRoleDocument } from 'erxes-api-shared/core-types'; +import { IContext } from '~/connectionResolvers'; + +export default { + __resolveReference: async ( + { userId }: { userId: string }, + { models }: IContext, + ) => { + const { role } = await models.Roles.getRole(userId); + + return role; + }, + + user: async (role: IRoleDocument) => { + if (!role.userId) { + return; + } + + return { __typename: 'User', _id: role.userId }; + }, +}; diff --git a/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts b/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts new file mode 100644 index 0000000000..2be622817f --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts @@ -0,0 +1,19 @@ +import { requireLogin } from 'erxes-api-shared/core-modules'; +import { IRole } from 'erxes-api-shared/core-types'; +import { IContext } from '~/connectionResolvers'; + +export const roleMutations = { + async rolesUpsert(_root: undefined, doc: IRole, { models, user }: IContext) { + const { userId } = doc || {}; + + const role = await models.Roles.findOne({ userId }).lean(); + + if (role) { + return await models.Roles.updateRole(doc, user); + } + + return await models.Roles.createRole(doc, user); + }, +}; + +requireLogin(roleMutations, 'rolesUpsert'); diff --git a/backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts b/backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts new file mode 100644 index 0000000000..a439b5e166 --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts @@ -0,0 +1,34 @@ +import { requireLogin } from 'erxes-api-shared/core-modules'; +import { IRoleDocument, IRoleParams } from 'erxes-api-shared/core-types'; +import { cursorPaginate } from 'erxes-api-shared/utils'; +import { IContext } from '~/connectionResolvers'; + +const generateSelector = async ({ role, userId }: IRoleParams) => { + const filter: any = {}; + + if (userId) { + filter.userId = userId; + } + + if (role) { + filter.role = role; + } + + return filter; +}; + +export const roleQueries = { + async roles(_root: undefined, args: IRoleParams, { models }: IContext) { + const filter = await generateSelector(args); + + const { list, pageInfo, totalCount } = await cursorPaginate({ + model: models.Roles, + params: args as any, + query: filter, + }); + + return { list, pageInfo, totalCount }; + }, +}; + +requireLogin(roleQueries, 'roles'); diff --git a/backend/core-api/src/modules/permissions/graphql/schemas/role.ts b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts new file mode 100644 index 0000000000..d48163ab18 --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts @@ -0,0 +1,42 @@ +import { GQL_CURSOR_PARAM_DEFS } from 'erxes-api-shared/utils'; + +export const types = ` + enum ROLE { + owner + admin + member + } + + type Role { + user: User + role: ROLE + + cursor: String + } + + type RoleListResponse { + list: [Role] + pageInfo: PageInfo + totalCount: Int + } +`; + +const queryParams = ` + userId: String, + role: ROLE, + + ${GQL_CURSOR_PARAM_DEFS} +`; + +export const queries = ` + roles(${queryParams}): RoleListResponse +`; + +const mutationParams = ` + userId: String!, + role: ROLE! +`; + +export const mutations = ` + rolesUpsert(${mutationParams}): Role +`; diff --git a/backend/core-api/src/modules/permissions/trpc/index.ts b/backend/core-api/src/modules/permissions/trpc/index.ts index 90b50b1859..b55996bbd1 100644 --- a/backend/core-api/src/modules/permissions/trpc/index.ts +++ b/backend/core-api/src/modules/permissions/trpc/index.ts @@ -1,11 +1,13 @@ import { initTRPC } from '@trpc/server'; import { CoreTRPCContext } from '~/init-trpc'; import { permissionTrpcRouter as permissionRouter } from './permission'; +import { roleTrpcRouter } from './role'; import { userGroupTrpcRouter } from './userGroup'; const t = initTRPC.context().create(); export const permissionTrpcRouter = t.mergeRouters( + roleTrpcRouter, permissionRouter, userGroupTrpcRouter, ); diff --git a/backend/core-api/src/modules/permissions/trpc/role.ts b/backend/core-api/src/modules/permissions/trpc/role.ts new file mode 100644 index 0000000000..80d64429a3 --- /dev/null +++ b/backend/core-api/src/modules/permissions/trpc/role.ts @@ -0,0 +1,16 @@ +import { initTRPC } from '@trpc/server'; +import { z } from 'zod'; +import { CoreTRPCContext } from '~/init-trpc'; + +const t = initTRPC.context().create(); + +export const roleTrpcRouter = t.router({ + roles: t.router({ + findOne: t.procedure.input(z.any()).query(async ({ ctx, input }) => { + const { models } = ctx; + const { userId } = input; + + return await models.Roles.getRole(userId); + }), + }), +}); diff --git a/backend/erxes-api-shared/src/core-modules/permissions/utils.ts b/backend/erxes-api-shared/src/core-modules/permissions/utils.ts index bb9cc9d95f..5b2efd4275 100644 --- a/backend/erxes-api-shared/src/core-modules/permissions/utils.ts +++ b/backend/erxes-api-shared/src/core-modules/permissions/utils.ts @@ -1,5 +1,5 @@ -import { IPermissionContext, IUserDocument } from '../../core-types'; -import { getEnv, redis } from '../../utils'; +import { IPermissionContext, IUserDocument, Resolver } from '../../core-types'; +import { getEnv, redis, sendTRPCMessage } from '../../utils'; import { getUserActionsMap } from './user-actions-map'; export const getKey = (user: IUserDocument) => `user_permissions_${user._id}`; @@ -178,3 +178,56 @@ export const moduleCheckPermission = async ( } } }; + +export const checkRolePermission = async ( + subdomain: string, + userId: string, + resolverKey: string, +) => { + const { role } = await sendTRPCMessage({ + pluginName: 'core', + method: 'query', + module: 'roles', + action: 'findOne', + input: { + userId, + resolverKey, + }, + defaultValue: { role: null }, + }); + + if (!role) { + return false; + } + + if ( + role === 'member' && + ['remove', 'delete'].some((resolver) => + resolverKey.toLowerCase().includes(resolver), + ) + ) { + return false; + } + + return true; +}; + +export const wrapPermission = (resolver: Resolver, resolverKey: string) => { + return async (parent: any, args: any, context: any, info: any) => { + const { user, subdomain } = context; + + checkLogin(user); + + const permission = await checkRolePermission( + subdomain, + user._id, + resolverKey, + ); + + if (!permission) { + throw new Error('Permission denied'); + } + + return resolver(parent, args, context, info); + }; +}; diff --git a/backend/erxes-api-shared/src/core-types/common.ts b/backend/erxes-api-shared/src/core-types/common.ts index 7768bb3e9f..baf6a8b814 100644 --- a/backend/erxes-api-shared/src/core-types/common.ts +++ b/backend/erxes-api-shared/src/core-types/common.ts @@ -1,3 +1,4 @@ +import { GraphQLResolveInfo } from 'graphql'; import { SortOrder } from 'mongoose'; import { IUserDocument } from './modules/team-member/user'; @@ -99,3 +100,20 @@ export interface IPageInfo { startCursor: string | null; endCursor: string | null; } + +export interface IResolverSymbol { + skipPermission?: boolean; +} + +export type Resolver< + Parent = any, + Args = any, + Context = { subdomain: string } & IMainContext, + Result = any, +> = (( + parent: Parent, + args: Args, + context: Context, + info: GraphQLResolveInfo, +) => Promise | Result) & + IResolverSymbol; diff --git a/backend/erxes-api-shared/src/core-types/index.ts b/backend/erxes-api-shared/src/core-types/index.ts index 34963494e9..a603a47e9e 100644 --- a/backend/erxes-api-shared/src/core-types/index.ts +++ b/backend/erxes-api-shared/src/core-types/index.ts @@ -3,13 +3,14 @@ export * from './modules/app/app'; export * from './modules/contacts/company'; export * from './modules/contacts/contacts-common'; export * from './modules/contacts/customer'; +export * from './modules/logs/logs'; export * from './modules/permissions/permission'; +export * from './modules/permissions/role'; export * from './modules/products/product'; export * from './modules/products/productCategory'; export * from './modules/products/productConfig'; export * from './modules/products/uom'; +export * from './modules/relations/relations'; export * from './modules/tags/tag'; export * from './modules/team-member/structure'; export * from './modules/team-member/user'; -export * from './modules/relations/relations'; -export * from './modules/logs/logs'; diff --git a/backend/erxes-api-shared/src/core-types/modules/permissions/role.ts b/backend/erxes-api-shared/src/core-types/modules/permissions/role.ts new file mode 100644 index 0000000000..d82de891b3 --- /dev/null +++ b/backend/erxes-api-shared/src/core-types/modules/permissions/role.ts @@ -0,0 +1,24 @@ +import { ICursorPaginateParams } from '@/core-types/common'; +import { Document } from 'mongoose'; + +export enum Roles { + OWNER = 'owner', + ADMIN = 'admin', + MEMBER = 'member', +} +export interface IRole { + userId: string; + role: Roles; +} + +export interface IRoleDocument extends IRole, Document { + _id: string; + + createdAt: Date; + updatedAt: Date; +} + +export interface IRoleParams extends ICursorPaginateParams { + userId: string; + role: Roles; +} diff --git a/backend/erxes-api-shared/src/utils/apollo/index.ts b/backend/erxes-api-shared/src/utils/apollo/index.ts index 31d5c8f79d..af40508ee6 100644 --- a/backend/erxes-api-shared/src/utils/apollo/index.ts +++ b/backend/erxes-api-shared/src/utils/apollo/index.ts @@ -1,5 +1,6 @@ export * from './commonTypeDefs'; +export * from './constants'; export * from './customScalars'; -export * from './wrapperMutations'; export * from './utils'; -export * from './constants'; +export * from './wrapperMutations'; +export * from './wrapperResolvers'; diff --git a/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts b/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts new file mode 100644 index 0000000000..ba7d281a70 --- /dev/null +++ b/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts @@ -0,0 +1,81 @@ +import { wrapPermission } from '../../core-modules/permissions/utils'; +import { IResolverSymbol, Resolver } from '../../core-types/common'; +import { logHandler } from '../logs'; + +const withLogging = (resolver: Resolver): Resolver => { + return async (root, args, context, info) => { + const { user, req, processId, subdomain } = context; + const requestData = req.headers; + + return await logHandler( + async () => await resolver(root, args, context, info), + { + subdomain, + source: 'graphql', + action: 'mutation', + payload: { + mutationName: info.fieldName, + requestData, + args, + }, + processId, + userId: user?._id, + }, + ); + }; +}; + +export const wrapApolloResolvers = (resolvers: Record) => { + const wrappedResolvers: any = {}; + + for (const [key, resolver] of Object.entries(resolvers)) { + if (key === 'Mutation') { + const mutationResolvers: any = {}; + + for (const [mutationKey, mutationResolver] of Object.entries(resolver)) { + const isPublic = mutationResolver.skipPermission === true; + + if (isPublic) { + mutationResolvers[mutationKey] = mutationResolver; + } else { + mutationResolvers[mutationKey] = withLogging( + wrapPermission(mutationResolver, mutationKey), + ); + } + } + + wrappedResolvers[key] = mutationResolvers; + continue; + } + + if (key === 'Query') { + const queryResolvers: any = {}; + + for (const [queryKey, queryResolver] of Object.entries(resolver)) { + const isPublic = queryResolver.skipPermission === true; + + if (isPublic) { + queryResolvers[queryKey] = queryResolver; + } else { + queryResolvers[queryKey] = wrapPermission(queryResolver, queryKey); + } + } + + wrappedResolvers[key] = queryResolvers; + continue; + } + + wrappedResolvers[key] = resolver; + } + + return wrappedResolvers; +}; + +export const markResolvers = ( + resolvers: Record, + symbols: IResolverSymbol, +) => { + for (const key in resolvers) { + resolvers[key] = Object.assign(resolvers[key], symbols); + } +}; diff --git a/backend/plugins/operation_api/src/modules/team/graphql/resolvers/mutations/team.ts b/backend/plugins/operation_api/src/modules/team/graphql/resolvers/mutations/team.ts index a046c93bc0..a84fb8fb08 100644 --- a/backend/plugins/operation_api/src/modules/team/graphql/resolvers/mutations/team.ts +++ b/backend/plugins/operation_api/src/modules/team/graphql/resolvers/mutations/team.ts @@ -1,7 +1,8 @@ import { TeamMemberRoles } from '@/team/@types/team'; import { checkUserRole } from '@/utils'; -import { requireLogin, sendNotification } from 'erxes-api-shared/core-modules'; +import { requireLogin } from 'erxes-api-shared/core-modules'; import { IContext } from '~/connectionResolvers'; +import { createNotifications } from '~/utils/notifications'; export const teamMutations = { teamAdd: async ( @@ -94,40 +95,15 @@ export const teamMutations = { allowedRoles: [TeamMemberRoles.ADMIN, TeamMemberRoles.LEAD], }); - for (const memberId of memberIds) { - const teamMember = await models.TeamMember.findOne({ - memberId, - teamId: _id, - }); - - if (!teamMember) { - sendNotification(subdomain, { - title: 'Team Invitation', - message: `You have been invited to join a new team!`, - type: 'info', - userIds: [memberId], - priority: 'low', - kind: 'system', - contentType: 'operation:team.invite', - }); - } else { - const team = await models.Team.findOne({ _id }); - - sendNotification(subdomain, { - title: 'Team Invitation', - message: `You have been invited to join the ${ - team?.name || 'a' - } team.`, - userIds: [memberId], - fromUserId: user._id, - contentType: `operation:team`, - contentTypeId: _id, - type: 'info', - priority: 'low', - kind: 'user', - }); - } - } + await createNotifications({ + contentType: 'team', + contentTypeId: _id, + fromUserId: user._id, + subdomain, + notificationType: 'team', + userIds: memberIds, + action: 'teamAddMembers', + }); return models.TeamMember.createTeamMembers( memberIds.map((memberId) => ({ diff --git a/backend/plugins/operation_api/src/utils/notifications.ts b/backend/plugins/operation_api/src/utils/notifications.ts index b8e71727d4..589cf1cfb1 100644 --- a/backend/plugins/operation_api/src/utils/notifications.ts +++ b/backend/plugins/operation_api/src/utils/notifications.ts @@ -8,6 +8,10 @@ const getTitle = (contentType: string) => { if (contentType === 'project') { return 'Project'; } + + if (contentType === 'team') { + return 'Team'; + } }; const getMessage = (contentType: string, notificationType: string) => { @@ -22,6 +26,8 @@ const getMessage = (contentType: string, notificationType: string) => { return 'You have been assigned to project'; case 'note': return `You have been mentioned in note ${contentType}`; + case 'team': + return 'You have been invited to team'; default: return 'Notification'; } diff --git a/frontend/core-ui/src/modules/app/hooks/useCreateAppRouter.tsx b/frontend/core-ui/src/modules/app/hooks/useCreateAppRouter.tsx index 75637aa264..7d1bf904a4 100644 --- a/frontend/core-ui/src/modules/app/hooks/useCreateAppRouter.tsx +++ b/frontend/core-ui/src/modules/app/hooks/useCreateAppRouter.tsx @@ -20,8 +20,8 @@ import { SettingsRoutes } from '@/app/components/SettingsRoutes'; import { getPluginsRoutes } from '@/app/hooks/usePluginsRouter'; import { UserProvider } from '@/auth/providers/UserProvider'; import { OrganizationProvider } from '@/organization/providers/OrganizationProvider'; -import { useVersion } from 'ui-modules'; import { lazy } from 'react'; +import { useVersion } from 'ui-modules'; import { NotFoundPage } from '~/pages/not-found/NotFoundPage'; import { Providers } from '~/providers'; import { DocumentsRoutes } from '../components/DocumentsRoutes'; @@ -80,7 +80,10 @@ export const useCreateAppRouter = () => { element={} /> )} - } /> + + {isOS && ( + } /> + )} {isOS && ( }) => { const [, setOpen] = useQueryState('reset_password_id'); @@ -376,5 +377,16 @@ export const teamMemberColumns: ColumnDef[] = [ ); }, }, + { + id: 'role', + accessorKey: 'role', + header: () => , + cell: ({ cell }) => { + const { _id } = cell.row.original || {}; + return ( + + ); + }, + }, teamMemberPasswordResetColumn, ]; diff --git a/frontend/core-ui/src/modules/settings/team-member/components/record/team-member-edit/TeamMemberRoleSelect.tsx b/frontend/core-ui/src/modules/settings/team-member/components/record/team-member-edit/TeamMemberRoleSelect.tsx new file mode 100644 index 0000000000..02986c8787 --- /dev/null +++ b/frontend/core-ui/src/modules/settings/team-member/components/record/team-member-edit/TeamMemberRoleSelect.tsx @@ -0,0 +1,58 @@ +import { PopoverScoped, RecordTableInlineCell, Command } from 'erxes-ui'; +import { Roles } from '@/settings/team-member/constants/roles'; +import { useRoleUpsert } from '@/settings/team-member/hooks/useRoleUpsert'; +import { IconCheck } from '@tabler/icons-react'; +import { useState } from 'react'; + +export const TeamMemberRoleSelect = ({ + value, + userId, +}: { + value: string; + userId: string; +}) => { + const { roleUpsert } = useRoleUpsert(); + const [open, setOpen] = useState(false); + const handleRoleChange = (role: string) => { + roleUpsert({ + variables: { + userId, + role, + }, + onCompleted: () => setOpen(false), + }); + }; + + return ( + + + {value} + + + + + + No results found. + {Object.values(Roles) + .filter((role) => role !== 'owner') + .map((role) => ( + handleRoleChange(role)} + className="font-medium capitalize" + > + + {role} + {value === role && ( + + )} + + + ))} + + + + + ); +}; diff --git a/frontend/core-ui/src/modules/settings/team-member/constants/roles.ts b/frontend/core-ui/src/modules/settings/team-member/constants/roles.ts new file mode 100644 index 0000000000..899c268b6d --- /dev/null +++ b/frontend/core-ui/src/modules/settings/team-member/constants/roles.ts @@ -0,0 +1,5 @@ +export enum Roles { + Owner = "owner", + Admin = "admin", + Member = "member", +} \ No newline at end of file diff --git a/frontend/core-ui/src/modules/settings/team-member/graphql/roleMutation.ts b/frontend/core-ui/src/modules/settings/team-member/graphql/roleMutation.ts new file mode 100644 index 0000000000..a5e982fdff --- /dev/null +++ b/frontend/core-ui/src/modules/settings/team-member/graphql/roleMutation.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const ROLES_UPSERT = gql` + mutation RolesUpsert($userId: String!, $role: ROLE!) { + rolesUpsert(userId: $userId, role: $role) { + role + } + } +`; diff --git a/frontend/core-ui/src/modules/settings/team-member/graphql/usersQueries.ts b/frontend/core-ui/src/modules/settings/team-member/graphql/usersQueries.ts index de10298cde..712c6f7972 100644 --- a/frontend/core-ui/src/modules/settings/team-member/graphql/usersQueries.ts +++ b/frontend/core-ui/src/modules/settings/team-member/graphql/usersQueries.ts @@ -72,6 +72,7 @@ const GET_USERS_QUERY = gql` brandIds score positionIds + role details { avatar shortName diff --git a/frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx b/frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx new file mode 100644 index 0000000000..7857703dc4 --- /dev/null +++ b/frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx @@ -0,0 +1,36 @@ +import { MutationHookOptions, useMutation } from '@apollo/client'; +import { ROLES_UPSERT } from '@/settings/team-member/graphql/roleMutation'; +import { toast } from 'erxes-ui'; +export const useRoleUpsert = () => { + const [_roleUpsert, { loading }] = useMutation(ROLES_UPSERT); + + const roleUpsert = ({ variables, ...options }: MutationHookOptions) => { + _roleUpsert({ + ...options, + variables, + onCompleted: (data) => { + toast({ title: 'Role has been updated', variant: 'default' }); + options?.onCompleted?.(data); + }, + onError: (error) => { + toast({ + title: 'Failed to update role', + description: error.message, + variant: 'destructive', + }); + options?.onError?.(error); + }, + update: (cache) => { + cache.modify({ + id: cache.identify({ _id: variables?.userId, __typename: 'User' }), + fields: { + role: () => variables?.role, + }, + optimistic: true, + }); + }, + }); + }; + + return { roleUpsert, loading }; +}; diff --git a/frontend/core-ui/src/modules/settings/team-member/hooks/useUsers.tsx b/frontend/core-ui/src/modules/settings/team-member/hooks/useUsers.tsx index fa02997d99..44400b2bae 100644 --- a/frontend/core-ui/src/modules/settings/team-member/hooks/useUsers.tsx +++ b/frontend/core-ui/src/modules/settings/team-member/hooks/useUsers.tsx @@ -7,6 +7,7 @@ import { useMultiQueryState, useRecordTableCursor, validateFetchMore, + isUndefinedOrNull, } from 'erxes-ui'; import { IUser, IDetailsType } from '../types'; import { TEAM_MEMBER_CURSOR_SESSION_KEY } from '../constants/teamMemberCursorSessionKey'; @@ -50,6 +51,7 @@ const useUsers = (options?: QueryHookOptions) => { onError(error) { console.error('An error occoured on fetch', error.message); }, + skip: isUndefinedOrNull(cursor), }, );