Feat: Extend user api (#460)
* api routes for user management * add invites and roles to db * services * invite repo * include user in accepting invitation * include user role in create user * api password reset * delete invite * include email in reset password token * added metadata as dbawarecolumn * added events for invite handling and delete functionality * added invite model to exports * add default value member and allow null roles * conditional inclusion of invites in "list-users" * integration tests for users * helpers for user testing * add unauthenticated routes to users * simplifying create invite * create users with first and last name, and dev role * reset password endpoint * removed token from response * update user with firstname, lastname and role * create invite refactor * test password reset without email in body * removed redundant router variable * cleanup * unit tests * adjustments * service tests * adjustments according to api changes * fix cart test * cloned now works * change name to verified token for the verified token * add a space * db aware columns * fix: timestampz dbaware * more testing * add list-invites endpoint * reset-password error handling * pr issues adjusted * fixed test * add optional to link templates * move invites to a new endpoint * migrate invites to own testsuite * adjust snapshots * email constraint for invite * fix integration tests * addressing pr feedback * unit tests for extended user api * linting * fix integration tests * fix unit tests * fix: Addresses breaking change from class-transformer * fix orders testing * merge "create-claim" js and ts files * add out commented tests * update typescript endpoints to reflect changes made for user management * converted invites to typescript * add exports from api endpoints * remove old js files used for reference * integration test * import reflect metadata * invite service conversion to ts * removed unused import * update invite service to match styleguide * add "expires_at" and "token" to invite table * update invite service to save tokens and validate expires_at * fix failing tests * fix tests after adding token and expires_at to invite * add expiration to create Co-authored-by: Sebastian Rindom <skrindom@gmail.com> Co-authored-by: olivermrbl <oliver@mrbltech.com>
This commit is contained in:
co-authored by
Sebastian Rindom
olivermrbl
parent
5cb09184d4
commit
d1b8f4b50b
@@ -4,7 +4,8 @@ import cors from "cors"
|
||||
import middlewares from "../../middlewares"
|
||||
import authRoutes from "./auth"
|
||||
import productRoutes from "./products"
|
||||
import userRoutes from "./users"
|
||||
import userRoutes, { unauthenticatedUserRoutes } from "./users"
|
||||
import inviteRoutes, { unauthenticatedInviteRoutes } from "./invites"
|
||||
import regionRoutes from "./regions"
|
||||
import shippingOptionRoutes from "./shipping-options"
|
||||
import shippingProfileRoutes from "./shipping-profiles"
|
||||
@@ -40,6 +41,12 @@ export default (app, container, config) => {
|
||||
// Unauthenticated routes
|
||||
authRoutes(route)
|
||||
|
||||
// reset password
|
||||
unauthenticatedUserRoutes(route)
|
||||
|
||||
// accept invite
|
||||
unauthenticatedInviteRoutes(route)
|
||||
|
||||
const middlewareService = container.resolve("middlewareService")
|
||||
// Calls all middleware that has been registered to run before authentication.
|
||||
middlewareService.usePreAuthentication(app)
|
||||
@@ -70,6 +77,7 @@ export default (app, container, config) => {
|
||||
notificationRoutes(route)
|
||||
returnReasonRoutes(route)
|
||||
noteRoutes(route)
|
||||
inviteRoutes(route)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { InviteServiceMock } from "../../../../../services/__mocks__/invite"
|
||||
|
||||
describe("POST /invites/accept", () => {
|
||||
describe("successfully accepts an invite", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", `/admin/invites/accept`, {
|
||||
payload: {
|
||||
token: "jwt_test_invite",
|
||||
user: {
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
password: "supersecret",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls InviteService accept", () => {
|
||||
expect(InviteServiceMock.accept).toHaveBeenCalledTimes(1)
|
||||
expect(InviteServiceMock.accept).toHaveBeenCalledWith("jwt_test_invite", {
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
password: "supersecret",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { InviteServiceMock } from "../../../../../services/__mocks__/invite"
|
||||
|
||||
describe("POST /invites", () => {
|
||||
describe("checks validation rules", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", `/admin/invites`, {
|
||||
payload: {
|
||||
role: "",
|
||||
},
|
||||
session: {
|
||||
jwt: {
|
||||
userId: "test_user",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("throws when role is empty", () => {
|
||||
expect(subject.error).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { request } from "../../../../../helpers/test-request"
|
||||
import { InviteServiceMock } from "../../../../../services/__mocks__/invite"
|
||||
|
||||
describe("POST /invites/:invite_id/resend", () => {
|
||||
describe("successfully resends an invite", () => {
|
||||
let subject
|
||||
|
||||
beforeAll(async () => {
|
||||
subject = await request("POST", `/admin/invites/invite_test/resend`, {
|
||||
adminSession: {
|
||||
jwt: {
|
||||
id: "test_user",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("returns 200", () => {
|
||||
expect(subject.status).toEqual(200)
|
||||
})
|
||||
|
||||
it("calls InviteService accept", () => {
|
||||
expect(InviteServiceMock.resend).toHaveBeenCalledTimes(1)
|
||||
expect(InviteServiceMock.resend).toHaveBeenCalledWith("invite_test")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Type } from "class-transformer"
|
||||
import { IsNotEmpty, IsString, ValidateNested } from "class-validator"
|
||||
import InviteService from "../../../../services/invite"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
|
||||
/**
|
||||
* @oas [post] /invites/accept
|
||||
* operationId: "PostInvitesInviteAccept"
|
||||
* summary: "Accept an Invite"
|
||||
* description: "Accepts an Invite and creates a corresponding user"
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* required:
|
||||
* - token
|
||||
* - user
|
||||
* properties:
|
||||
* token:
|
||||
* description: "The invite token provided by the admin."
|
||||
* type: string
|
||||
* user:
|
||||
* description: "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 desired password for the User
|
||||
* type: string
|
||||
* tags:
|
||||
* - Invites
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const validated = await validator(AdminPostInvitesInviteAcceptReq, req.body)
|
||||
|
||||
const inviteService: InviteService = req.scope.resolve("inviteService")
|
||||
|
||||
await inviteService.accept(validated.token, validated.user)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
export class AdminPostInvitesInviteAcceptUserReq {
|
||||
@IsString()
|
||||
first_name: string
|
||||
|
||||
@IsString()
|
||||
last_name: string
|
||||
|
||||
@IsString()
|
||||
password: string
|
||||
}
|
||||
|
||||
export class AdminPostInvitesInviteAcceptReq {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
token: string
|
||||
|
||||
@IsNotEmpty()
|
||||
@ValidateNested()
|
||||
@Type(() => AdminPostInvitesInviteAcceptUserReq)
|
||||
user: AdminPostInvitesInviteAcceptUserReq
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IsEmail, IsEnum } from "class-validator"
|
||||
import { UserRoles } from "../../../../models/user"
|
||||
import InviteService from "../../../../services/invite"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
|
||||
/**
|
||||
* @oas [post] /invites
|
||||
* operationId: "PostInvites"
|
||||
* summary: "Create an Invite"
|
||||
* description: "Creates an Invite and triggers an 'invite' created event"
|
||||
* x-authenticated: true
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* required:
|
||||
* - user
|
||||
* - role
|
||||
* properties:
|
||||
* user:
|
||||
* description: "The email for the user to be created."
|
||||
* type: string
|
||||
* role:
|
||||
* description: "The role of the user to be created."
|
||||
* type: string
|
||||
* tags:
|
||||
* - Invites
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const validated = await validator(AdminPostInvitesReq, req.body)
|
||||
|
||||
const inviteService: InviteService = req.scope.resolve("inviteService")
|
||||
|
||||
await inviteService.create(validated.user, validated.role)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
export class AdminPostInvitesReq {
|
||||
@IsEmail()
|
||||
user: string
|
||||
|
||||
@IsEnum(UserRoles)
|
||||
role: UserRoles
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import InviteService from "../../../../services/invite"
|
||||
|
||||
/**
|
||||
* @oas [delete] /invites/{invite_id}
|
||||
* operationId: "DeleteInvitesInvite"
|
||||
* summary: "Create an Invite"
|
||||
* description: "Creates an Invite and triggers an 'invite' created event"
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) invite_id=* {string} The id of the Invite
|
||||
* tags:
|
||||
* - Invites
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { invite_id } = req.params
|
||||
|
||||
const inviteService: InviteService = req.scope.resolve("inviteService")
|
||||
await inviteService.delete(invite_id)
|
||||
|
||||
res.status(200).send({
|
||||
id: invite_id,
|
||||
object: "invite",
|
||||
deleted: true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Router } from "express"
|
||||
import { Invite } from "../../../../models/invite"
|
||||
import { DeleteResponse } from "../../../../types/common"
|
||||
import middlewares from "../../../middlewares"
|
||||
import "reflect-metadata"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export const unauthenticatedInviteRoutes = (app) => {
|
||||
app.use("/invites", route)
|
||||
|
||||
route.post("/accept", middlewares.wrap(require("./accept-invite").default))
|
||||
}
|
||||
|
||||
export default (app) => {
|
||||
app.use("/invites", route)
|
||||
|
||||
route.get("/", middlewares.wrap(require("./list-invites").default))
|
||||
|
||||
route.post("/", middlewares.wrap(require("./create-invite").default))
|
||||
|
||||
route.post(
|
||||
"/:invite_id/resend",
|
||||
middlewares.wrap(require("./resend-invite").default)
|
||||
)
|
||||
|
||||
route.delete(
|
||||
"/:invite_id",
|
||||
middlewares.wrap(require("./delete-invite").default)
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
export type AdminInviteDeleteRes = DeleteResponse
|
||||
|
||||
export type AdminListInvitesRes = {
|
||||
invites: Invite[]
|
||||
}
|
||||
|
||||
export * from "./accept-invite"
|
||||
export * from "./create-invite"
|
||||
export * from "./delete-invite"
|
||||
export * from "./list-invites"
|
||||
export * from "./resend-invite"
|
||||
@@ -0,0 +1,28 @@
|
||||
import InviteService from "../../../../services/invite"
|
||||
|
||||
/**
|
||||
* @oas [get] /invites
|
||||
* operationId: "GetInvites"
|
||||
* summary: "Lists all Invites"
|
||||
* description: "Lists all Invites"
|
||||
* x-authenticated: true
|
||||
* tags:
|
||||
* - Invites
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* properties:
|
||||
* invites:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: "#/components/schemas/invite"
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const inviteService: InviteService = req.scope.resolve("inviteService")
|
||||
const invites = await inviteService.list({})
|
||||
|
||||
res.status(200).json({ invites })
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import InviteService from "../../../../services/invite"
|
||||
|
||||
/**
|
||||
* @oas [post] /invites/{invite_id}/resend
|
||||
* operationId: "PostInvitesInviteResend"
|
||||
* summary: "Resend an Invite"
|
||||
* description: "Resends an Invite by triggering the 'invite' created event again"
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) invite_id=* {string} The id of the Invite
|
||||
* tags:
|
||||
* - Invites
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK
|
||||
*/
|
||||
export default async (req, res) => {
|
||||
const { invite_id } = req.params
|
||||
const inviteService: InviteService = req.scope.resolve("inviteService")
|
||||
|
||||
await inviteService.resend(invite_id)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Router } from "express"
|
||||
import { Order } from "../../../.."
|
||||
import middlewares from "../../../middlewares"
|
||||
import "reflect-metadata"
|
||||
import { DeleteResponse, PaginatedResponse } from "../../../../types/common"
|
||||
import "reflect-metadata"
|
||||
|
||||
const route = Router()
|
||||
|
||||
|
||||
@@ -19,6 +19,12 @@ import { validator } from "../../../../utils/validator"
|
||||
* swap_link_template:
|
||||
* description: "A template for Swap links - use `{{cart_id}}` to insert the Swap Cart id"
|
||||
* type: string
|
||||
* payment_link_template:
|
||||
* description: "A template for payment links links - use `{{cart_id}}` to insert the Cart id"
|
||||
* type: string
|
||||
* invite_link_template:
|
||||
* description: "A template for invite links - use `{{invite_token}}` to insert the invite token"
|
||||
* type: string
|
||||
* default_currency_code:
|
||||
* description: "The default currency code for the Store."
|
||||
* type: string
|
||||
@@ -57,6 +63,10 @@ export class AdminPostStoreReq {
|
||||
@IsOptional()
|
||||
payment_link_template?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
invite_link_template?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
default_currency_code?: string
|
||||
|
||||
@@ -11,11 +11,6 @@ describe("POST /admin/users/password-token", () => {
|
||||
payload: {
|
||||
email: "vandijk@test.dk",
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
userId: IdMap.getId("admin_user"),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ describe("POST /admin/users/reset-password", () => {
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(UserServiceMock.setPassword).toHaveBeenCalledTimes(1)
|
||||
expect(UserServiceMock.setPassword).toHaveBeenCalledWith(
|
||||
expect(UserServiceMock.setPassword_).toHaveBeenCalledTimes(1)
|
||||
expect(UserServiceMock.setPassword_).toHaveBeenCalledWith(
|
||||
IdMap.getId("vandijk"),
|
||||
"new-password"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,8 @@ describe("POST /admin/users/:id", () => {
|
||||
`/admin/users/${IdMap.getId("test-user")}`,
|
||||
{
|
||||
payload: {
|
||||
name: "Oliver Juhl",
|
||||
first_name: "Oliver",
|
||||
last_name: "Juhl",
|
||||
},
|
||||
adminSession: {
|
||||
jwt: {
|
||||
@@ -32,7 +33,8 @@ describe("POST /admin/users/:id", () => {
|
||||
expect(UserServiceMock.update).toHaveBeenCalledWith(
|
||||
IdMap.getId("test-user"),
|
||||
{
|
||||
name: "Oliver Juhl",
|
||||
first_name: "Oliver",
|
||||
last_name: "Juhl",
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator"
|
||||
import { IsEmail, IsEnum, IsOptional, IsString } from "class-validator"
|
||||
import _ from "lodash"
|
||||
import { UserRoles } from "../../../../models/user"
|
||||
import UserService from "../../../../services/user"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
|
||||
@@ -20,9 +21,15 @@ import { validator } from "../../../../utils/validator"
|
||||
* email:
|
||||
* description: "The Users email."
|
||||
* type: string
|
||||
* name:
|
||||
* first_name:
|
||||
* description: "The name of the User."
|
||||
* type: string
|
||||
* last_name:
|
||||
* description: "The name of the User."
|
||||
* type: string
|
||||
* role:
|
||||
* description: "Userrole assigned to the user."
|
||||
* type: string
|
||||
* password:
|
||||
* description: "The Users password."
|
||||
* type: string
|
||||
@@ -42,11 +49,11 @@ export default async (req, res) => {
|
||||
const validated = await validator(AdminCreateUserRequest, req.body)
|
||||
|
||||
const userService: UserService = req.scope.resolve("userService")
|
||||
const data = _.pick(validated, ["email", "name"])
|
||||
const data = _.omit(validated, ["password"])
|
||||
|
||||
const user = await userService.create(data, validated.password)
|
||||
|
||||
res.status(200).json({ user })
|
||||
res.status(200).json({ user: _.omit(user, ["password_hash"]) })
|
||||
}
|
||||
|
||||
export class AdminCreateUserRequest {
|
||||
@@ -55,7 +62,15 @@ export class AdminCreateUserRequest {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string
|
||||
first_name?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
last_name?: string
|
||||
|
||||
@IsEnum(UserRoles)
|
||||
@IsOptional()
|
||||
role?: UserRoles
|
||||
|
||||
@IsString()
|
||||
password: string
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { Router } from "express"
|
||||
import middlewares from "../../../middlewares"
|
||||
|
||||
const route = Router()
|
||||
|
||||
export default (app) => {
|
||||
export const unauthenticatedUserRoutes = (app) => {
|
||||
app.use("/users", route)
|
||||
|
||||
route.get("/", middlewares.wrap(require("./list-users").default))
|
||||
route.get("/:user_id", middlewares.wrap(require("./get-user").default))
|
||||
|
||||
route.post("/", middlewares.wrap(require("./create-user").default))
|
||||
route.post(
|
||||
"/password-token",
|
||||
middlewares.wrap(require("./reset-password-token").default)
|
||||
@@ -19,9 +14,20 @@ export default (app) => {
|
||||
"/reset-password",
|
||||
middlewares.wrap(require("./reset-password").default)
|
||||
)
|
||||
}
|
||||
|
||||
export default (app) => {
|
||||
app.use("/users", route)
|
||||
|
||||
route.get("/:user_id", middlewares.wrap(require("./get-user").default))
|
||||
|
||||
route.post("/", middlewares.wrap(require("./create-user").default))
|
||||
|
||||
route.post("/:user_id", middlewares.wrap(require("./update-user").default))
|
||||
|
||||
route.delete("/:user_id", middlewares.wrap(require("./delete-user").default))
|
||||
|
||||
route.get("/", middlewares.wrap(require("./list-users").default))
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { IsEmail, IsString } from "class-validator"
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator"
|
||||
import jwt from "jsonwebtoken"
|
||||
import _ from "lodash"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { User } from "../../../.."
|
||||
import UserService from "../../../../services/user"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
|
||||
@@ -42,26 +45,55 @@ import { validator } from "../../../../utils/validator"
|
||||
export default async (req, res) => {
|
||||
const validated = await validator(AdminResetPasswordRequest, req.body)
|
||||
|
||||
const userService: UserService = req.scope.resolve("userService")
|
||||
const user = await userService.retrieveByEmail(validated.email)
|
||||
try {
|
||||
const userService: UserService = req.scope.resolve("userService")
|
||||
|
||||
const decodedToken = jwt.verify(validated.token, user.password_hash) as {
|
||||
user_id: string
|
||||
const decoded = (await jwt.decode(validated.token)) as payload
|
||||
|
||||
let user: User
|
||||
try {
|
||||
user = await userService.retrieveByEmail(
|
||||
validated.email || decoded?.email,
|
||||
{
|
||||
select: ["id", "password_hash"],
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, "invalid token")
|
||||
}
|
||||
|
||||
const verifiedToken = (await jwt.verify(
|
||||
validated.token,
|
||||
user.password_hash
|
||||
)) as payload
|
||||
if (!verifiedToken || verifiedToken.user_id !== user.id) {
|
||||
res.status(401).send("Invalid or expired password reset token")
|
||||
return
|
||||
}
|
||||
|
||||
const userResult = await userService.setPassword_(
|
||||
user.id,
|
||||
validated.password
|
||||
)
|
||||
|
||||
res.status(200).json({ user: _.omit(userResult, ["password_hash"]) })
|
||||
} catch (error) {
|
||||
if (error.message === "invalid token") {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!decodedToken || decodedToken.user_id !== user.id) {
|
||||
res.status(401).send("Invalid or expired password reset token")
|
||||
return
|
||||
}
|
||||
|
||||
const data = await userService.setPassword(user.id, validated.password)
|
||||
|
||||
res.status(200).json({ user: data })
|
||||
}
|
||||
|
||||
export type payload = {
|
||||
email: string
|
||||
user_id: string
|
||||
password: string
|
||||
}
|
||||
export class AdminResetPasswordRequest {
|
||||
@IsEmail()
|
||||
email: string
|
||||
@IsOptional()
|
||||
email?: string
|
||||
|
||||
@IsString()
|
||||
token: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsOptional, IsString } from "class-validator"
|
||||
import { IsEnum, IsObject, IsOptional, IsString } from "class-validator"
|
||||
import { UserRoles } from "../../../../models/user"
|
||||
import UserService from "../../../../services/user"
|
||||
import { validator } from "../../../../utils/validator"
|
||||
|
||||
@@ -10,7 +11,9 @@ import { validator } from "../../../../utils/validator"
|
||||
* x-authenticated: true
|
||||
* parameters:
|
||||
* - (path) user_id=* {string} The id of the User.
|
||||
* - (body) name {string} The name of the User.
|
||||
* - (body) first_name {string} The name of the User.
|
||||
* - (body) last_name {string} The name of the User.
|
||||
* - (body) role {string} The role of the User(admin, member, developer).
|
||||
* - (body) api_token {string} The api_token of the User.
|
||||
* tags:
|
||||
* - Users
|
||||
@@ -37,9 +40,21 @@ export default async (req, res) => {
|
||||
export class AdminUpdateUserRequest {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string
|
||||
first_name?: string
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
last_name?: string
|
||||
|
||||
@IsEnum(UserRoles)
|
||||
@IsOptional()
|
||||
role?: UserRoles
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
api_token?: string
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
metadata?: JSON
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export { GiftCard } from "./models/gift-card"
|
||||
export { GiftCardTransaction } from "./models/gift-card-transaction"
|
||||
export { IdempotencyKey } from "./models/idempotency-key"
|
||||
export { Image } from "./models/image"
|
||||
export { Invite } from "./models/invite"
|
||||
export { LineItem } from "./models/line-item"
|
||||
export { MoneyAmount } from "./models/money-amount"
|
||||
export { Note } from "./models/note"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
|
||||
export class extendedUserApi1633512755401 implements MigrationInterface {
|
||||
name = "extendedUserApi1633512755401"
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "invite_role_enum" AS ENUM('admin', 'member', 'developer')`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "invite" ("id" character varying NOT NULL, "user_email" character varying NOT NULL, "role" "invite_role_enum" DEFAULT 'member', "accepted" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, "metadata" jsonb, CONSTRAINT "PK_fc9fa190e5a3c5d80604a4f63e1" PRIMARY KEY ("id"))`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "public"."invite" ADD "token" character varying NOT NULL`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "public"."invite" ADD "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`
|
||||
)
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "user_role_enum" AS ENUM('admin', 'member', 'developer')`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user" ADD "role" "user_role_enum" DEFAULT 'member'`
|
||||
)
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "store" ADD "invite_link_template" character varying`
|
||||
)
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_6b0ce4b4bcfd24491510bf19d1" ON "invite" ("user_email")`
|
||||
)
|
||||
await queryRunner.query(`DROP INDEX "IDX_e12875dfb3b1d92d7d7c5377e2"`)
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_ba8de19442d86957a3aa3b5006" ON "public"."user" ("email") WHERE deleted_at IS NULL`
|
||||
)
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "IDX_ba8de19442d86957a3aa3b5006"`)
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_e12875dfb3b1d92d7d7c5377e2" ON "public"."user" ("email") `
|
||||
)
|
||||
await queryRunner.query(`DROP INDEX "IDX_6b0ce4b4bcfd24491510bf19d1"`)
|
||||
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "role"`)
|
||||
await queryRunner.query(`DROP TYPE "user_role_enum"`)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "public"."invite" DROP COLUMN "expires_at"`
|
||||
)
|
||||
await queryRunner.query(`ALTER TABLE "public"."invite" DROP COLUMN "token"`)
|
||||
|
||||
await queryRunner.query(`DROP TABLE "invite"`)
|
||||
await queryRunner.query(`DROP TYPE "invite_role_enum"`)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "store" DROP COLUMN "invite_link_template"`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
BeforeInsert,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from "typeorm"
|
||||
import { ulid } from "ulid"
|
||||
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
|
||||
import { UserRoles } from "./user"
|
||||
|
||||
@Entity()
|
||||
export class Invite {
|
||||
@PrimaryColumn()
|
||||
id: string
|
||||
|
||||
@Index({ unique: true, where: "deleted_at IS NULL" })
|
||||
@Column()
|
||||
user_email: string
|
||||
|
||||
@DbAwareColumn({
|
||||
type: "enum",
|
||||
enum: UserRoles,
|
||||
nullable: true,
|
||||
default: UserRoles.MEMBER,
|
||||
})
|
||||
role: UserRoles
|
||||
|
||||
@Column({ default: false })
|
||||
accepted: boolean
|
||||
|
||||
@Column()
|
||||
token: string
|
||||
|
||||
@CreateDateColumn({ type: resolveDbType("timestamptz") })
|
||||
expires_at: Date
|
||||
|
||||
@CreateDateColumn({ type: resolveDbType("timestamptz") })
|
||||
created_at: Date
|
||||
|
||||
@UpdateDateColumn({ type: resolveDbType("timestamptz") })
|
||||
updated_at: Date
|
||||
|
||||
@DeleteDateColumn({ type: resolveDbType("timestamptz") })
|
||||
deleted_at: Date
|
||||
|
||||
@DbAwareColumn({ type: "jsonb", nullable: true })
|
||||
metadata: any
|
||||
|
||||
@BeforeInsert()
|
||||
private beforeInsert(): void {
|
||||
if (this.id) {
|
||||
return
|
||||
}
|
||||
const id = ulid()
|
||||
this.id = `invite_${id}`
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@ export class Store {
|
||||
@Column({ nullable: true })
|
||||
payment_link_template: string
|
||||
|
||||
@Column({ nullable: true })
|
||||
invite_link_template: string
|
||||
|
||||
@CreateDateColumn({ type: resolveDbType("timestamptz") })
|
||||
created_at: Date
|
||||
|
||||
|
||||
@@ -11,12 +11,26 @@ import {
|
||||
import { ulid } from "ulid"
|
||||
import { resolveDbType, DbAwareColumn } from "../utils/db-aware-column"
|
||||
|
||||
export enum UserRoles {
|
||||
ADMIN = "admin",
|
||||
MEMBER = "member",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
@Entity()
|
||||
export class User {
|
||||
@PrimaryColumn()
|
||||
id: string
|
||||
|
||||
@Index({ unique: true })
|
||||
@DbAwareColumn({
|
||||
type: "enum",
|
||||
enum: UserRoles,
|
||||
nullable: true,
|
||||
default: UserRoles.MEMBER,
|
||||
})
|
||||
role: UserRoles
|
||||
|
||||
@Index({ unique: true, where: "deleted_at IS NULL" })
|
||||
@Column()
|
||||
email: string
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EntityRepository, Repository } from "typeorm"
|
||||
import { Invite } from "../models/invite"
|
||||
|
||||
@EntityRepository(Invite)
|
||||
export class InviteRepository extends Repository<Invite> {}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const InviteServiceMock = {
|
||||
list: jest.fn().mockImplementation((selector, config) => {
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
|
||||
create: jest.fn().mockImplementation(data => {
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
|
||||
accept: jest.fn().mockImplementation((token, user_id) => {
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
|
||||
resend: jest.fn().mockImplementation((id, inviter) => {
|
||||
return Promise.resolve({})
|
||||
}),
|
||||
}
|
||||
|
||||
const mock = jest.fn().mockImplementation(() => {
|
||||
return InviteServiceMock
|
||||
})
|
||||
|
||||
export default mock
|
||||
@@ -57,7 +57,7 @@ export const UserServiceMock = {
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}),
|
||||
setPassword: jest.fn().mockImplementation(userId => {
|
||||
setPassword_: jest.fn().mockImplementation(userId => {
|
||||
if (userId === IdMap.getId("test-user")) {
|
||||
return Promise.resolve(users.testUser)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import InviteService from "../invite"
|
||||
import { MockManager, MockRepository } from "medusa-test-utils"
|
||||
import { EventBusServiceMock } from "../__mocks__/event-bus"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
|
||||
// const _MockManager
|
||||
|
||||
describe("InviteService", () => {
|
||||
describe("list", () => {
|
||||
const inviteRepo = MockRepository({
|
||||
find: (q) => {
|
||||
return Promise.resolve([{ id: "invite-test-id" }])
|
||||
},
|
||||
})
|
||||
|
||||
const inviteService = new InviteService({
|
||||
manager: { getCustomRepository: jest.fn(() => inviteRepo) },
|
||||
userService: {},
|
||||
userRepository: {},
|
||||
inviteRepository: inviteRepo,
|
||||
eventBusService: EventBusServiceMock,
|
||||
})
|
||||
|
||||
it("calls invite repository find", async () => {
|
||||
await inviteService.list({ id: "test" })
|
||||
|
||||
expect(inviteRepo.find).toHaveBeenCalledTimes(1)
|
||||
expect(inviteRepo.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "test",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("token generation and validation", () => {
|
||||
const inviteService = new InviteService({
|
||||
manager: MockManager,
|
||||
userService: {},
|
||||
userRepository: {},
|
||||
inviteRepository: {},
|
||||
eventBusService: EventBusServiceMock,
|
||||
})
|
||||
|
||||
it("validating a signed token succeeds", () => {
|
||||
const res = inviteService.verifyToken(
|
||||
inviteService.generateToken({ data: "test" })
|
||||
)
|
||||
|
||||
expect(res).toEqual(expect.objectContaining({ data: "test" }))
|
||||
})
|
||||
})
|
||||
|
||||
describe("accept", () => {
|
||||
const inviteRepo = MockRepository({
|
||||
findOne: (q) => {
|
||||
if (q.where.id === "accepted") {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (q.where.id === "existingUser") {
|
||||
return Promise.resolve({
|
||||
user_email: "existing@medusa-commerce.com",
|
||||
token: inviteService.generateToken({
|
||||
user_email: "existing@medusa-commerce.com",
|
||||
invite_id: "existingUser",
|
||||
}),
|
||||
})
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: q.where.id,
|
||||
role: "admin",
|
||||
user_email: "test@test.com",
|
||||
token: inviteService.generateToken({
|
||||
user_email: "test@test.com",
|
||||
invite_id: q.where.id,
|
||||
}),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const userRepo = MockRepository({
|
||||
findOne: (q) => {
|
||||
if (q.where.user_id === "test_user") {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
if (q.where.email === "existing@medusa-commerce.com") {
|
||||
return Promise.resolve("usr_test123")
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
})
|
||||
|
||||
const createMock = {
|
||||
create: jest.fn().mockImplementation((data) => {
|
||||
return Promise.resolve({ ...data, email: "test@test.com" })
|
||||
}),
|
||||
}
|
||||
|
||||
const userServiceMock = {
|
||||
withTransaction: jest.fn().mockImplementation((m) => {
|
||||
return createMock
|
||||
}),
|
||||
}
|
||||
|
||||
const inviteService = new InviteService({
|
||||
manager: MockManager,
|
||||
userService: userServiceMock,
|
||||
userRepository: userRepo,
|
||||
inviteRepository: inviteRepo,
|
||||
eventBusService: EventBusServiceMock,
|
||||
})
|
||||
|
||||
beforeEach(() => jest.clearAllMocks())
|
||||
|
||||
it("fails to accept an invite already accepted", async () => {
|
||||
expect.assertions(1)
|
||||
await inviteService
|
||||
.accept(
|
||||
inviteService.generateToken({
|
||||
user_email: "accepted@medusa-commerce.com",
|
||||
invite_id: "accepted",
|
||||
}),
|
||||
{}
|
||||
)
|
||||
.catch((err) => {
|
||||
expect(err).toEqual(
|
||||
new MedusaError(MedusaError.Types.INVALID_DATA, "Invalid invite")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("fails to accept an with an invalid token", async () => {
|
||||
expect.assertions(2)
|
||||
await inviteService.accept("totally.valid.token", {}).catch((err) => {
|
||||
console.log(err)
|
||||
expect(err.message).toEqual("Token is not valid")
|
||||
expect(err.type).toEqual("invalid_data")
|
||||
})
|
||||
})
|
||||
|
||||
it("fails to accept an with an existing user", async () => {
|
||||
expect.assertions(1)
|
||||
await inviteService
|
||||
.accept(
|
||||
inviteService.generateToken({
|
||||
user_email: "existing@medusa-commerce.com",
|
||||
invite_id: "existingUser",
|
||||
}),
|
||||
{}
|
||||
)
|
||||
.catch((err) => {
|
||||
expect(err).toEqual(
|
||||
new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"User already joined"
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
it("accepts an invite", async () => {
|
||||
await inviteService.accept(
|
||||
inviteService.generateToken({
|
||||
user_email: "test@test.com",
|
||||
invite_id: "not yet accepted",
|
||||
}),
|
||||
{ first_name: "John", last_name: "Doe", password: "test stuff" }
|
||||
)
|
||||
expect(createMock.create).toHaveBeenCalledTimes(1)
|
||||
expect(createMock.create).toHaveBeenCalledWith(
|
||||
{
|
||||
email: "test@test.com",
|
||||
role: "admin",
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
},
|
||||
"test stuff"
|
||||
)
|
||||
|
||||
expect(inviteRepo.delete).toHaveBeenCalledTimes(1)
|
||||
expect(inviteRepo.delete).toHaveBeenCalledWith({
|
||||
id: "not yet accepted",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resend", () => {
|
||||
const inviteRepo = MockRepository({
|
||||
findOne: (q) => {
|
||||
return Promise.resolve({
|
||||
id: q.id,
|
||||
role: "admin",
|
||||
user_email: "test@test.com",
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const inviteService = new InviteService({
|
||||
manager: { getCustomRepository: jest.fn(() => inviteRepo) },
|
||||
userService: {},
|
||||
userRepository: {},
|
||||
inviteRepository: inviteRepo,
|
||||
eventBusService: EventBusServiceMock,
|
||||
})
|
||||
|
||||
inviteService.generateToken = jest.fn()
|
||||
|
||||
it("generates a token with the retreived invite", async () => {
|
||||
await inviteService.resend("invite-test-id")
|
||||
|
||||
expect(inviteRepo.findOne).toHaveBeenCalledTimes(1)
|
||||
expect(inviteService.generateToken).toHaveBeenCalledTimes(1)
|
||||
expect(inviteService.generateToken).toHaveBeenCalledWith({
|
||||
invite_id: "invite-test-id",
|
||||
role: "admin",
|
||||
user_email: "test@test.com",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import { BaseService } from "medusa-interfaces"
|
||||
import jwt, { JwtPayload } from "jsonwebtoken"
|
||||
import config from "../config"
|
||||
import { MedusaError } from "medusa-core-utils"
|
||||
import { User } from ".."
|
||||
import { EntityManager } from "typeorm"
|
||||
import { EventBusService, UserService } from "."
|
||||
import { InviteRepository } from "../repositories/invite"
|
||||
import { UserRepository } from "../repositories/user"
|
||||
import { ListInvite } from "../types/invites"
|
||||
import { UserRoles } from "../models/user"
|
||||
|
||||
// 7 days
|
||||
const DEFAULT_VALID_DURATION = 1000 * 60 * 60 * 24 * 7
|
||||
|
||||
type InviteServiceProps = {
|
||||
manager: EntityManager
|
||||
userService: UserService
|
||||
userRepository: UserRepository
|
||||
inviteRepository: InviteRepository
|
||||
eventBusService: EventBusService
|
||||
}
|
||||
|
||||
class InviteService extends BaseService {
|
||||
static Events = {
|
||||
CREATED: "invite.created",
|
||||
}
|
||||
|
||||
private manager_: EntityManager
|
||||
private userService_: UserService
|
||||
private userRepo_: UserRepository
|
||||
private inviteRepository_: InviteRepository
|
||||
private eventBus_: EventBusService
|
||||
|
||||
constructor({
|
||||
manager,
|
||||
userService,
|
||||
userRepository,
|
||||
inviteRepository,
|
||||
eventBusService,
|
||||
}: InviteServiceProps) {
|
||||
super()
|
||||
|
||||
/** @private @constant {EntityManager} */
|
||||
this.manager_ = manager
|
||||
|
||||
/** @private @constant {UserService} */
|
||||
this.userService_ = userService
|
||||
|
||||
/** @private @constant {UserRepository} */
|
||||
this.userRepo_ = userRepository
|
||||
|
||||
/** @private @constant {InviteRepository} */
|
||||
this.inviteRepository_ = inviteRepository
|
||||
|
||||
/** @private @const {EventBus} */
|
||||
this.eventBus_ = eventBusService
|
||||
}
|
||||
|
||||
withTransaction(manager): InviteService {
|
||||
if (!manager) {
|
||||
return this
|
||||
}
|
||||
|
||||
const cloned = new InviteService({
|
||||
manager,
|
||||
inviteRepository: this.inviteRepository_,
|
||||
userService: this.userService_,
|
||||
userRepository: this.userRepo_,
|
||||
eventBusService: this.eventBus_,
|
||||
})
|
||||
|
||||
cloned.transactionManager_ = manager
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
generateToken(data): string {
|
||||
if (config.jwtSecret) {
|
||||
return jwt.sign(data, config.jwtSecret)
|
||||
}
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Please configure JwtSecret"
|
||||
)
|
||||
}
|
||||
|
||||
async list(selector, config = {}): Promise<ListInvite[]> {
|
||||
const inviteRepo = this.manager_.getCustomRepository(InviteRepository)
|
||||
|
||||
const query = this.buildQuery_(selector, config)
|
||||
|
||||
return await inviteRepo.find(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an account_user.
|
||||
* @param {string} user - user emails
|
||||
* @param {string} role - role to assign to the user
|
||||
* @param {number} validDuration - role to assign to the user
|
||||
* @return {Promise} the result of create
|
||||
*/
|
||||
async create(
|
||||
user: string,
|
||||
role: UserRoles,
|
||||
validDuration = DEFAULT_VALID_DURATION
|
||||
): Promise<void> {
|
||||
return await this.atomicPhase_(async (manager) => {
|
||||
const inviteRepository =
|
||||
this.manager_.getCustomRepository(InviteRepository)
|
||||
|
||||
const userRepo = this.manager_.getCustomRepository(UserRepository)
|
||||
|
||||
const userEntity = await userRepo.findOne({
|
||||
where: { email: user },
|
||||
})
|
||||
|
||||
if (userEntity) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Can't invite a user with an existing account"
|
||||
)
|
||||
}
|
||||
|
||||
let invite = await inviteRepository.findOne({
|
||||
where: { user_email: user },
|
||||
})
|
||||
// if user is trying to send another invite for the same account + email, but with a different role
|
||||
// then change the role on the invite as long as the invite has not been accepted yet
|
||||
if (invite && !invite.accepted && invite.role !== role) {
|
||||
invite.role = role
|
||||
|
||||
invite = await inviteRepository.save(invite)
|
||||
} else if (!invite) {
|
||||
// if no invite is found, create a new one
|
||||
const created = await inviteRepository.create({
|
||||
role,
|
||||
token: "",
|
||||
user_email: user,
|
||||
})
|
||||
|
||||
invite = await inviteRepository.save(created)
|
||||
}
|
||||
|
||||
invite.token = this.generateToken({
|
||||
invite_id: invite.id,
|
||||
role,
|
||||
user_email: user,
|
||||
})
|
||||
|
||||
invite.expires_at = new Date()
|
||||
invite.expires_at.setMilliseconds(
|
||||
invite.expires_at.getMilliseconds() + validDuration
|
||||
)
|
||||
|
||||
invite = await inviteRepository.save(invite)
|
||||
|
||||
await this.eventBus_
|
||||
.withTransaction(manager)
|
||||
.emit(InviteService.Events.CREATED, {
|
||||
id: invite.id,
|
||||
token: invite.token,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an invite from a given user id.
|
||||
* @param {string} inviteId - the id of the invite to delete. Must be
|
||||
* castable as an ObjectId
|
||||
* @return {Promise} the result of the delete operation.
|
||||
*/
|
||||
async delete(inviteId): Promise<void> {
|
||||
return this.atomicPhase_(async (manager) => {
|
||||
const inviteRepo: InviteRepository =
|
||||
manager.getCustomRepository(InviteRepository)
|
||||
|
||||
// Should not fail, if invite does not exist, since delete is idempotent
|
||||
const invite = await inviteRepo.findOne({ where: { id: inviteId } })
|
||||
|
||||
if (!invite) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
await inviteRepo.delete({ id: invite.id })
|
||||
|
||||
return Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async accept(token, user_): Promise<User> {
|
||||
let decoded
|
||||
try {
|
||||
decoded = this.verifyToken(token)
|
||||
} catch (err) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Token is not valid"
|
||||
)
|
||||
}
|
||||
|
||||
const { invite_id, user_email } = decoded
|
||||
|
||||
return this.atomicPhase_(async (m) => {
|
||||
const userRepo = m.getCustomRepository(this.userRepo_)
|
||||
const inviteRepo: InviteRepository = m.getCustomRepository(
|
||||
this.inviteRepository_
|
||||
)
|
||||
|
||||
const invite = await inviteRepo.findOne({ where: { id: invite_id } })
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
invite?.user_email !== user_email ||
|
||||
new Date() > invite.expires_at
|
||||
) {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, `Invalid invite`)
|
||||
}
|
||||
|
||||
const exists = await userRepo.findOne({
|
||||
where: { email: user_email.toLowerCase() },
|
||||
select: ["id"],
|
||||
})
|
||||
|
||||
if (exists) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"User already joined"
|
||||
)
|
||||
}
|
||||
|
||||
// use the email of the user who actually accepted the invite
|
||||
const user = await this.userService_.withTransaction(m).create(
|
||||
{
|
||||
email: invite.user_email,
|
||||
role: invite.role,
|
||||
first_name: user_.first_name,
|
||||
last_name: user_.last_name,
|
||||
},
|
||||
user_.password
|
||||
)
|
||||
|
||||
await inviteRepo.delete({ id: invite.id })
|
||||
|
||||
return user
|
||||
}, "SERIALIZABLE")
|
||||
}
|
||||
|
||||
verifyToken(token): JwtPayload | string {
|
||||
if (config.jwtSecret) {
|
||||
return jwt.verify(token, config.jwtSecret)
|
||||
}
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Please configure JwtSecret"
|
||||
)
|
||||
}
|
||||
|
||||
async resend(id): Promise<any> {
|
||||
const inviteRepo = this.manager_.getCustomRepository(InviteRepository)
|
||||
|
||||
const invite = await inviteRepo.findOne({ id })
|
||||
|
||||
if (!invite) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Invite doesn't exist`
|
||||
)
|
||||
}
|
||||
|
||||
invite.token = this.generateToken({
|
||||
invite_id: invite.id,
|
||||
role: invite.role,
|
||||
user_email: invite.user_email,
|
||||
})
|
||||
|
||||
invite.expires_at = new Date()
|
||||
invite.expires_at.setDate(invite.expires_at.getDate() + 7)
|
||||
|
||||
await inviteRepo.save(invite)
|
||||
|
||||
await this.eventBus_
|
||||
.withTransaction(this.manager_)
|
||||
.emit(InviteService.Events.CREATED, {
|
||||
id: invite.id,
|
||||
token: invite.token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default InviteService
|
||||
@@ -82,11 +82,12 @@ class UserService extends BaseService {
|
||||
|
||||
/**
|
||||
* @param {FilterableUserProps} selector - the query object for find
|
||||
* @param {Object} config - the configuration object for the query
|
||||
* @return {Promise} the result of the find operation
|
||||
*/
|
||||
async list(selector: FilterableUserProps): Promise<User[]> {
|
||||
async list(selector: FilterableUserProps, config = {}): Promise<User[]> {
|
||||
const userRepo = this.manager_.getCustomRepository(this.userRepository_)
|
||||
return userRepo.find({ where: selector })
|
||||
return userRepo.find(this.buildQuery_(selector, config))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,11 +309,14 @@ class UserService extends BaseService {
|
||||
* @return {string} the generated JSON web token
|
||||
*/
|
||||
async generateResetPasswordToken(userId: string): Promise<string> {
|
||||
const user = await this.retrieve(userId)
|
||||
const user = await this.retrieve(userId, {
|
||||
select: ["id", "email", "password_hash"],
|
||||
})
|
||||
const secret = user.password_hash
|
||||
const expiry = Math.floor(Date.now() / 1000) + 60 * 15
|
||||
const payload = { user_id: user.id, exp: expiry }
|
||||
const payload = { user_id: user.id, email: user.email, exp: expiry }
|
||||
const token = jwt.sign(payload, secret)
|
||||
|
||||
// Notify subscribers
|
||||
this.eventBus_.emit(UserService.Events.PASSWORD_RESET, {
|
||||
email: user.email,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Invite } from "../models/invite"
|
||||
|
||||
export type ListInvite = Omit<Invite, "beforeInsert"> & {
|
||||
token: string
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from "../models/user"
|
||||
import { User, UserRoles } from "../models/user"
|
||||
import { PartialPick } from "./common"
|
||||
|
||||
export interface CreateUserInput {
|
||||
@@ -7,6 +7,7 @@ export interface CreateUserInput {
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
api_token?: string
|
||||
role?: UserRoles
|
||||
metadata?: JSON
|
||||
}
|
||||
|
||||
@@ -16,9 +17,16 @@ export interface UpdateUserInput {
|
||||
last_name?: string
|
||||
readonly password_hash?: string
|
||||
api_token?: string
|
||||
role?: UserRoles
|
||||
metadata?: JSON
|
||||
}
|
||||
|
||||
export enum UserRole {
|
||||
MEMBER = "member",
|
||||
ADMIN = "admin",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export type FilterableUserProps = PartialPick<
|
||||
User,
|
||||
| "email"
|
||||
|
||||
Reference in New Issue
Block a user