Create a Marketplace with Medusa Part 3: Implement User Management and Permissions
Shahed Nasser
Posted on June 21, 2022
đ¨ The content of this tutorial may be outdated. You can instead check out the full code for the series in this GitHub Repository.đ¨
In the previous parts of this series, you learned how to build a marketplace using Medusa and Medusa Extender. You associated users, products, and orders to a store.
This part of the tutorial focuses on user management within a store. This entails adding team members to a store, restricting the visibility of users in the whole marketplace based on the store theyâre in, and adding access control to manage permissions within a storeâs team.
You can find the code for this tutorial in this GitHub repository.
You can alternatively use the Medusa Marketplace plugin as indicated in the README of the GitHub repository. If youâre already using it make sure to update to the latest version:
npm install medusa-marketplace@latest
Prerequisites
It is assumed that youâve followed along with the first parts of the series before continuing this part. If you havenât, you should start from the first part of the series.
Alternatively, clone the [part-2
branch of the GitHub repository](https://github.com/shahednasser/medusa-marketplace-tutorial/tree/part-2) and set up and configure your PostgreSQL database. Then, follow the steps in the README.
Medusa Admin
If you donât have the Medusa Admin installed, it is recommended that you install it so that you can easily view products and orders, among other functionalities.
Alternatively, you can use Medusaâs Admin APIs to access the data on your server. However, the rest of the tutorial will mostly showcase features through the Medusa Admin.
Update Dependencies
Medusa and Medusa Extender both had new releases since the last article. So, before you implement the new functionalities itâs important to update the dependencies related to these 2 first.
In the root of your Medusa server, run the following command to update dependencies:
npm i @medusajs/medusa@latest medusa-extender@latest @medusajs/medusa-cli@latest medusa-interfaces@latest
This will install version 1.3.1
of Medusaâs core packages, and version 1.7.2
of the Medusa Extender.
Please note that you might receive an error if the version of awilix
is set to anything other than 4.2.3
while using version 1.3.1
. If you get the error, please update the dependency in package.json
:
"awilix": "4.2.3"
If at the time youâre following along with the tutorial the versions have changed, please note that there might be some difference between the versions used here and the version you have.
Version 1.7.2
of Medusa Extender changes how migrations are found in the code base and now requires you to include the path in the configuration. So, in medusa-config.js
, add the following in the exported object:
module.exports = {
//other options...
projectConfig: {
//other options...
cli_migration_dirs: [
'dist/**/*.migration.js'
]
},
};
Changes Based on Update
The Medusa Extenderâs newest updates bring changes for a better developer experience as well as new features. There is one change that affects the current code you have.
Change LoggedInUser Middleware
The change entails the loggedInUser
middleware located in src/modules/user/middlewares/loggedInUser.middleware.ts
. Currently, the middleware runs for all route paths.
The new Medusa Extender update allows you to specify a regular expression pattern for the path
route that the current route will be tested on.
So, change the Middleware
decorator in src/modules/user/middlewares/loggedInUser.middleware.ts
to the following:
@Middleware({ requireAuth: true, routes: [{ method: "all", path: '/admin/*' }] })
Following this change, the middleware now will only run when the route starts with /admin/
.
Then, change the code inside the consume
method to the following:
public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
const userService = req.scope.resolve('userService') as UserService;
const loggedInUser = await userService.retrieve(req.user.userId, {
select: ['id', 'store_id']
});
req.scope.register({
loggedInUser: {
resolve: () => loggedInUser,
},
});
next();
}
Previously, you had to check whether the current route is an admin route inside the middleware. As this is not necessary anymore, you just set the logged-in user right away without checking.
Change loggedInUser in Services
This change also means that the loggedInUser
will not always be in the scope as you previously implemented it. So, youâll need to update how the loggedInUser
was accessed before in multiple places.
In src/modules/order/order.service.ts
, change the loggedInUser
in InjectedDependencies
to the following:
loggedInUser?: User;
Then, change the if
condition in the buildQuery_
method to the following:
if (Object.keys(this.container).includes('loggedInUser') && this.container.loggedInUser.store_id) {
selector['store_id'] = this.container.loggedInUser.store_id;
}
As the container
acts as a proxy object to the container object the awilix
package gives us, you can check if a property exists in the container
using Object.keys(this.container).includes
. If you access the property directly and it doesnât exist in the container, the error AwilixResolutionError
will be thrown.
The next change is in src/modules/product/services/product.service.ts
. Change the loggedInUser
in ConstructorParams
to the following:
loggedInUser?: User;
Then, in the prepareListQuery_
method change the declaration and initialization of loggedInUser
to the following:
const loggedInUser = Object.keys(this.container).includes('loggedInUser') ? this.container.loggedInUser : null
Youâre not making changes in
attachStoreToProduct
since a product can only be added if a user is logged in.
Finally, in src/modules/store/services/store.service.ts
, change the loggedInUser
in ConstructorParams
to the following:
loggedInUser?: User;
Then, in the retrieve
method change the if
condition at the beginning of the method to the following:
if (!Object.keys(this.container).includes('loggedInUser')) {
return super.retrieve(relations);
}
Test Current Code
The new update and changes will not affect how your marketplace was previously functioning. To test it out, run the server:
npm start
Then, you can try out the previous functionalities you implemented. Everything should work as expected.
You can run the Medusa admin to test out functionalities on it by going to the directory of the Medusa admin and running the following command:
npm start
Retrieve Users By Store
Go to your Medusa admin and log in with a user that has a store. Alternatively, you can use the Authenticate a User endpoint.
Then, on the Medusa admin go to Settings > Users, or use the Retrieve all Users REST API. At the moment, only one user is part of each store. However, you can see that all users are retrieved and not just the users that are part of the store the currently logged-in user is in.
To change this, go to src/modules/user/services/user.service.ts
and add loggedInUser
to ConstructorParams
:
type ConstructorParams = {
//...
loggedInUser?: User;
};
Next, change the Service
decorator to the following:
@Service({ scope: 'SCOPED', override: MedusaUserService })
This allows the UserService
to access the loggedInUser
in the scope.
Finally, add the following method inside the UserService
class:
buildQuery_(selector, config = {}): object {
if (Object.keys(this.container).includes('loggedInUser') && this.container.loggedInUser.store_id) {
selector['store_id'] = this.container.loggedInUser.store_id;
}
return super.buildQuery_(selector, config);
}
This filters out the users based on the store_id
of the logged-in user.
Test Retrieve Users by Store
If you restart the Medusa server now and check the Users page, youâll see only one user in the team now.
Add Users to a Store
In the previous implementation, you created a new store for every new user. In this section, youâll change that to allow users to add other users to their store.
To do that, go to src/modules/store/services/store.service.ts
and change the createStoreForNewUser
method to the following:
@OnMedusaEntityEvent.Before.Insert(User, { async: true })
public async createStoreForNewUser(
params: MedusaEventHandlerParams<User, 'Insert'>
): Promise<EntityEventType<User, 'Insert'>> {
const { event } = params;
let store_id = Object.keys(this.container).includes("loggedInUser")
? this.container.loggedInUser.store_id
: null;
if (!store_id) {
const createdStore = await this.withTransaction(event.manager).createForUser(event.entity);
if (!!createdStore) {
store_id = createdStore.id;
}
}
event.entity.store_id = store_id;
return event;
}
This method now checks if the currently logged-in user has a store_id
and attaches it to the new user. Otherwise, it creates a new store for the new user.
This implementation also allows a super admin that does not belong to any store to create new users with new stores.
Test Adding Users to a Store
The Medusa admin only includes the invite functionality to add new users, which youâll work on in the next section. So, to test out this functionality you need to use the REST APIs.
Restart your Medusa server, then log in using the REST API with a user that belongs to a store.
Next, send a request to the Create a User endpoint.
You can now go to the Medusa admin or use the Retrieve a User endpoint. You should see one more user in the team other than the previously created user.
Associate Invites with Stores
When an admin user invites another user to join their team, whether through an endpoint or through the Medusa admin, a new invite is created.
Then, when the invite is accepted, a user is created using information from that invite as well as information that the person enters when they accept the invite.
In this section, youâll make the necessary changes that associate an invite with a store and ensure that when the invite is accepted the created user is also associated with the store.
Create Invite Entity
Create the directory src/modules/invite
. This will hold all files related to the invites module.
Then, create the file src/modules/invite/invite.entity.ts
with the following content:
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
import { Entity as MedusaEntity } from "medusa-extender";
import { Invite as MedusaInvite } from "@medusajs/medusa";
import { Store } from "../store/entities/store.entity";
@MedusaEntity({override: MedusaInvite})
@Entity()
export class Invite extends MedusaInvite {
@Index()
@Column({ nullable: true })
store_id: string;
@ManyToOne(() => Store, (store) => store.invites)
@JoinColumn({ name: 'store_id' })
store: Store;
}
You extend the Invite
entity from Medusaâs core and add to it the store_id
field and the store
relation.
This relation should also be added to the Store
entity.
So, in src/modules/store/entities/store.entity.ts
add the following import at the beginning of the file:
import { Invite } from './../../invite/invite.entity';
And add the following inside the Store
class:
@OneToMany(() => Invite, (invite) => invite.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
invites: Invite[];
Create Invite Repository
Next, create the file src/modules/invite/invite.repository.ts
with the following content:
import { Repository as MedusaRepository, Utils } from "medusa-extender";
import { EntityRepository } from "typeorm";
import { Invite } from "./invite.entity";
import { InviteRepository as MedusaInviteRepository } from "@medusajs/medusa/dist/repositories/invite";
@MedusaRepository({override: MedusaInviteRepository})
@EntityRepository(Invite)
export class InviteRepository extends Utils.repositoryMixin<Invite, MedusaInviteRepository>(MedusaInviteRepository) {}
This creates a repository for the Invite
entity that extends InviteRepository
from Medusaâs core. This allows you to retrieve invites from the database based on the Invite
entity you created in the previous section.
Create Migration
To reflect the new column in the database, you need to create a migration file in the invite module.
As migration files have the format <timestamp>-invite.migration.ts
, a migration file is unique to you so you need to create it yourself.
You can generate the migration file using the following command provided by Medusa Extenderâs CLI:
./node_modules/.bin/medex g -mi invite
This creates the file src/modules/invite/<timestamp>-invite.migration.ts
 for you. Open that file and replace the up
 and down
 methods with the following implementation:
public async up(queryRunner: QueryRunner): Promise<void> {
const query = `
ALTER TABLE public."invite" ADD COLUMN IF NOT EXISTS "store_id" text;
`;
await queryRunner.query(query);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const query = `
ALTER TABLE public."invite" DROP COLUMN "store_id";
`;
await queryRunner.query(query);
}
The up
method adds the store_id
column to the invite
table, and the down
method drops the column.
Run Migrations
To actually reflect these changes in the database, you need to run the migrations.
As migrations can only be run as JavaScript files, run the build
command to transpile the TypeScript files to JavaScript files:
npm run build
Then, use the migrate
command provided by the Medusa Extender CLI to run those migrations:
./node_modules/.bin/medex migrate --run
If you get an error about duplicate migrations because of migrations from the previous part of this series, go ahead and remove the old ones from theÂ
dist
 directory and try running the command again.
If you check your database after the migration is run successfully, you can see that the new column store_id
added to the invite
table.
Change List Invites
Similar to the Retrieve all Users functionality, the List all Invites functionality currently returns all invites irrespective of which store the invite belongs to.
If you try to invite a user to a store using the Medusa admin or using the REST APIs, then log into another store with another user, you can see the invite there as well. Only invites that belong to the store of the currently logged-in user should be shown.
So, in this section, youâll implement the necessary changes so that only invites specific to a store are retrieved.
Create the file src/modules/invite/invite.service.ts
with the following content:
import { ConfigModule } from '@medusajs/medusa/dist/types/global';
import { EntityManager } from 'typeorm';
import { EventBusService } from '@medusajs/medusa';
import { Invite } from './invite.entity';
import { InviteRepository } from './invite.repository';
import { default as MedusaInviteService } from "@medusajs/medusa/dist/services/invite";
import { Service } from "medusa-extender";
import { User } from '../user/entities/user.entity';
import UserRepository from '../user/repositories/user.repository';
import UserService from '../user/services/user.service';
type InviteServiceProps = {
manager: EntityManager;
userService: UserService;
userRepository: UserRepository;
eventBusService: EventBusService;
loggedInUser?: User;
inviteRepository: InviteRepository;
}
@Service({ scope: 'SCOPED', override: MedusaInviteService })
export class InviteService extends MedusaInviteService {
static readonly resolutionKey = "inviteService"
private readonly manager: EntityManager;
private readonly container: InviteServiceProps;
private readonly inviteRepository: InviteRepository;
constructor(container: InviteServiceProps, configModule: ConfigModule) {
super(container, configModule);
this.manager = container.manager;
this.container = container;
this.inviteRepository = container.inviteRepository
}
withTransaction(transactionManager: EntityManager): InviteService {
if (!transactionManager) {
return this
}
const cloned = new InviteService({
...this.container,
manager: transactionManager
},
this.configModule_
)
cloned.transactionManager = transactionManager
return cloned
}
buildQuery_(selector, config = {}): object {
if (Object.keys(this.container).includes('loggedInUser') && this.container.loggedInUser.store_id) {
selector['store_id'] = this.container.loggedInUser.store_id;
}
return super.buildQuery_(selector, config);
}
}
You override the InviteService
from Medusaâs core. Inside the service, you override the buildQuery_
method to filter the invites based on the store ID of the currently logged-in user.
Create Invite Module
Before you can test out what you just implemented, you need to create an invite module.
Create the file src/modules/invite/invite.module.ts
with the following content:
import { Invite } from "./invite.entity";
import { InviteMigration1655123458263 } from './1655123458263-invite.migration';
import { InviteRepository } from './invite.repository';
import { InviteService } from './invite.service';
import { Module } from "medusa-extender";
@Module({
imports: [
Invite,
InviteRepository,
InviteService,
InviteMigration1655123458263,
]
})
export class InviteModule {}
Make sure to replace the migration with your own migration class name and file path.
Then, in src/main.ts
import the InviteModule
at the beginning of the file:
import { InviteModule } from './modules/invite/invite.module';
and add it to the array passed to the load
function:
await new Medusa(__dirname + '/../', expressInstance).load([
//...
InviteModule,
]);
Listen to Create Invite
In this section, youâll listen to the âbefore insertâ event on invites then attach store_id
of the logged-in user to the invite.
To listen to events on entities such as the âbefore insertâ event, you need to create a subscriber and a middleware that registers the subscriber.
To create the subscriber, create the file src/modules/invite/invite.subscriber.ts
with the following content:
import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm';
import { Utils as MedusaUtils, OnMedusaEntityEvent, eventEmitter } from 'medusa-extender';
import { Invite } from './invite.entity';
@EventSubscriber()
export default class InviteSubscriber implements EntitySubscriberInterface<Invite> {
static attachTo(connection: Connection): void {
MedusaUtils.attachOrReplaceEntitySubscriber(connection, InviteSubscriber);
}
public listenTo(): typeof Invite {
return Invite;
}
/**
* Relay the event to the handlers.
* @param event Event to pass to the event handler
*/
public async beforeInsert(event: InsertEvent<Invite>): Promise<void> {
return await eventEmitter.emitAsync(OnMedusaEntityEvent.Before.InsertEvent(Invite), {
event,
transactionalEntityManager: event.manager,
});
}
}
This subscriber emits the âbefore insertâ event whenever a new invite is created.
Then, create the file src/modules/invite/inviteSubscriber.middleware.ts
with the following content:
import {
MEDUSA_RESOLVER_KEYS,
MedusaAuthenticatedRequest,
MedusaMiddleware,
Utils as MedusaUtils,
Middleware
} from 'medusa-extender';
import { NextFunction, Response } from 'express';
import { Connection } from 'typeorm';
import InviteSubscriber from './invite.subscriber';
@Middleware({ requireAuth: true, routes: [{ method: "post", path: '/admin/invites*' }] })
export class AttachInviteSubscriberMiddleware implements MedusaMiddleware {
public async consume(req: MedusaAuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
const { connection } = req.scope.resolve(MEDUSA_RESOLVER_KEYS.manager) as { connection: Connection };
InviteSubscriber.attachTo(connection)
return next();
}
}
This middleware registers the subscriber on all routes that begin with /admin/invites
if the request method is POST
.
Next, in src/modules/store/services/store.service.ts
import Invite
at the beginning of the file:
import { Invite } from '../../invite/invite.entity';
Then, add the following method to listen to the âbefore insertâ event and handle it:
@OnMedusaEntityEvent.Before.Insert(Invite, { async: true })
public async addStoreToInvite(
params: MedusaEventHandlerParams<Invite, 'Insert'>
): Promise<EntityEventType<Invite, 'Insert'>> {
const { event } = params; //invite to be created is in event.entity
let store_id = this.container.loggedInUser.store_id
if (!event.entity.store_id && store_id) {
event.entity.store_id = store_id;
}
return event;
}
This checks if the invite doesnât already have a store_id
and if the current user has a store_id
, then set the store_id
of the invite to the logged-in userâs invite.
This implementation also enables the super admin to send invites to other super admins.
Finally, you need to add the middleware to the invite module before you can test it out.
In src/modules/invite/invite.module.ts
add the following import at the beginning of the file:
import { AttachInviteSubscriberMiddleware } from "./inviteSubscriber.middleware";
Then, add the AttachInviteSubscriberMiddleware
class to the imports
array passed to the Module
directive:
@Module({
imports: [
//...
AttachInviteSubscriberMiddleware,
]
})
Test List and Create Invites
Restart the server and check the Users page again. Then, create an invite. The invite should now appear in the store itâs associated with and not in all stores.
Associate Invited User with Store
When the invite is accepted, a new user is created. However, the current implementation does not associate the new user with any store.
In this section, youâll override the Accept an Invite endpoint to add this implementation.
Go to src/modules/user/services/user.service.ts
and add the new method addUserToStore
:
public async addUserToStore (user_id, store_id) {
await this.atomicPhase(async (m) => {
const userRepo = m.getCustomRepository(this.userRepository);
const query = this.buildQuery_({ id: user_id });
const user = await userRepo.findOne(query);
if (user) {
user.store_id = store_id;
await userRepo.save(user);
}
})
}
Also, add the withTransaction
method to override the parentâs method and make sure it returns the custom user service:
withTransaction(transactionManager: EntityManager): UserService {
if (!transactionManager) {
return this
}
const cloned = new UserService({
...this.container,
manager: transactionManager
})
cloned.transactionManager = transactionManager
return cloned
}
Next, you need to add a new method to InviteService
that retrieves an invite by ID.
In src/modules/invite/invite.service.ts
add the following method in InviteService
:
async retrieve (invite_id: string) : Promise<Invite|null> {
return await this.atomicPhase_(async (m) => {
const inviteRepo: InviteRepository = m.getCustomRepository(
this.inviteRepository
)
return await inviteRepo.findOne({ where: { id: invite_id } })
})
}
You can now override the original route that handles accepting invites.
Create the file src/modules/invite/acceptInvite.controller.ts
with the following content:
import { AdminPostInvitesInviteAcceptReq } from "@medusajs/medusa"
import { InviteService } from './invite.service';
import { MedusaError } from 'medusa-core-utils';
import UserService from '../user/services/user.service';
import { validator } from "@medusajs/medusa/dist/utils/validator"
export default async (req, res) => {
const validated = await validator(AdminPostInvitesInviteAcceptReq, req.body)
const inviteService: InviteService = req.scope.resolve(InviteService.resolutionKey)
const manager: EntityManager = req.scope.resolve("manager")
await manager.transaction(async (m) => {
//retrieve invite
let decoded
try {
decoded = inviteService
.withTransaction(m)
.verifyToken(validated.token)
} catch (err) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"Token is not valid"
)
}
const invite = await inviteService
.withTransaction(m)
.retrieve(decoded.invite_id);
let store_id = invite ? invite.store_id : null;
const user = await inviteService
.withTransaction(m)
.accept(validated.token, validated.user);
if (store_id) {
const userService: UserService = req.scope.resolve("userService");
await userService
.withTransaction(m)
.addUserToStore(user.id, store_id);
}
res.sendStatus(200)
})await userService
.withTransaction(m)
.addUserToStore(user.id, store_id);
}
res.sendStatus(200)
})
}
This uses the same implementation as the original accept invite endpoint. However, it retrieves the invite using the retrieve
method you created earlier. Then, it retrieves the store_id
of the invite.
The accept
method in inviteService
creates a new user and returns it. You then check if the store_id
was set on the invite and add the store_id
to the user using the userService.addUserToStore
method.
To make this function the handler of the Accept an Invite endpoint, create the file src/modules/invite/invite.router.ts
with the following content:
import { Router } from 'medusa-extender';
import acceptInvite from './acceptInvite.controller';
@Router({
routes: [
{
requiredAuth: false,
path: '/admin/invites/accept',
method: 'post',
handlers: [acceptInvite],
},
],
})
export class AcceptInviteRouter {}
The last step is to add this router to the invite module.
In src/modules/invite/invite.module.ts
import this class at the beginning of the function:
import { AcceptInviteRouter } from "./invite.router";
Then, add it to the imports
array passed to the Module
directive:
@Module({
imports: [
//...
AcceptInviteRouter,
]
})
Test Accepting Invites
Restart your server. Then, send a request to the List all Invites endpoint and retrieve the token
of the invite you want to accept.
After you retrieve the token, send a request to the Accept an Invite endpoint. You can then check if the new user shows up in the store itâs associated with it.
Implementing Roles and Permissions
This section covers how to implement user roles and permissions in a brief way. You may choose to implement it differently based on your use case.
This entails creating two new entities Role
and Permission
. A permission can be associated with multiple roles, and a role can have multiple permissions. Additionally, a role is associated with a store, and a user is associated with a role.
Then, youâll create a guard middleware that you can add to any route you want to protect based on a certain permission. Youâll see an example of how to use the guard middleware.
â ď¸Â This section does not cover the endpoints necessary to add permissions and roles and associate them with users. Youâll need to implement it yourself.
Create the Role Module
Start by creating the src/modules/role
directory that holds all files related to the role module.
Then, create the file src/modules/role/role.entity.ts
with the following content:
import { BeforeInsert, Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany } from "typeorm";
import { BaseEntity } from "@medusajs/medusa";
import { Entity as MedusaEntity } from "medusa-extender";
import { Permission } from '../permission/permission.entity';
import { Store } from "../store/entities/store.entity";
import { User } from "../user/entities/user.entity";
import { generateEntityId } from "@medusajs/medusa/dist/utils";
@MedusaEntity()
@Entity()
export class Role extends BaseEntity {
@Column({type: "varchar"})
name: string;
@Index()
@Column({ nullable: true })
store_id: string;
@ManyToMany(() => Permission)
@JoinTable({
name: "role_permissions",
joinColumn: {
name: "role_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "permission_id",
referencedColumnName: "id",
},
})
permissions: Permission[]
@OneToMany(() => User, (user) => user.teamRole)
@JoinColumn({ name: 'id', referencedColumnName: 'role_id' })
users: User[];
@ManyToOne(() => Store, (store) => store.roles)
@JoinColumn({ name: 'store_id' })
store: Store;
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "role")
}
}
If you see errors about missing imports or methods, youâll be resolving them as you proceed with the next steps.
This creates a Role
entity that has the properties name
and store_id
as well as the relations mentioned earlier.
You need to add these relations to the Store
and User
entities.
Go to src/modules/user/entities/user.entity.ts
and add the following import at the beginning of the file:
import { Role } from '../../role/role.entity';
Then, add the following inside the User
entity:
@Index()
@Column({ nullable: true })
role_id: string;
@ManyToOne(() => Role, (role) => role.users)
@JoinColumn({ name: 'role_id' })
teamRole: Role;
Next, go to src/modules/store/entities/store.entity.ts
and add the following import at the beginning of the file:
import { Role } from '../../role/role.entity';
Then, add the following inside the Store
entity:
@OneToMany(() => Role, (role) => role.store)
@JoinColumn({ name: 'id', referencedColumnName: 'store_id' })
roles: Role[];
Then, create the file src/modules/role/role.repository.ts
with the following content:
import { EntityRepository, Repository } from "typeorm";
import { Repository as MedusaRepository } from "medusa-extender";
import { Role } from './role.entity';
@MedusaRepository()
@EntityRepository(Role)
export class RoleRepository extends Repository<Role> {}
Next, you need to create 2 migrations: one to create the role
table and one to add the role_id
column to the user
table.
Run the following command to create the role
migration:
./node_modules/.bin/medex g -mi role
Then, go to src/modules/role/<TIMESTAMP>-role.migration.ts
and add the following import at the beginning of the file:
import { TableForeignKey } from 'typeorm';
Then, replace the up
and down
methods with the following:
public async up(queryRunner: QueryRunner): Promise<void> {
const query = `
CREATE TABLE "role" ("id" character varying NOT NULL,
"name" character varying NOT NULL, "store_id" character varying NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now())
`;
await queryRunner.query(query);
await queryRunner.createPrimaryKey("role", ["id"])
await queryRunner.createForeignKey("role", new TableForeignKey({
columnNames: ["store_id"],
referencedColumnNames: ["id"],
referencedTableName: "store",
onDelete: "CASCADE",
onUpdate: "CASCADE"
}))
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("role", true);
}
Next, run the following command:
./node_modules/.bin/medex g -mi user
Running this command can mess up the file
src/modules/user/user.module.ts
as the files in the user module are based on an old structure that the Medusa Extender used. Please check the file for any errors in the imports and resolve them. If it adds an import forUserSubscriber
you can safely remove it as well as remove it from theimports
array passed toModule
.
Go to src/modules/user/<TIMESTAMP>-user.migration.ts
and add the following import at the beginning of the file:
import { TableForeignKey } from 'typeorm';
Then, replace the up
and down
methods with the following content:
public async up(queryRunner: QueryRunner): Promise<void> {
const query = `ALTER TABLE public."user" ADD COLUMN IF NOT EXISTS "role_id" text;`;
await queryRunner.query(query);
await queryRunner.createForeignKey("user", new TableForeignKey({
columnNames: ["role_id"],
referencedColumnNames: ["id"],
referencedTableName: "role",
onDelete: "CASCADE",
onUpdate: "CASCADE"
}))
}
public async down(queryRunner: QueryRunner): Promise<void> {
const query = `ALTER TABLE public."user" DROP COLUMN "role_id";`;
await queryRunner.query(query);
}
Next, create the file src/modules/role/role.module.ts
with the following content:
import { Module } from "medusa-extender";
import { Role } from "./role.entity";
import { RoleMigration1655131148363 } from './1655131148363-role.migration';
import { RoleRepository } from "./role.repository";
@Module({
imports: [
Role,
RoleRepository,
RoleMigration1655131148363
]
})
export class RoleModule {}
Make sure to replace the migration with your own migration class name and file path.
Finally, at the beginning of src/main.ts
import the role module:
import { RoleModule } from './modules/role/role.module';
and add it in the array passed to the load
function:
await new Medusa(__dirname + '/../', expressInstance).load([
//...
RoleModule,
]);
Create the Permission Module
Start by creating the src/modules/permission
folder that holds all files related to the permission module.
Then, create the file src/modules/permission/permission.entity.ts
with the following content:
import { BeforeInsert, Column, Entity, JoinTable, ManyToMany } from "typeorm";
import { BaseEntity } from "@medusajs/medusa";
import { DbAwareColumn } from "@medusajs/medusa/dist/utils/db-aware-column";
import { Entity as MedusaEntity } from "medusa-extender";
import { Role } from "../role/role.entity";
import { generateEntityId } from "@medusajs/medusa/dist/utils";
@MedusaEntity()
@Entity()
export class Permission extends BaseEntity {
@Column({type: "varchar"})
name: string;
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
@ManyToMany(() => Role)
@JoinTable({
name: "role_permissions",
joinColumn: {
name: "permission_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "role_id",
referencedColumnName: "id",
},
})
roles: Role[]
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "perm")
}
}
This creates the entity Permission
which has the attributes name
and metadata
. You can use the metadata
attribute, which acts as an object with key-value pairs, to add whatever condition that these permissions entail. For example, you can add the name of paths that the role this permission is associated with can access.
Then, create the file src/modules/permission/permission.repository.ts
with the following content:
import { EntityRepository, Repository } from "typeorm";
import { Repository as MedusaRepository } from "medusa-extender";
import { Permission } from './permission.entity';
@MedusaRepository()
@EntityRepository(Permission)
export class PermissionRepository extends Repository<Permission> {}
Next, you need to create the migration for permission. Run the following command to create the migration:
./node_modules/.bin/medex g -mi permission
Then, open the file src/modules/permission/<TIMESTAMP>-permission.migration.ts
and add the following import at the beginning of the file:
import { TableForeignKey } from 'typeorm';
and replace the up
and down
methods with the following:
public async up(queryRunner: QueryRunner): Promise<void> {
let query = `
CREATE TABLE "permission" ("id" character varying NOT NULL,
"name" character varying NOT NULL, "metadata" jsonb,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now())`;
await queryRunner.query(query);
await queryRunner.createPrimaryKey("permission", ["id"])
query = `
CREATE TABLE "role_permissions" ("role_id" character varying NOT NULL, "permission_id" character varying NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now())`;
await queryRunner.query(query);
await queryRunner.createPrimaryKey("role_permissions", ["role_id", "permission_id"])
await queryRunner.createForeignKey("role_permissions", new TableForeignKey({
columnNames: ["role_id"],
referencedColumnNames: ["id"],
referencedTableName: "role",
onDelete: "CASCADE",
onUpdate: "CASCADE"
}))
await queryRunner.createForeignKey("role_permissions", new TableForeignKey({
columnNames: ["permission_id"],
referencedColumnNames: ["id"],
referencedTableName: "permission",
onDelete: "CASCADE",
onUpdate: "CASCADE"
}))
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable("role_permissions", true);
await queryRunner.dropTable("permission", true);
}
Notice that in this migration you also create the table role_permissions
which creates the many-to-many relation between the two entities.
The final step is to add the guard middleware that will handle checking whether the user can access an endpoint or not.
Create the file src/modules/permission/permission.guard.ts
with the following content:
import UserService from "../user/services/user.service";
import _ from "lodash";
export default (permissions: Record<string, unknown>[]) => {
return async (req, res, next) => {
const userService = req.scope.resolve('userService') as UserService;
const loggedInUser = await userService.retrieve(req.user.userId, {
select: ['id', 'store_id'],
relations: ['teamRole', 'teamRole.permissions']
});
const isAllowed = permissions.every(permission =>
loggedInUser.teamRole?.permissions.some((userPermission) => _.isEqual(userPermission.metadata, permission))
)
if (isAllowed) {
return next()
}
//permission denied
res.sendStatus(401)
}
}
The guard accepts the parameter permissions
which is an array of type any. Then, it returns a function that accepts the req
, res
, and next
parameters as every middleware in Express.
The permissions
parameter is an array of permissions that the logged in user must have before accessing a route. So, inside the returned middleware function, you check that the logged in user has every item in permissions
as part of their role. You use the metadata
field in the Permission
entity to check for equality between the userâs permissions and the required permissions for this route.
If the user has all permissions, they are admitted to the route by calling next
. Otherwise, the 401
unauthorized status is returned.
Next, create the file src/modules/permission/permission.module.ts
with the following content:
import { Module } from "medusa-extender";
import { Permission } from "./permission.entity";
import { PermissionMigration1655131601491 } from "./1655131601491-permission.migration";
import { PermissionRepository } from "./permission.repository";
@Module({
imports: [
Permission,
PermissionRepository,
PermissionMigration1655131601491
]
})
export class PermissionModule {}
Make sure to replace the migration with your own migration class name and file path.
Finally, import this file at the beginning of src/main.ts
:
import { PermissionModule } from './modules/permission/permission.module';
And add the class to the array passed to the load
function:
await new Medusa(__dirname + '/../', expressInstance).load([
//...
PermissionModule,
]);
Run Migrations
Run the build
command to transpile the TypeScript files to JavaScript files:
npm run build
If you see an error when you run this command, check the imports in
src/modules/invite/invite.module.ts
. This is because of how the Medusa Extender CLI works when you ran the migrate command earlier. If you see an import forInviteSubscriber
you can safely remove it as well as remove it from theimports
array passed toModule
.
Then, use the migrate
command provided by the Medusa Extender CLI to run those migrations:
./node_modules/.bin/medex migrate --run
If you get an error about duplicate migrations because of previous migrations, go ahead and remove the old ones from theÂ
dist
 directory and try running the command again.
