diff --git a/packages/acl/src/acl-resource.ts b/packages/acl/src/acl-resource.ts index 4e51a3432..60fe97551 100644 --- a/packages/acl/src/acl-resource.ts +++ b/packages/acl/src/acl-resource.ts @@ -35,7 +35,7 @@ export class ACLResource { } getAction(name: string) { - return this.actions.get(this.acl.resolveActionAlias(name)); + return this.actions.get(name) || this.actions.get(this.acl.resolveActionAlias(name)); } setAction(name: string, params: RoleActionParams) { diff --git a/packages/acl/src/acl-role.ts b/packages/acl/src/acl-role.ts index 2edca2fe4..003cc4c83 100644 --- a/packages/acl/src/acl-role.ts +++ b/packages/acl/src/acl-role.ts @@ -52,7 +52,11 @@ export class ACLRole { } public revokeResource(resourceName) { - this.resources.delete(resourceName); + for (const key of [...this.resources.keys()]) { + if (key === resourceName || key.includes(`${resourceName}.`)) { + this.resources.delete(key); + } + } } public grantAction(path: string, options?: RoleActionParams) { @@ -101,6 +105,7 @@ export class ACLRole { const [resourceName, actionName] = path.split(':'); const resource = this.resources.get(resourceName); + let action = null; if (resource) { action = resource.getAction(actionName); diff --git a/packages/acl/src/acl.ts b/packages/acl/src/acl.ts index d36ed3fc7..0d4641745 100644 --- a/packages/acl/src/acl.ts +++ b/packages/acl/src/acl.ts @@ -144,10 +144,6 @@ export class ACL extends EventEmitter { } can({ role, resource, action }: CanArgs): CanResult | null { - if (!this.isAvailableAction(action)) { - return null; - } - const aclRole = this.roles.get(role); const aclResource = aclRole.getResource(resource); diff --git a/packages/database/src/relation-repository/hasmany-repository.ts b/packages/database/src/relation-repository/hasmany-repository.ts index e389721eb..dfb798668 100644 --- a/packages/database/src/relation-repository/hasmany-repository.ts +++ b/packages/database/src/relation-repository/hasmany-repository.ts @@ -70,6 +70,7 @@ export class HasManyRepository extends MultipleRelationRepository implements IHa where: { [Op.and]: where, }, + individualHooks: true, transaction, }); diff --git a/packages/database/src/relation-repository/multiple-relation-repository.ts b/packages/database/src/relation-repository/multiple-relation-repository.ts index 383b55892..8d913d97b 100644 --- a/packages/database/src/relation-repository/multiple-relation-repository.ts +++ b/packages/database/src/relation-repository/multiple-relation-repository.ts @@ -158,6 +158,13 @@ export abstract class MultipleRelationRepository extends RelationRepository { }); } + for (const instance of instances) { + if (options.hooks !== false) { + await this.db.emitAsync(`${this.targetCollection.name}.afterUpdateWithAssociations`, instance, options); + await this.db.emitAsync(`${this.targetCollection.name}.afterSaveWithAssociations`, instance, options); + } + } + return instances; } diff --git a/packages/database/src/relation-repository/relation-repository.ts b/packages/database/src/relation-repository/relation-repository.ts index 7e3f6fe65..632ce0fe7 100644 --- a/packages/database/src/relation-repository/relation-repository.ts +++ b/packages/database/src/relation-repository/relation-repository.ts @@ -8,6 +8,7 @@ import { updateAssociations } from '../update-associations'; import lodash from 'lodash'; import { transactionWrapperBuilder } from '../transaction-decorator'; import { RelationField } from '../fields/relation-field'; +import Database from '../database'; export const transaction = transactionWrapperBuilder(function () { return this.sourceCollection.model.sequelize.transaction(); @@ -22,8 +23,11 @@ export abstract class RelationRepository { associationField: RelationField; sourceKeyValue: string | number; sourceInstance: Model; + db: Database; constructor(sourceCollection: Collection, association: string, sourceKeyValue: string | number) { + this.db = sourceCollection.context.database; + this.sourceCollection = sourceCollection; this.sourceKeyValue = sourceKeyValue; this.associationName = association; @@ -55,6 +59,11 @@ export abstract class RelationRepository { await updateAssociations(instance, values, options); + if (options.hooks !== false) { + const eventName = `${this.targetCollection.name}.afterSaveWithAssociations`; + await this.db.emitAsync(eventName, instance, options); + } + return instance; } diff --git a/packages/database/src/repository.ts b/packages/database/src/repository.ts index 9684499cf..781d1114d 100644 --- a/packages/database/src/repository.ts +++ b/packages/database/src/repository.ts @@ -306,6 +306,7 @@ export class Repository { action: 'create', params: { filter: { published: true }, - fields: [], }, }); @@ -162,7 +161,7 @@ describe('acl', () => { resource: 'c1', action: 'view', params: { - fields: ['title', 'age'], + fields: ['age', 'title', 'id', 'createdAt', 'updatedAt'], }, }); diff --git a/packages/plugin-acl/src/__tests__/association-field.test.ts b/packages/plugin-acl/src/__tests__/association-field.test.ts new file mode 100644 index 000000000..32e4d8f9f --- /dev/null +++ b/packages/plugin-acl/src/__tests__/association-field.test.ts @@ -0,0 +1,300 @@ +import { MockServer } from '@nocobase/test'; +import { Database, HasManyRepository, Model } from '@nocobase/database'; +import { ACL } from '@nocobase/acl'; +import { prepareApp } from './prepare'; +import PluginACL from '@nocobase/plugin-acl'; + +describe('association field acl', () => { + let app: MockServer; + let db: Database; + let acl: ACL; + + let role: Model; + + afterEach(async () => { + await app.destroy(); + }); + + beforeEach(async () => { + app = await prepareApp(); + db = app.db; + const aclPlugin = app.getPlugin('PluginACL'); + + acl = aclPlugin.getACL(); + + role = await db.getRepository('roles').create({ + values: { + name: 'admin', + title: 'Admin User', + allowConfigure: true, + }, + }); + + await db.getRepository('collections').create({ + values: { + name: 'users', + }, + context: {}, + }); + + await db.getRepository('collections').create({ + values: { + name: 'orders', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'users').create({ + values: { + name: 'name', + type: 'string', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'users').create({ + values: { + name: 'age', + type: 'integer', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'users').create({ + values: { + interface: 'linkTo', + name: 'orders', + type: 'hasMany', + target: 'orders', + }, + context: {}, + }); + + await db.getRepository('collections.fields', 'orders').create({ + values: { + name: 'content', + type: 'string', + }, + context: {}, + }); + + await app + .agent() + .resource('roles.resources') + .create({ + associatedIndex: 'admin', + values: { + name: 'users', + usingActionsConfig: true, + actions: [ + { + name: 'create', + fields: ['orders'], + }, + { + name: 'view', + fields: ['orders'], + }, + ], + }, + }); + }); + + it('should revoke target action on association action revoke', async () => { + expect( + acl.can({ + role: 'admin', + resource: 'orders', + action: 'list', + }), + ).toMatchObject({ + role: 'admin', + resource: 'orders', + action: 'list', + }); + + await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: 'admin', + values: { + name: 'users', + usingActionsConfig: true, + actions: [], + }, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'orders', + action: 'list', + }), + ).toBeNull(); + }); + + it('should revoke association action on action revoke', async () => { + expect( + acl.can({ + role: 'admin', + resource: 'users.orders', + action: 'add', + }), + ).toMatchObject({ + role: 'admin', + resource: 'users.orders', + action: 'add', + }); + + const viewAction = await db.getRepository('rolesResourcesActions').findOne({ + filter: { + name: 'view', + }, + }); + + const actionId = viewAction.get('id') as number; + + const response = await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: 'admin', + values: { + name: 'users', + usingActionsConfig: true, + actions: [ + { + id: actionId, + }, + ], + }, + }); + + expect(response.statusCode).toEqual(200); + + expect( + acl.can({ + role: 'admin', + resource: 'users.orders', + action: 'add', + }), + ).toBeNull(); + }); + + it('should revoke association action on field deleted', async () => { + await app + .agent() + .resource('roles.resources') + .update({ + associatedIndex: 'admin', + values: { + name: 'users', + usingActionsConfig: true, + actions: [ + { + name: 'create', + fields: ['name', 'age'], + }, + ], + }, + }); + expect( + acl.can({ + role: 'admin', + resource: 'users', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'users', + action: 'create', + params: { + whitelist: ['age', 'name'], + }, + }); + const roleResource = await db.getRepository('rolesResources').findOne({ + filter: { + name: 'users', + }, + }); + + const action = await db + .getRepository('rolesResources.actions', roleResource.get('id') as string) + .findOne({ + filter: { + name: 'create', + }, + }); + + expect(action.get('fields').includes('name')).toBeTruthy(); + + // remove field + await db.getRepository('collections.fields', 'users').destroy({ + filter: { + name: 'name', + }, + context: {}, + }); + + expect( + acl.can({ + role: 'admin', + resource: 'users', + action: 'create', + }), + ).toMatchObject({ + role: 'admin', + resource: 'users', + action: 'create', + params: { + whitelist: ['age'], + }, + }); + }); + + it('should allow association fields access', async () => { + const createResponse = await app + .agent() + .resource('users') + .create({ + values: { + orders: [ + { + content: 'apple', + }, + ], + }, + }); + + expect(createResponse.statusCode).toEqual(200); + + const user = await db.getRepository('users').findOne(); + // @ts-ignore + expect(await user.countOrders()).toEqual(1); + + expect( + acl.can({ + role: 'admin', + resource: 'users.orders', + action: 'list', + }), + ).toMatchObject({ + role: 'admin', + resource: 'users.orders', + action: 'list', + }); + + expect( + acl.can({ + role: 'admin', + resource: 'orders', + action: 'list', + }), + ).toMatchObject({ + role: 'admin', + resource: 'orders', + action: 'list', + }); + }); +}); diff --git a/packages/plugin-acl/src/__tests__/middleware.test.ts b/packages/plugin-acl/src/__tests__/middleware.test.ts index e43947aa5..a3cc89133 100644 --- a/packages/plugin-acl/src/__tests__/middleware.test.ts +++ b/packages/plugin-acl/src/__tests__/middleware.test.ts @@ -61,7 +61,7 @@ describe('middleware', () => { }); }); - afterAll(async () => { + afterEach(async () => { await app.destroy(); }); diff --git a/packages/plugin-acl/src/__tests__/role.test.ts b/packages/plugin-acl/src/__tests__/role.test.ts index 792618a2b..3ba5f8709 100644 --- a/packages/plugin-acl/src/__tests__/role.test.ts +++ b/packages/plugin-acl/src/__tests__/role.test.ts @@ -63,4 +63,30 @@ describe('role api', () => { }); }); }); + + it('should works with default option', async () => { + await db.getRepository('roles').create({ + values: { + name: 'role1', + title: 'admin 1', + default: true, + }, + }); + + await db.getRepository('roles').create({ + values: { + name: 'role2', + default: true, + }, + }); + + const defaultRole = await db.getRepository('roles').find({ + filter: { + default: true, + }, + }); + + expect(defaultRole.length).toEqual(1); + expect(defaultRole[0].get('name')).toEqual('role2'); + }); }); diff --git a/packages/plugin-acl/src/collections/resouces.ts b/packages/plugin-acl/src/collections/resouces.ts index c63f27fe8..e3d82243a 100644 --- a/packages/plugin-acl/src/collections/resouces.ts +++ b/packages/plugin-acl/src/collections/resouces.ts @@ -2,6 +2,7 @@ import { CollectionOptions } from '@nocobase/database'; export default { name: 'rolesResources', + model: 'RoleResourceModel', fields: [ { type: 'belongsTo', diff --git a/packages/plugin-acl/src/collections/resource-actions.ts b/packages/plugin-acl/src/collections/resource-actions.ts index dadff9189..8f854dada 100644 --- a/packages/plugin-acl/src/collections/resource-actions.ts +++ b/packages/plugin-acl/src/collections/resource-actions.ts @@ -2,6 +2,7 @@ import { CollectionOptions } from '@nocobase/database'; export default { name: 'rolesResourcesActions', + model: 'RoleResourceActionModel', fields: [ { type: 'belongsTo', @@ -14,7 +15,7 @@ export default { name: 'name', }, { - type: 'json', + type: 'array', name: 'fields', defaultValue: [], }, diff --git a/packages/plugin-acl/src/collections/roles.ts b/packages/plugin-acl/src/collections/roles.ts index afcd2a1cc..a5b7728cb 100644 --- a/packages/plugin-acl/src/collections/roles.ts +++ b/packages/plugin-acl/src/collections/roles.ts @@ -26,6 +26,11 @@ export default { type: 'json', name: 'strategy', }, + { + type: 'boolean', + name: 'default', + defaultValue: false, + }, { type: 'boolean', name: 'allowConfigure', diff --git a/packages/plugin-acl/src/model/RoleResourceActionModel.ts b/packages/plugin-acl/src/model/RoleResourceActionModel.ts new file mode 100644 index 000000000..482226e35 --- /dev/null +++ b/packages/plugin-acl/src/model/RoleResourceActionModel.ts @@ -0,0 +1,75 @@ +import { Database, Model } from '@nocobase/database'; +import { ACL, ACLRole } from '@nocobase/acl'; +import { AssociationFieldAction, AssociationFieldsActions, GrantHelper } from '../server'; + +export class RoleResourceActionModel extends Model { + async writeToACL(options: { + acl: ACL; + role: ACLRole; + resourceName: string; + associationFieldsActions: AssociationFieldsActions; + grantHelper: GrantHelper; + }) { + // @ts-ignore + const db: Database = this.constructor.database; + + const { resourceName, role, acl, associationFieldsActions, grantHelper } = options; + const actionName = this.get('name') as string; + + const fields = this.get('fields') as any; + const actionPath = `${resourceName}:${actionName}`; + const actionParams = { + fields, + }; + + // @ts-ignore + const scope = await this.getScope(); + + if (scope) { + actionParams['filter'] = scope.get('scope'); + } + + role.grantAction(actionPath, actionParams); + + const collection = db.getCollection(resourceName); + + if (!collection) { + return; + } + + const availableAction = acl.resolveActionAlias(actionName); + + for (const field of fields) { + const collectionField = collection.getField(field); + const fieldType = collectionField.get('interface') as string; + + const fieldActions: AssociationFieldAction = associationFieldsActions?.[fieldType]?.[availableAction]; + + if (fieldActions) { + const associationActions = fieldActions.associationActions || []; + associationActions.forEach((associationAction) => { + const actionName = `${resourceName}.${field}:${associationAction}`; + role.grantAction(actionName); + }); + + const targetActions = fieldActions.targetActions || []; + + targetActions.forEach((targetAction) => { + const targetActionPath = `${field}:${targetAction}`; + + grantHelper.resourceTargetActionMap.set(resourceName, [ + ...(grantHelper.resourceTargetActionMap.get(resourceName) || []), + targetActionPath, + ]); + + grantHelper.targetActionResourceMap.set(targetActionPath, [ + ...(grantHelper.targetActionResourceMap.get(targetActionPath) || []), + resourceName, + ]); + + role.grantAction(targetActionPath); + }); + } + } + } +} diff --git a/packages/plugin-acl/src/model/RoleResourceModel.ts b/packages/plugin-acl/src/model/RoleResourceModel.ts new file mode 100644 index 000000000..a75a5301d --- /dev/null +++ b/packages/plugin-acl/src/model/RoleResourceModel.ts @@ -0,0 +1,60 @@ +import { Database, Model } from '@nocobase/database'; +import { ACL, ACLRole } from '@nocobase/acl'; +import { RoleResourceActionModel } from './RoleResourceActionModel'; +import { AssociationFieldsActions, GrantHelper } from '../server'; + +export class RoleResourceModel extends Model { + async revoke(options: { role: ACLRole; resourceName: string; grantHelper: GrantHelper }) { + const { role, resourceName, grantHelper } = options; + role.revokeResource(resourceName); + + const targetActions = grantHelper.resourceTargetActionMap.get(resourceName) || []; + + for (const targetAction of targetActions) { + const targetActionResource = (grantHelper.targetActionResourceMap.get(targetAction) || []).filter( + (item) => resourceName !== item, + ); + + grantHelper.targetActionResourceMap.set(targetAction, targetActionResource); + if (targetActionResource.length == 0) { + role.revokeAction(targetAction); + } + } + + grantHelper.resourceTargetActionMap.set(resourceName, []); + } + + async writeToACL(options: { + acl: ACL; + associationFieldsActions: AssociationFieldsActions; + grantHelper: GrantHelper; + transaction: any; + }) { + const { acl, associationFieldsActions, grantHelper } = options; + const resourceName = this.get('name') as string; + const roleName = this.get('roleName') as string; + const role = acl.getRole(roleName); + + await this.revoke({ role, resourceName, grantHelper }); + + // @ts-ignore + if (this.usingActionsConfig === false) { + return; + } + + // @ts-ignore + const actions: RoleResourceActionModel[] = await this.getActions({ + transaction: options.transaction, + }); + + for (const action of actions) { + await action.writeToACL({ + acl, + role, + resourceName, + associationFieldsActions, + grantHelper: options.grantHelper, + }); + } + } +} diff --git a/packages/plugin-acl/src/server.ts b/packages/plugin-acl/src/server.ts index 7ad25f7af..ac139015d 100644 --- a/packages/plugin-acl/src/server.ts +++ b/packages/plugin-acl/src/server.ts @@ -4,35 +4,90 @@ import path from 'path'; import { availableActionResource } from './actions/available-actions'; import { roleCollectionsResource } from './actions/role-collections'; import { createACL } from './acl'; +import { RoleResourceActionModel } from './model/RoleResourceActionModel'; +import { RoleResourceModel } from './model/RoleResourceModel'; -async function actionModelToParams(actionModel, resourceName) { - const fields = actionModel.get('fields'); - const actionPath = `${resourceName}:${actionModel.get('name')}`; +export interface AssociationFieldAction { + associationActions: string[]; + targetActions?: string[]; +} - const actionParams = { - fields, - }; +interface AssociationFieldActions { + [availableActionName: string]: AssociationFieldAction; +} - const scope = await actionModel.getScope(); +export interface AssociationFieldsActions { + [associationType: string]: AssociationFieldActions; +} - if (scope) { - actionParams['filter'] = scope.get('scope'); - } +export class GrantHelper { + resourceTargetActionMap = new Map(); + targetActionResourceMap = new Map(); - return { - actionPath, - actionParams, - }; + constructor() {} } export default class PluginACL extends Plugin { acl: ACL; + associationFieldsActions: AssociationFieldsActions = {}; + + grantHelper = new GrantHelper(); + + registerAssociationFieldAction(associationType: string, value: AssociationFieldActions) { + this.associationFieldsActions[associationType] = value; + } + getACL() { return this.acl; } + registerAssociationFieldsActions() { + this.registerAssociationFieldAction('linkTo', { + view: { + associationActions: ['list', 'get'], + }, + create: { + associationActions: ['add'], + targetActions: ['view'], + }, + update: { + associationActions: ['add', 'remove', 'toggle'], + targetActions: ['view'], + }, + }); + + this.registerAssociationFieldAction('attachments', { + view: { + associationActions: ['list', 'get'], + }, + add: { + associationActions: ['upload', 'add'], + }, + update: { + associationActions: ['update', 'add', 'remove', 'toggle'], + }, + }); + + this.registerAssociationFieldAction('subTable', { + view: { + associationActions: ['list', 'get'], + }, + create: { + associationActions: ['create'], + }, + update: { + associationActions: ['update', 'destroy'], + }, + }); + } + async load() { + this.app.db.registerModels({ + RoleResourceActionModel, + RoleResourceModel, + }); + const acl = createACL(); this.acl = acl; @@ -40,12 +95,15 @@ export default class PluginACL extends Plugin { directory: path.resolve(__dirname, 'collections'), }); + this.registerAssociationFieldsActions(); + this.app.resourcer.define(availableActionResource); this.app.resourcer.define(roleCollectionsResource); this.app.resourcer.use(this.acl.middleware()); - this.app.db.on('roles.afterSave', (model) => { + this.app.db.on('roles.afterSave', async (model, options) => { + const { transaction } = options; const roleName = model.get('name'); let role = acl.getRole(roleName); @@ -59,6 +117,20 @@ export default class PluginACL extends Plugin { ...(model.get('strategy') || {}), allowConfigure: model.get('allowConfigure'), }); + + // model is default + if (model.get('default')) { + await this.app.db.getRepository('roles').update({ + values: { + default: false, + }, + filter: { + 'name.$ne': model.get('name'), + }, + hooks: false, + transaction, + }); + } }); this.app.db.on('roles.afterDestroy', (model) => { @@ -66,47 +138,53 @@ export default class PluginACL extends Plugin { acl.removeRole(roleName); }); - this.app.db.on('rolesResources.afterSave', async (model, options) => { - const roleName = model.get('roleName'); - const role = acl.getRole(roleName); - - if (model.usingActionsConfig === true && model._previousDataValues.usingActionsConfig === false) { - const actions = await model.getActions(); - for (const action of actions) { - const { actionPath, actionParams } = await actionModelToParams(action, model.get('name')); - role.grantAction(actionPath, actionParams); - } - } - - if (model._previousDataValues.usingActionsConfig === true && model.usingActionsConfig === false) { - role.revokeResource(model.get('name')); - } + this.app.db.on('rolesResources.afterSaveWithAssociations', async (model: RoleResourceModel, options) => { + await model.writeToACL({ + acl: this.acl, + associationFieldsActions: this.associationFieldsActions, + transaction: options.transaction, + grantHelper: this.grantHelper, + }); }); - this.app.db.on('rolesResourcesActions.beforeBulkUpdate', async (options) => { - options.individualHooks = true; + this.app.db.on('rolesResourcesActions.afterUpdateWithAssociations', async (model, options) => { + const { transaction } = options; + const resource = await model.getResource({ + transaction, + }); + + await resource.writeToACL({ + acl: this.acl, + associationFieldsActions: this.associationFieldsActions, + transaction: options.transaction, + grantHelper: this.grantHelper, + }); }); - this.app.db.on('rolesResourcesActions.afterSave', async (model) => { - const resource = await model.getResource(); - if (!resource) { - const previousResource = await this.app.db.getRepository('rolesResources').findOne({ - filter: { - id: model._previousDataValues.rolesResourceId, + this.app.db.on('fields.afterDestroy', async (model, options) => { + const collectionName = model.get('collectionName'); + const fieldName = model.get('name'); + + const resourceActions = await this.app.db.getRepository('rolesResourcesActions').find({ + filter: { + 'resource.name': collectionName, + 'fields.$anyOf': [fieldName], + }, + transaction: options.transaction, + }); + + for (const resourceAction of resourceActions) { + const fields = resourceAction.get('fields') as string[]; + const newFields = fields.filter((field) => field != fieldName); + + await this.app.db.getRepository('rolesResourcesActions').update({ + filterByTk: resourceAction.get('id') as number, + values: { + fields: newFields, }, + transaction: options.transaction, }); - - const roleName = previousResource.get('roleName') as string; - const role = acl.getRole(roleName); - role.revokeAction(`${previousResource.get('name')}:${model.get('name')}`); - return; } - - const roleName = resource.get('roleName'); - const role = acl.getRole(roleName); - - const { actionPath, actionParams } = await actionModelToParams(model, resource.get('name')); - role.grantAction(actionPath, actionParams); }); } } diff --git a/packages/plugin-users/src/__tests__/role.test.ts b/packages/plugin-users/src/__tests__/role.test.ts new file mode 100644 index 000000000..0794ac16c --- /dev/null +++ b/packages/plugin-users/src/__tests__/role.test.ts @@ -0,0 +1,72 @@ +import { MockServer, mockServer } from '@nocobase/test'; +import Database from '@nocobase/database'; +import PluginACL from '@nocobase/plugin-acl'; + +describe('role', () => { + let api: MockServer; + let db: Database; + + beforeEach(async () => { + api = mockServer(); + await api.cleanDb(); + api.plugin(require('../server').default); + api.plugin(PluginACL); + await api.loadAndSync(); + + db = api.db; + }); + + afterEach(async () => { + await api.destroy(); + }); + + it('should set default role', async () => { + await db.getRepository('roles').create({ + values: { + name: 'test1', + title: 'Admin User', + allowConfigure: true, + default: true, + }, + }); + + const user = await db.getRepository('users').create({}); + + // @ts-ignore + const roles = await user.getRoles(); + + expect(roles.length).toEqual(1); + expect(roles[0].get('name')).toEqual('test1'); + }); + + it('should not add role when user has role', async () => { + await db.getRepository('roles').create({ + values: { + name: 'test1', + default: true, + }, + }); + + await db.getRepository('roles').create({ + values: { + name: 'test2', + }, + }); + + const user = await db.getRepository('users').create({ + values: { + roles: [ + { + name: 'test2', + }, + ], + }, + }); + + // @ts-ignore + const roles = await user.getRoles(); + + expect(roles.length).toEqual(1); + expect(roles[0].get('name')).toEqual('test2'); + }); +}); diff --git a/packages/plugin-users/src/collections/users.ts b/packages/plugin-users/src/collections/users.ts index 3af271cc3..d6a710a4e 100644 --- a/packages/plugin-users/src/collections/users.ts +++ b/packages/plugin-users/src/collections/users.ts @@ -50,8 +50,8 @@ export default { type: 'belongsToMany', name: 'roles', target: 'roles', - foreignKey: 'user_id', - otherKey: 'role_name', + foreignKey: 'userId', + otherKey: 'roleName', sourceKey: 'id', targetKey: 'name', uiSchema: { diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts index 8d81d414c..2122214df 100644 --- a/packages/plugin-users/src/server.ts +++ b/packages/plugin-users/src/server.ts @@ -19,6 +19,21 @@ export default { }); }); + database.on('users.afterCreateWithAssociations', async (model, options) => { + const { transaction } = options; + + const defaultRole = await this.app.db.getRepository('roles').findOne({ + filter: { + default: true, + }, + transaction, + }); + + if (defaultRole && (await model.countRoles({ transaction })) == 0) { + await model.addRoles(defaultRole, { transaction }); + } + }); + database.on('afterDefineCollection', (collection: Collection) => { let { createdBy, updatedBy } = collection.options; if (createdBy === true) {