From 4dd7b859e1b897ef88f10d6966fec44a67a38651 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Thu, 25 Sep 2025 13:30:16 +0800 Subject: [PATCH 1/9] chore: role base implementation --- .../src/apollo/resolvers/mutations.ts | 2 + .../core-api/src/apollo/resolvers/queries.ts | 2 + .../src/apollo/resolvers/resolvers.ts | 4 +- backend/core-api/src/apollo/schema/schema.ts | 19 +++- backend/core-api/src/connectionResolvers.ts | 11 ++ .../team-member/db/models/Users.ts | 17 ++- .../team-member/graphql/mutations.ts | 20 ++-- .../src/modules/permissions/db/constants.ts | 6 ++ .../permissions/db/definitions/roles.ts | 16 +++ .../modules/permissions/db/models/Roles.ts | 101 ++++++++++++++++++ .../graphql/resolvers/customResolver/index.ts | 5 + .../graphql/resolvers/customResolver/role.ts | 19 ++++ .../graphql/resolvers/mutations/role.ts | 12 +++ .../graphql/resolvers/queries/role.ts | 31 ++++++ .../permissions/graphql/schemas/role.ts | 44 ++++++++ .../erxes-api-shared/src/core-types/index.ts | 5 +- .../core-types/modules/permissions/role.ts | 24 +++++ 17 files changed, 321 insertions(+), 17 deletions(-) create mode 100644 backend/core-api/src/modules/permissions/db/constants.ts create mode 100644 backend/core-api/src/modules/permissions/db/definitions/roles.ts create mode 100644 backend/core-api/src/modules/permissions/db/models/Roles.ts create mode 100644 backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/index.ts create mode 100644 backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts create mode 100644 backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts create mode 100644 backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts create mode 100644 backend/core-api/src/modules/permissions/graphql/schemas/role.ts create mode 100644 backend/erxes-api-shared/src/core-types/modules/permissions/role.ts 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 7f50c70d6b..dd121f3ada 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/organization/team-member/db/models/Users.ts b/backend/core-api/src/modules/organization/team-member/db/models/Users.ts index 8cc5a62ae7..b3cfa12039 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) => { 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) => { 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) => { 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) => { brandIds, }); + models.Roles.create({ + userId: user._id, + role: PERMISSION_ROLES.MEMBER, + }); + return token; } 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..39cfa9edbe 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,10 +1,11 @@ -import { IContext } from '~/connectionResolvers'; import { - IUser, IDetail, - ILink, IEmailSignature, + ILink, + IUser, } 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[]; @@ -48,7 +49,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 +144,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 +164,7 @@ export const userMutations = { }, links, employeeId, - positionIds + positionIds, }; const updatedUser = await models.Users.editProfile(user._id, doc); 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..b911db1a8a --- /dev/null +++ b/backend/core-api/src/modules/permissions/db/definitions/roles.ts @@ -0,0 +1,16 @@ +import { Schema } from 'mongoose'; +import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; + +export const roleSchema = new Schema( + { + userId: { type: String, label: 'User' }, + role: { + type: String, + enum: PERMISSION_ROLES.ALL, + label: 'Role', + }, + }, + { + timestamps: 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..efe8301692 --- /dev/null +++ b/backend/core-api/src/modules/permissions/db/models/Roles.ts @@ -0,0 +1,101 @@ +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(_id: 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) { + return; + } + + 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'); + } + } + + if (userRole.role === PERMISSION_ROLES.MEMBER) { + throw new Error('Access denied'); + } + } + + public static async getRole(_id: string) { + const role = await models.Roles.findOne({ _id }).lean(); + + if (!role) { + throw new Error('Role not found'); + } + + 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..1bfe4c6bf1 --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts @@ -0,0 +1,19 @@ +import { IPermissionDocument } from 'erxes-api-shared/core-types'; +import { IContext } from '~/connectionResolvers'; + +export default { + __resolveReference: async ( + { _id }: { _id: string }, + { models }: IContext, + ) => { + return await models.Permissions.getPermission(_id); + }, + + user: async (permission: IPermissionDocument) => { + if (!permission.userId) { + return; + } + + return { __typename: 'User', _id: permission.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..82c2f26d5d --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts @@ -0,0 +1,12 @@ +import { IRole } from 'erxes-api-shared/core-types'; +import { IContext } from '~/connectionResolvers'; + +export const roleMutations = { + async rolesAdd(_root: undefined, doc: IRole, { models, user }: IContext) { + return await models.Roles.createRole(doc, user); + }, + + async rolesEdit(_root: undefined, doc: IRole, { models, user }: IContext) { + return await models.Roles.updateRole(doc, user); + }, +}; 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..e13edfc3ac --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts @@ -0,0 +1,31 @@ +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 }; + }, +}; 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..bab2f9f464 --- /dev/null +++ b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts @@ -0,0 +1,44 @@ +import { GQL_CURSOR_PARAM_DEFS } from 'erxes-api-shared/utils'; + +export const types = ` + enum ROLE { + owner + admin + member + } + + type Role { + _id: String! + 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 = ` + rolesAdd(${mutationParams}): [Role] + rolesEdit(${mutationParams}): [Role] +`; 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; +} From 4a216b74493e625048cf15e57da0b7af20e1ba4b Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Tue, 30 Sep 2025 11:44:15 +0800 Subject: [PATCH 2/9] chore: auto-create default role if none exists --- .../graphql/customResolver/customResolvers.ts | 6 +++++- .../permissions/db/definitions/roles.ts | 2 ++ .../modules/permissions/db/models/Roles.ts | 19 +++++++++++-------- .../graphql/resolvers/customResolver/role.ts | 14 ++++++++------ .../graphql/resolvers/mutations/role.ts | 17 ++++++++++++----- .../graphql/resolvers/queries/role.ts | 3 +++ .../permissions/graphql/schemas/role.ts | 6 ++---- 7 files changed, 43 insertions(+), 24 deletions(-) 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..dd242940d2 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,10 @@ export default { return 'Verified'; }, + async role(user: IUserDocument) { + return { __typename: 'Role', userId: user._id }; + }, + // async currentOrganization(_user, _args, { subdomain, models }: IContext) { // const organization = await getOrganizationDetail({ subdomain, models }); diff --git a/backend/core-api/src/modules/permissions/db/definitions/roles.ts b/backend/core-api/src/modules/permissions/db/definitions/roles.ts index b911db1a8a..abb040a42f 100644 --- a/backend/core-api/src/modules/permissions/db/definitions/roles.ts +++ b/backend/core-api/src/modules/permissions/db/definitions/roles.ts @@ -14,3 +14,5 @@ export const roleSchema = new Schema( 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 index efe8301692..08c5275ad6 100644 --- a/backend/core-api/src/modules/permissions/db/models/Roles.ts +++ b/backend/core-api/src/modules/permissions/db/models/Roles.ts @@ -9,7 +9,7 @@ import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; import { roleSchema } from '../definitions/roles'; export interface IRoleModel extends Model { - getRole(_id: string): Promise; + getRole(userId: string): Promise; createRole(doc: IRole, user: IUserDocument): Promise; updateRole(doc: IRole, user: IUserDocument): Promise; } @@ -35,6 +35,10 @@ export const loadRoleClass = (models: IModels) => { 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, @@ -54,17 +58,16 @@ export const loadRoleClass = (models: IModels) => { throw new Error('Access denied'); } } - - if (userRole.role === PERMISSION_ROLES.MEMBER) { - throw new Error('Access denied'); - } } - public static async getRole(_id: string) { - const role = await models.Roles.findOne({ _id }).lean(); + public static async getRole(userId: string) { + const role = await models.Roles.findOne({ userId }).lean(); if (!role) { - throw new Error('Role not found'); + return await models.Roles.create({ + userId, + role: PERMISSION_ROLES.MEMBER, + }); } return 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 index 1bfe4c6bf1..13142032ff 100644 --- a/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/customResolver/role.ts @@ -1,19 +1,21 @@ -import { IPermissionDocument } from 'erxes-api-shared/core-types'; +import { IRoleDocument } from 'erxes-api-shared/core-types'; import { IContext } from '~/connectionResolvers'; export default { __resolveReference: async ( - { _id }: { _id: string }, + { userId }: { userId: string }, { models }: IContext, ) => { - return await models.Permissions.getPermission(_id); + const { role } = await models.Roles.getRole(userId); + + return role; }, - user: async (permission: IPermissionDocument) => { - if (!permission.userId) { + user: async (role: IRoleDocument) => { + if (!role.userId) { return; } - return { __typename: 'User', _id: permission.userId }; + 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 index 82c2f26d5d..3fec8ccb9c 100644 --- a/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts @@ -1,12 +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 rolesAdd(_root: undefined, doc: IRole, { models, user }: IContext) { - return await models.Roles.createRole(doc, user); - }, + async rolesUpsert(_root: undefined, doc: IRole, { models, user }: IContext) { + const { userId } = doc || {}; + + const role = await models.Roles.getRole(userId); - async rolesEdit(_root: undefined, doc: IRole, { models, user }: IContext) { - return await models.Roles.updateRole(doc, user); + 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 index e13edfc3ac..a439b5e166 100644 --- a/backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/queries/role.ts @@ -1,3 +1,4 @@ +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'; @@ -29,3 +30,5 @@ export const roleQueries = { 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 index bab2f9f464..1d758c5f5a 100644 --- a/backend/core-api/src/modules/permissions/graphql/schemas/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts @@ -7,8 +7,7 @@ export const types = ` member } - type Role { - _id: String! + type Role @key(fields: "userId") @cacheControl(maxAge: 3) { user: User role: ROLE @@ -39,6 +38,5 @@ const mutationParams = ` `; export const mutations = ` - rolesAdd(${mutationParams}): [Role] - rolesEdit(${mutationParams}): [Role] + rolesUpsert(${mutationParams}): [Role] `; From 22ae5dcb7352df4b400f23651fa5bac39fa5fb5c Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Tue, 30 Sep 2025 20:54:51 +0800 Subject: [PATCH 3/9] chore: wrap resolvers --- backend/core-api/src/apollo/apolloServer.ts | 11 +-- .../auth/graphql/resolvers/mutations.ts | 9 ++- .../modules/auth/graphql/resolvers/queries.ts | 5 +- .../graphql/customResolver/customResolvers.ts | 6 +- .../team-member/graphql/schema.ts | 1 + .../permissions/db/definitions/roles.ts | 4 +- .../modules/permissions/db/models/Roles.ts | 12 ++- .../graphql/resolvers/mutations/role.ts | 2 +- .../permissions/graphql/schemas/role.ts | 2 +- .../src/modules/permissions/trpc/index.ts | 2 + .../src/modules/permissions/trpc/role.ts | 16 ++++ .../src/core-modules/permissions/utils.ts | 57 +++++++++++++- .../erxes-api-shared/src/core-types/common.ts | 19 +++++ .../src/utils/apollo/index.ts | 5 +- .../src/utils/apollo/wrapperResolvers.ts | 74 +++++++++++++++++++ .../auth/graphql/queries/currentUser.ts | 1 + .../team-member/graphql/usersQueries.ts | 1 + 17 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 backend/core-api/src/modules/permissions/trpc/role.ts create mode 100644 backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts 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/modules/auth/graphql/resolvers/mutations.ts b/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts index 712a29be49..c653b64fc5 100644 --- a/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts +++ b/backend/core-api/src/modules/auth/graphql/resolvers/mutations.ts @@ -1,3 +1,5 @@ +import { WorkOS } from '@workos-inc/node'; +import { Resolver } from 'erxes-api-shared/core-types'; import { authCookieOptions, getEnv, @@ -5,9 +7,8 @@ import { 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, @@ -21,7 +22,7 @@ type LoginParams = { deviceToken?: string; }; -export const authMutations = { +export const authMutations: Record = { /* * Login */ @@ -239,3 +240,5 @@ export const authMutations = { return 'success'; }, }; + +authMutations.login.metadata = { public: 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..85542af2fb 100644 --- a/backend/core-api/src/modules/auth/graphql/resolvers/queries.ts +++ b/backend/core-api/src/modules/auth/graphql/resolvers/queries.ts @@ -1,6 +1,7 @@ +import { Resolver } from 'erxes-api-shared/core-types'; import { IContext } from '~/connectionResolvers'; -export const authQueries = { +export const authQueries: Record = { /** * Current user */ @@ -16,3 +17,5 @@ export const authQueries = { return result; }, }; + +authQueries.currentUser.metadata = { public: true }; 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 dd242940d2..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 @@ -21,8 +21,10 @@ export default { return 'Verified'; }, - async role(user: IUserDocument) { - return { __typename: 'Role', userId: user._id }; + 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) { 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/definitions/roles.ts b/backend/core-api/src/modules/permissions/db/definitions/roles.ts index abb040a42f..167d8d0f96 100644 --- a/backend/core-api/src/modules/permissions/db/definitions/roles.ts +++ b/backend/core-api/src/modules/permissions/db/definitions/roles.ts @@ -3,11 +3,13 @@ import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; export const roleSchema = new Schema( { - userId: { type: String, label: 'User' }, + userId: { type: String, label: 'User', index: true, required: true }, role: { type: String, enum: PERMISSION_ROLES.ALL, label: 'Role', + index: true, + required: 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 index 08c5275ad6..dfae30e3ef 100644 --- a/backend/core-api/src/modules/permissions/db/models/Roles.ts +++ b/backend/core-api/src/modules/permissions/db/models/Roles.ts @@ -64,10 +64,18 @@ export const loadRoleClass = (models: IModels) => { const role = await models.Roles.findOne({ userId }).lean(); if (!role) { - return await models.Roles.create({ + 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; 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 index 3fec8ccb9c..2be622817f 100644 --- a/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/resolvers/mutations/role.ts @@ -6,7 +6,7 @@ export const roleMutations = { async rolesUpsert(_root: undefined, doc: IRole, { models, user }: IContext) { const { userId } = doc || {}; - const role = await models.Roles.getRole(userId); + const role = await models.Roles.findOne({ userId }).lean(); if (role) { return await models.Roles.updateRole(doc, user); diff --git a/backend/core-api/src/modules/permissions/graphql/schemas/role.ts b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts index 1d758c5f5a..4411912a22 100644 --- a/backend/core-api/src/modules/permissions/graphql/schemas/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts @@ -7,7 +7,7 @@ export const types = ` member } - type Role @key(fields: "userId") @cacheControl(maxAge: 3) { + type Role { user: User role: 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..8645204753 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,21 @@ export interface IPageInfo { startCursor: string | null; endCursor: string | null; } + +export type ResolverMetadata = { + public?: boolean; +}; + +export type Resolver< + Parent = any, + Args = any, + Context = { subdomain: string } & IMainContext, + Result = any, +> = (( + parent: Parent, + args: Args, + context: Context, + info: GraphQLResolveInfo, +) => Promise | Result) & { + metadata?: ResolverMetadata; +}; 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..58da875351 --- /dev/null +++ b/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts @@ -0,0 +1,74 @@ +import { wrapPermission } from '../../core-modules/permissions/utils'; +import { 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( + resolvers[key], + )) { + const isPublic = mutationResolver.metadata?.public === 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(resolvers[key])) { + const isPublic = queryResolver.metadata?.public === true; + + if (isPublic) { + queryResolvers[queryKey] = queryResolver; + } else { + queryResolvers[queryKey] = wrapPermission(queryResolver, queryKey); + } + } + + wrappedResolvers[key] = queryResolvers; + continue; + } + + wrappedResolvers[key] = resolver; + } + + return wrappedResolvers; +}; diff --git a/frontend/core-ui/src/modules/auth/graphql/queries/currentUser.ts b/frontend/core-ui/src/modules/auth/graphql/queries/currentUser.ts index 37d370ce3c..022de1b6e7 100644 --- a/frontend/core-ui/src/modules/auth/graphql/queries/currentUser.ts +++ b/frontend/core-ui/src/modules/auth/graphql/queries/currentUser.ts @@ -8,6 +8,7 @@ export const currentUser = gql` username email isOwner + role details { avatar fullName 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 From ac4e7bcc84217453a2654ac5aa926aa0b02e3b64 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 1 Oct 2025 12:19:20 +0800 Subject: [PATCH 4/9] chore: mark resolvers as group --- .../auth/graphql/resolvers/mutations.ts | 8 ++++--- .../modules/auth/graphql/resolvers/queries.ts | 8 ++++--- .../team-member/graphql/mutations.ts | 5 ++++- .../erxes-api-shared/src/core-types/common.ts | 11 +++++----- .../src/utils/apollo/wrapperResolvers.ts | 21 ++++++++++++------- 5 files changed, 33 insertions(+), 20 deletions(-) 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 c653b64fc5..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,9 +1,9 @@ import { WorkOS } from '@workos-inc/node'; -import { Resolver } from 'erxes-api-shared/core-types'; import { authCookieOptions, getEnv, logHandler, + markResolvers, redis, updateSaasOrganization, } from 'erxes-api-shared/utils'; @@ -22,7 +22,7 @@ type LoginParams = { deviceToken?: string; }; -export const authMutations: Record = { +export const authMutations = { /* * Login */ @@ -241,4 +241,6 @@ export const authMutations: Record = { }, }; -authMutations.login.metadata = { public: true }; +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 85542af2fb..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,7 +1,7 @@ -import { Resolver } from 'erxes-api-shared/core-types'; +import { markResolvers } from 'erxes-api-shared/utils/apollo/wrapperResolvers'; import { IContext } from '~/connectionResolvers'; -export const authQueries: Record = { +export const authQueries = { /** * Current user */ @@ -18,4 +18,6 @@ export const authQueries: Record = { }, }; -authQueries.currentUser.metadata = { public: true }; +markResolvers(authQueries, { + skipPermission: true, +}); 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 39cfa9edbe..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 @@ -3,6 +3,7 @@ import { IEmailSignature, ILink, IUser, + Resolver, } from 'erxes-api-shared/core-types'; import { IContext } from '~/connectionResolvers'; import { PERMISSION_ROLES } from '~/modules/permissions/db/constants'; @@ -12,7 +13,7 @@ export interface IUsersEdit extends IUser { _id: string; } -export const userMutations = { +export const userMutations: Record = { async usersCreateOwner( _parent: undefined, { @@ -357,3 +358,5 @@ export const userMutations = { return; }, }; + +userMutations.usersCreateOwner.skipPermission = true; diff --git a/backend/erxes-api-shared/src/core-types/common.ts b/backend/erxes-api-shared/src/core-types/common.ts index 8645204753..baf6a8b814 100644 --- a/backend/erxes-api-shared/src/core-types/common.ts +++ b/backend/erxes-api-shared/src/core-types/common.ts @@ -101,9 +101,9 @@ export interface IPageInfo { endCursor: string | null; } -export type ResolverMetadata = { - public?: boolean; -}; +export interface IResolverSymbol { + skipPermission?: boolean; +} export type Resolver< Parent = any, @@ -115,6 +115,5 @@ export type Resolver< args: Args, context: Context, info: GraphQLResolveInfo, -) => Promise | Result) & { - metadata?: ResolverMetadata; -}; +) => Promise | Result) & + IResolverSymbol; diff --git a/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts b/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts index 58da875351..ba7d281a70 100644 --- a/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts +++ b/backend/erxes-api-shared/src/utils/apollo/wrapperResolvers.ts @@ -1,5 +1,5 @@ import { wrapPermission } from '../../core-modules/permissions/utils'; -import { Resolver } from '../../core-types/common'; +import { IResolverSymbol, Resolver } from '../../core-types/common'; import { logHandler } from '../logs'; const withLogging = (resolver: Resolver): Resolver => { @@ -32,10 +32,8 @@ export const wrapApolloResolvers = (resolvers: Record) => { if (key === 'Mutation') { const mutationResolvers: any = {}; - for (const [mutationKey, mutationResolver] of Object.entries( - resolvers[key], - )) { - const isPublic = mutationResolver.metadata?.public === true; + for (const [mutationKey, mutationResolver] of Object.entries(resolver)) { + const isPublic = mutationResolver.skipPermission === true; if (isPublic) { mutationResolvers[mutationKey] = mutationResolver; @@ -53,8 +51,8 @@ export const wrapApolloResolvers = (resolvers: Record) => { if (key === 'Query') { const queryResolvers: any = {}; - for (const [queryKey, queryResolver] of Object.entries(resolvers[key])) { - const isPublic = queryResolver.metadata?.public === true; + for (const [queryKey, queryResolver] of Object.entries(resolver)) { + const isPublic = queryResolver.skipPermission === true; if (isPublic) { queryResolvers[queryKey] = queryResolver; @@ -72,3 +70,12 @@ export const wrapApolloResolvers = (resolvers: Record) => { return wrappedResolvers; }; + +export const markResolvers = ( + resolvers: Record, + symbols: IResolverSymbol, +) => { + for (const key in resolvers) { + resolvers[key] = Object.assign(resolvers[key], symbols); + } +}; From 0f2aa6c445ce41015863b3423ea2fcd1ba6c10b1 Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 1 Oct 2025 19:38:31 +0800 Subject: [PATCH 5/9] chore: prevent multiple owner --- .../src/modules/organization/team-member/db/models/Users.ts | 2 +- backend/core-api/src/modules/permissions/db/models/Roles.ts | 4 ++++ .../core-api/src/modules/permissions/graphql/schemas/role.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) 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 e3d154ec73..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 @@ -808,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/permissions/db/models/Roles.ts b/backend/core-api/src/modules/permissions/db/models/Roles.ts index dfae30e3ef..b5a84f0261 100644 --- a/backend/core-api/src/modules/permissions/db/models/Roles.ts +++ b/backend/core-api/src/modules/permissions/db/models/Roles.ts @@ -32,6 +32,10 @@ export const loadRoleClass = (models: IModels) => { } if (userRole.role === PERMISSION_ROLES.OWNER) { + if (role === PERMISSION_ROLES.OWNER) { + throw new Error('Access denied'); + } + return; } diff --git a/backend/core-api/src/modules/permissions/graphql/schemas/role.ts b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts index 4411912a22..d48163ab18 100644 --- a/backend/core-api/src/modules/permissions/graphql/schemas/role.ts +++ b/backend/core-api/src/modules/permissions/graphql/schemas/role.ts @@ -38,5 +38,5 @@ const mutationParams = ` `; export const mutations = ` - rolesUpsert(${mutationParams}): [Role] + rolesUpsert(${mutationParams}): Role `; From 662615455f414a5bd44b3e80d3b0beb10dccc44a Mon Sep 17 00:00:00 2001 From: batmnkh2344 Date: Wed, 1 Oct 2025 19:49:52 +0800 Subject: [PATCH 6/9] chore: notification for team invite --- .../team/graphql/resolvers/mutations/team.ts | 45 +++++-------------- .../operation_api/src/utils/notifications.ts | 6 +++ .../modules/app/hooks/useCreateAppRouter.tsx | 7 ++- 3 files changed, 21 insertions(+), 37 deletions(-) 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 4566bcf5bf..e1dae934dc 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,7 @@ import { TeamMemberRoles } from '@/team/@types/team'; import { checkUserRole } from '@/utils'; -import { sendNotification } from 'erxes-api-shared/core-modules'; import { IContext } from '~/connectionResolvers'; +import { createNotifications } from '~/utils/notifications'; export const teamMutations = { teamAdd: async ( @@ -94,40 +94,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 && ( Date: Thu, 2 Oct 2025 19:30:15 +0800 Subject: [PATCH 7/9] added role select field on team members record field --- .../components/record/TeamMemberColumns.tsx | 14 +++++++- .../settings/team-member/constants/roles.ts | 5 +++ .../team-member/graphql/roleMutation.ts | 9 +++++ .../team-member/hooks/useRoleUpsert.tsx | 36 +++++++++++++++++++ .../settings/team-member/hooks/useUsers.tsx | 2 ++ 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 frontend/core-ui/src/modules/settings/team-member/constants/roles.ts create mode 100644 frontend/core-ui/src/modules/settings/team-member/graphql/roleMutation.ts create mode 100644 frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx diff --git a/frontend/core-ui/src/modules/settings/team-member/components/record/TeamMemberColumns.tsx b/frontend/core-ui/src/modules/settings/team-member/components/record/TeamMemberColumns.tsx index d6b7527570..a8de7b6fdc 100644 --- a/frontend/core-ui/src/modules/settings/team-member/components/record/TeamMemberColumns.tsx +++ b/frontend/core-ui/src/modules/settings/team-member/components/record/TeamMemberColumns.tsx @@ -7,6 +7,7 @@ import { IconMailCheck, IconRefresh, IconUser, + IconUserCheck, } from '@tabler/icons-react'; import type { ColumnDef, Cell } from '@tanstack/react-table'; @@ -33,7 +34,6 @@ import { renderingTeamMemberDetailAtom, renderingTeamMemberResetPasswordAtom, } from '../../states/teamMemberDetailStates'; -import { SelectPositions } from 'ui-modules'; import { useUserEdit, useUsersStatusEdit } from '../../hooks/useUserEdit'; import { ChangeEvent, useState } from 'react'; import { SettingsHotKeyScope } from '@/types/SettingsHotKeyScope'; @@ -42,6 +42,7 @@ import { ApolloError } from '@apollo/client'; import { TeamMemberEmailField } from '@/settings/team-member/components/record/team-member-edit/TeammemberEmailField'; import clsx from 'clsx'; import { useResendInvite } from '@/settings/team-member/hooks/useResendInvite'; +import { TeamMemberRoleSelect } from '@/settings/team-member/components/record/team-member-edit/TeamMemberRoleSelect'; const UserResetPassword = ({ cell }: { cell: Cell }) => { 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/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/hooks/useRoleUpsert.tsx b/frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx new file mode 100644 index 0000000000..e8c9023835 --- /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 '../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), }, ); From 323eec5e6f3a0b0720961694f36a5f220cd7bfe6 Mon Sep 17 00:00:00 2001 From: Kato-101 Date: Thu, 2 Oct 2025 19:44:03 +0800 Subject: [PATCH 8/9] follow up --- .../team-member-edit/TeamMemberRoleSelect.tsx | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 frontend/core-ui/src/modules/settings/team-member/components/record/team-member-edit/TeamMemberRoleSelect.tsx 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 && ( + + )} + + + ))} + + + + + ); +}; From d1d8585c5df384c6085dca553119d21c8055bd36 Mon Sep 17 00:00:00 2001 From: Kato-101 Date: Fri, 3 Oct 2025 12:10:26 +0800 Subject: [PATCH 9/9] relative to absolute import --- .../src/modules/settings/team-member/hooks/useRoleUpsert.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e8c9023835..7857703dc4 100644 --- a/frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx +++ b/frontend/core-ui/src/modules/settings/team-member/hooks/useRoleUpsert.tsx @@ -1,5 +1,5 @@ import { MutationHookOptions, useMutation } from '@apollo/client'; -import { ROLES_UPSERT } from '../graphql/roleMutation'; +import { ROLES_UPSERT } from '@/settings/team-member/graphql/roleMutation'; import { toast } from 'erxes-ui'; export const useRoleUpsert = () => { const [_roleUpsert, { loading }] = useMutation(ROLES_UPSERT);