Feat(medusa, user, core-flows): User creation with invites (#6413)

**What**
- add accept invite endpoint 

**Invite accept flow**
- authenticate using the auth endpoint to create and auth-user
- invoke accept endpoint with token and info to create user
This commit is contained in:
Philip Korsholm
2024-02-19 13:29:15 +08:00
committed by GitHub
parent 5977a38ef4
commit f6d38163bb
28 changed files with 399 additions and 40 deletions
@@ -5,6 +5,7 @@ import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../environment-helpers/use-api"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
import { createAdminUser } from "../../helpers/create-admin-user"
jest.setTimeout(50000)
@@ -24,7 +25,7 @@ describe("POST /admin/invites", () => {
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await createAdminUser(dbConnection, adminHeaders)
})
afterAll(async () => {
@@ -8,6 +8,7 @@ import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../environment-helpers/use-api"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
import { createAdminUser } from "../../helpers/create-admin-user"
jest.setTimeout(50000)
@@ -31,7 +32,7 @@ describe("DELETE /admin/invites/:id", () => {
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await createAdminUser(dbConnection, adminHeaders)
})
afterAll(async () => {
@@ -8,6 +8,7 @@ import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../environment-helpers/use-api"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
import { createAdminUser } from "../../helpers/create-admin-user"
jest.setTimeout(50000)
@@ -31,7 +32,7 @@ describe("GET /admin/invites", () => {
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await createAdminUser(dbConnection, adminHeaders)
})
afterAll(async () => {
@@ -1,13 +1,13 @@
import { initDb, useDb } from "../../../environment-helpers/use-db"
import { IUserModuleService } from "@medusajs/types"
import { IAuthModuleService, IUserModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { getContainer } from "../../../environment-helpers/use-container"
import path from "path"
import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../environment-helpers/use-api"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
import { createAdminUser } from "../../helpers/create-admin-user"
jest.setTimeout(50000)
@@ -31,7 +31,7 @@ describe("GET /admin/invites/:id", () => {
})
beforeEach(async () => {
await adminSeeder(dbConnection)
await createAdminUser(dbConnection, adminHeaders)
})
afterAll(async () => {
@@ -48,8 +48,6 @@ describe("GET /admin/invites/:id", () => {
it("should retrieve a single invite", async () => {
const invite = await userModuleService.createInvites({
email: "potential_member@test.com",
token: "test",
expires_at: new Date(),
})
const api = useApi()! as AxiosInstance
@@ -0,0 +1,25 @@
import { IAuthModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import adminSeeder from "../../helpers/admin-seeder"
import jwt from "jsonwebtoken"
import { getContainer } from "../../environment-helpers/use-container"
export const createAdminUser = async (dbConnection, adminHeaders) => {
await adminSeeder(dbConnection)
const appContainer = getContainer()!
const authModule: IAuthModuleService = appContainer.resolve(
ModuleRegistrationName.AUTH
)
const authUser = await authModule.create({
provider: "emailpass",
entity_id: "admin@medusa.js",
scope: "admin",
app_metadata: {
user_id: "admin_user",
},
})
const token = jwt.sign(authUser, "test")
adminHeaders.headers["authorization"] = `Bearer ${token}`
}
@@ -48,6 +48,9 @@ module.exports = {
scope: "internal",
resources: "shared",
resolve: "@medusajs/user",
options: {
jwt_secret: "test",
},
},
[Modules.STOCK_LOCATION]: {
scope: "internal",
@@ -0,0 +1,19 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { InviteDTO } from "@medusajs/types"
import { IUserModuleService } from "@medusajs/types"
import { StepResponse } from "@medusajs/workflows-sdk"
import { createStep } from "@medusajs/workflows-sdk"
export const validateTokenStepId = "validate-invite-token-step"
export const validateTokenStep = createStep(
validateTokenStepId,
async (input: string, { container }) => {
const userModuleService: IUserModuleService = container.resolve(
ModuleRegistrationName.USER
)
const invite = await userModuleService.validateInviteToken(input)
return new StepResponse(invite)
}
)
@@ -0,0 +1,52 @@
import { UserDTO } from "@medusajs/types"
import {
WorkflowData,
createWorkflow,
transform,
} from "@medusajs/workflows-sdk"
import { createUsersStep } from "../../user"
import { validateTokenStep } from "../steps/validate-token"
import { setAuthAppMetadataStep } from "../../auth"
import { InviteWorkflow } from "@medusajs/types"
import { deleteInvitesStep } from "../steps"
export const acceptInviteWorkflowId = "accept-invite-workflow"
export const acceptInviteWorkflow = createWorkflow(
acceptInviteWorkflowId,
(
input: WorkflowData<InviteWorkflow.AcceptInviteWorkflowInputDTO>
): WorkflowData<UserDTO[]> => {
// validate token
const invite = validateTokenStep(input.invite_token)
const createUserInput = transform(
{ input, invite },
({ input, invite }) => {
return [
{
...input.user,
email: invite.email,
},
]
}
)
const users = createUsersStep(createUserInput)
const authUserInput = transform({ input, users }, ({ input, users }) => {
const createdUser = users[0]
return {
authUserId: input.auth_user_id,
key: "user_id",
value: createdUser.id,
}
})
setAuthAppMetadataStep(authUserInput)
deleteInvitesStep([invite.id])
return users
}
)
@@ -1,2 +1,3 @@
export * from "./create-invites"
export * from "./delete-invites"
export * from "./accept-invite"
@@ -0,0 +1,34 @@
import { acceptInviteWorkflow } from "@medusajs/core-flows"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { InviteWorkflow } from "@medusajs/types"
import { AdminPostInvitesInviteAcceptReq } from "../validators"
import { IUserModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
if (req.auth_user?.app_metadata?.user_id) {
const moduleService: IUserModuleService = req.scope.resolve(
ModuleRegistrationName.USER
)
const user = moduleService.retrieve(req.auth_user.app_metadata.user_id)
res.status(200).json({ user })
return
}
const workflow = acceptInviteWorkflow(req.scope)
const input = {
invite_token: req.filterableFields.token as string,
auth_user_id: req.auth_user!.id,
user: req.validatedBody as AdminPostInvitesInviteAcceptReq,
} as InviteWorkflow.AcceptInviteWorkflowInputDTO
const { result: users } = await workflow.run({ input })
// Set customer_id on session user if we are in session
if (req.session.auth_user) {
req.session.auth_user.app_metadata.user_id = users[0].id
}
res.status(200).json({ user: users[0] })
}
@@ -3,11 +3,19 @@ import {
AdminCreateInviteRequest,
AdminGetInvitesParams,
AdminGetInvitesInviteParams,
AdminPostInvitesInviteAcceptReq,
AdminPostInvitesInviteAcceptParams,
} from "./validators"
import * as QueryConfig from "./query-config"
import { MiddlewareRoute } from "../../../types/middlewares"
import { authenticate } from "../../../utils/authenticate-middleware"
export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
{
method: "ALL",
matcher: "/admin/invites*",
middlewares: [authenticate("admin", ["session", "bearer"])],
},
{
method: ["GET"],
matcher: "/admin/invites",
@@ -23,6 +31,14 @@ export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
matcher: "/admin/invites",
middlewares: [transformBody(AdminCreateInviteRequest)],
},
{
method: "POST",
matcher: "/admin/invites/accept",
middlewares: [
transformBody(AdminPostInvitesInviteAcceptReq),
transformQuery(AdminPostInvitesInviteAcceptParams),
],
},
{
method: ["GET"],
matcher: "/admin/invites/:id",
@@ -1,5 +1,11 @@
import { Type } from "class-transformer"
import { IsEmail, IsOptional, IsString, ValidateNested } from "class-validator"
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator"
import {
DateComparisonOperator,
FindParams,
@@ -70,3 +76,63 @@ export class AdminCreateInviteRequest {
@IsEmail()
email: string
}
/**
* Details of the use accepting the invite.
*/
export class AdminPostInvitesInviteAcceptUserReq {}
/**
* @schema AdminPostInvitesInviteAcceptReq
* type: object
* description: "The details of the invite to be accepted."
* required:
* - token
* - user
* properties:
* token:
* description: "The token of the invite to accept. This is a unique token generated when the invite was created or resent."
* type: string
* user:
* description: "The details of the user to create."
* type: object
* required:
* - first_name
* - last_name
* - password
* properties:
* first_name:
* type: string
* description: the first name of the User
* last_name:
* type: string
* description: the last name of the User
* password:
* description: The password for the User
* type: string
* format: password
*/
export class AdminPostInvitesInviteAcceptReq {
/**
* The invite's first name.
*/
@IsString()
@IsOptional()
first_name: string
/**
* The invite's last name.
*/
@IsString()
@IsOptional()
last_name: string
}
export class AdminPostInvitesInviteAcceptParams {
@IsString()
@IsNotEmpty()
token: string
@IsOptional()
expand = undefined
}
+2 -1
View File
@@ -15,6 +15,7 @@ export interface CreateInviteDTO {
metadata?: Record<string, unknown> | null
}
export interface UpdateInviteDTO extends Partial<CreateInviteDTO> {
export interface UpdateInviteDTO
extends Partial<Omit<CreateInviteDTO, "email">> {
id: string
}
+5
View File
@@ -12,6 +12,11 @@ import { IModuleService } from "../modules-sdk"
import { RestoreReturn, SoftDeleteReturn } from "../dal"
export interface IUserModuleService extends IModuleService {
validateInviteToken(
token: string,
sharedContext?: Context
): Promise<InviteDTO>
retrieve(
id: string,
config?: FindConfig<UserDTO>,
@@ -0,0 +1,10 @@
export interface AcceptInviteWorkflowInputDTO {
invite_token: string
auth_user_id: string
user: {
first_name?: string | null
last_name?: string | null
avatar_url?: string | null
metadata?: Record<string, unknown> | null
}
}
+1 -1
View File
@@ -1,3 +1,3 @@
export * from "./create-invite"
export * from "./update-invite"
export * from "./delete-invite"
export * from "./accept-invite"
@@ -1,5 +0,0 @@
import { UpdateInviteDTO } from "../../user"
export interface UpdateInvitesWorkflowInputDTO {
updates: UpdateInviteDTO[]
}
@@ -10,6 +10,7 @@ export function getInitModuleConfig() {
schema: process.env.MEDUSA_USER_DB_SCHEMA,
},
},
jwt_secret: "test",
}
const injectedDependencies = {}
+1
View File
@@ -56,6 +56,7 @@
"@mikro-orm/postgresql": "5.9.7",
"awilix": "^8.0.0",
"dotenv": "16.3.1",
"jsonwebtoken": "^9.0.2",
"knex": "2.4.2"
}
}
+1
View File
@@ -1 +1,2 @@
export { default as UserModuleService } from "./user-module"
export { default as InviteService } from "./invite"
+126
View File
@@ -0,0 +1,126 @@
import { Context, DAL } from "@medusajs/types"
import {
InjectTransactionManager,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import { Invite } from "@models"
import { InviteServiceTypes } from "@types"
import jwt, { JwtPayload } from "jsonwebtoken"
type InjectedDependencies = {
inviteRepository: DAL.RepositoryService
}
// 7 days
const DEFAULT_VALID_INVITE_DURATION = 1000 * 60 * 60 * 24 * 7
export default class InviteService<
TEntity extends Invite = Invite
> extends ModulesSdkUtils.internalModuleServiceFactory<InjectedDependencies>(
Invite
)<TEntity> {
// eslint-disable-next-line max-len
protected readonly inviteRepository_: DAL.RepositoryService<TEntity>
protected options_: { jwt_secret: string; valid_duration: number } | undefined
constructor(container: InjectedDependencies) {
super(container)
this.inviteRepository_ = container.inviteRepository
}
public withModuleOptions(options: any) {
const service = new InviteService<TEntity>(this.__container__)
service.options_ = options
return service
}
private getOption(key: string) {
if (!this.options_) {
throw new MedusaError(
MedusaError.Types.UNEXPECTED_STATE,
`Options are not configured for InviteService, call "withModuleOptions" and provide options`
)
}
return this.options_[key]
}
create(
data: InviteServiceTypes.CreateInviteDTO,
context?: Context
): Promise<TEntity>
create(
data: InviteServiceTypes.CreateInviteDTO[],
context?: Context
): Promise<TEntity[]>
@InjectTransactionManager("inviteRepository_")
async create(
data:
| InviteServiceTypes.CreateInviteDTO
| InviteServiceTypes.CreateInviteDTO[],
context: Context = {}
): Promise<TEntity | TEntity[]> {
const data_ = Array.isArray(data) ? data : [data]
const invites = await super.create(data_, context)
const expiresIn: number =
parseInt(this.getOption("valid_duration")) ||
DEFAULT_VALID_INVITE_DURATION
const updates = invites.map((invite) => {
return {
id: invite.id,
expires_at: new Date().setMilliseconds(
new Date().getMilliseconds() + expiresIn
),
token: this.generateToken({ id: invite.id }),
}
})
return await super.update(updates, context)
}
@InjectTransactionManager("inviteRepository_")
async validateInviteToken(
token: string,
context?: Context
): Promise<TEntity> {
const decoded = this.validateToken(token)
return await super.retrieve(decoded.payload.id, {}, context)
}
private generateToken(data: any): string {
const jwtSecret: string = this.getOption("jwt_secret")
const expiresIn: number =
parseInt(this.getOption("valid_duration")) ||
DEFAULT_VALID_INVITE_DURATION
if (!jwtSecret) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No jwt_secret was provided in the UserModule's options. Please add one."
)
}
return jwt.sign(data, jwtSecret, {
expiresIn,
})
}
private validateToken(data: any): JwtPayload {
const jwtSecret = this.getOption("jwt_secret")
if (!jwtSecret) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
"No jwt_secret was provided in the UserModule's options. Please add one."
)
}
return jwt.verify(data, jwtSecret, { complete: true })
}
}
+17 -8
View File
@@ -10,16 +10,18 @@ import {
InjectManager,
InjectTransactionManager,
MedusaContext,
MedusaError,
ModulesSdkUtils,
} from "@medusajs/utils"
import { entityNameToLinkableKeysMap, joinerConfig } from "../joiner-config"
import { Invite, User } from "@models"
import InviteService from "./invite"
type InjectedDependencies = {
baseRepository: DAL.RepositoryService
userService: ModulesSdkTypes.InternalModuleService<any>
inviteService: ModulesSdkTypes.InternalModuleService<any>
inviteService: InviteService<any>
}
const generateMethodForModels = [Invite]
@@ -46,7 +48,7 @@ export default class UserModuleService<
protected baseRepository_: DAL.RepositoryService
protected readonly userService_: ModulesSdkTypes.InternalModuleService<TUser>
protected readonly inviteService_: ModulesSdkTypes.InternalModuleService<TInvite>
protected readonly inviteService_: InviteService<TInvite>
constructor(
{ userService, inviteService, baseRepository }: InjectedDependencies,
@@ -57,7 +59,17 @@ export default class UserModuleService<
this.baseRepository_ = baseRepository
this.userService_ = userService
this.inviteService_ = inviteService
this.inviteService_ = inviteService.withModuleOptions(
this.moduleDeclaration
)
}
@InjectTransactionManager("baseRepository_")
async validateInviteToken(
token: string,
@MedusaContext() sharedContext: Context = {}
): Promise<UserTypes.InviteDTO> {
return await this.inviteService_.validateInviteToken(token, sharedContext)
}
create(
@@ -146,14 +158,11 @@ export default class UserModuleService<
data: UserTypes.CreateInviteDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TInvite[]> {
// expiration date in 10 days
const expirationDate = new Date().setDate(new Date().getDate() + 10)
const toCreate = data.map((invite) => {
return {
...invite,
expires_at: new Date(expirationDate),
token: "placeholder", // TODO: generate token
expires_at: new Date(),
token: "placeholder",
}
})
+1 -1
View File
@@ -4,4 +4,4 @@ export type InitializeModuleInjectableDependencies = {
logger?: Logger
}
export * as ServiceTypes from "./services"
export * from "./services"
+1 -1
View File
@@ -1 +1 @@
export * from "./user"
export * as InviteServiceTypes from "./invite"
@@ -0,0 +1,5 @@
export interface CreateInviteDTO {
email: string
accepted?: boolean
metadata?: Record<string, unknown> | null
}
-13
View File
@@ -1,13 +0,0 @@
export type UserDTO = {
id: string
}
export type CreateUserDTO = {
id?: string
}
export type UpdateUserDTO = {
id: string
}
export type FilterableUserProps = {}
@@ -46,7 +46,7 @@ export abstract class AbstractAuthModuleProvider {
const cloned = new (this.constructor as any)(this.container_)
cloned.scope_ = scope
cloned.scopeConfg_ = this.scopes_[scope]
cloned.scopeConfig_ = this.scopes_[scope]
return cloned
}
+1
View File
@@ -8875,6 +8875,7 @@ __metadata:
cross-env: ^5.2.1
dotenv: 16.3.1
jest: ^29.6.3
jsonwebtoken: ^9.0.2
knex: 2.4.2
medusa-test-utils: ^1.1.40
rimraf: ^3.0.2