If you check your database after the migration is run successfully, you can see that 2 new tables role
and permission
have been added to the database, and the user
table has a new column role_id
.
Test Roles and Permissions
As mentioned earlier in this section, youâll need to either implement endpoints to add roles and permissions yourself or add them directly to the database if you want to test it out.
Then, to use the permissions middleware, you can pass it to the handlers
array of any router. For example, hereâs a router that restricts access to the List Products endpoint:
import { Router } from 'medusa-extender';
import listProductsHandler from '@medusajs/medusa/dist/api/routes/admin/products/list-products';
import permissionGuard from '../permission/permission.guard';
import wrapHandler from '@medusajs/medusa/dist/api/middlewares/await-middleware';
@Router({
routes: [
{
requiredAuth: true,
path: '/admin/products',
method: 'get',
handlers: [
permissionGuard([
{path: "/admin/products"}
]),
wrapHandler(listProductsHandler)
],
},
],
})
export class ProductsRouter {}
Notice that you pass the argument [{path: "/admin/products"}]
as the permission to check for. If the user has permission that has metadata
with the same value as the argument, theyâll be able to access the endpoint. Otherwise, theyâll be unauthorized to access.
You can pass multiple permissions in the array.
Make sure to pass the router to the module it is associated with and restart the server before testing it out.
Whatâs Next?
In the next tutorial in this series, youâll learn how to make customization to endpoints and to the Medusa admin to make sure the super admin can manage the marketplace as a whole.
You can also check out the following resources for additional help while developing your marketplace with Medusa:
- Learn how to deploy Medusa on Heroku
- Learn how to add Stripe as a payment provider.
- Learn how to add a storefront either with Next.js or Gatsby.
Should you have any issues or questions related to Medusa, then feel free to reach out to the Medusa team via Discord.
Posted on June 21, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.