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:
Philip Korsholm
2021-12-08 10:15:22 +01:00
committed by GitHub
parent 5cb09184d4
commit d1b8f4b50b
41 changed files with 2081 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ Object {
"id": "admin_user",
"last_name": null,
"metadata": null,
"role": "admin",
"updated_at": Any<String>,
}
`;

View File

@@ -0,0 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/admin/invites GET /admin/users lists invites 1`] = `
Array [
Object {
"accepted": false,
"created_at": Any<String>,
"deleted_at": null,
"expires_at": Any<String>,
"id": "memberInvite",
"metadata": null,
"role": "member",
"token": Any<String>,
"updated_at": Any<String>,
"user_email": "invite-member@test.com",
},
Object {
"accepted": false,
"created_at": Any<String>,
"deleted_at": null,
"expires_at": Any<String>,
"id": "adminInvite",
"metadata": null,
"role": "admin",
"token": Any<String>,
"updated_at": Any<String>,
"user_email": "invite-admin@test.com",
},
]
`;

View File

@@ -0,0 +1,76 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/admin/users GET /admin/users lists users 1`] = `
Array [
Object {
"api_token": "test_token",
"created_at": Any<String>,
"deleted_at": null,
"email": "admin@medusa.js",
"first_name": null,
"id": "admin_user",
"last_name": null,
"metadata": null,
"role": "admin",
"updated_at": Any<String>,
},
Object {
"api_token": null,
"created_at": Any<String>,
"deleted_at": null,
"email": "member@test.com",
"first_name": "member",
"id": "member-user",
"last_name": "user",
"metadata": null,
"role": "member",
"updated_at": Any<String>,
},
]
`;
exports[`/admin/users GET /admin/users returns user by id 1`] = `
Object {
"api_token": "test_token",
"created_at": Any<String>,
"deleted_at": null,
"email": "admin@medusa.js",
"first_name": null,
"id": "admin_user",
"last_name": null,
"metadata": null,
"role": "admin",
"updated_at": Any<String>,
}
`;
exports[`/admin/users POST /admin/users creates a user 1`] = `
Object {
"api_token": null,
"created_at": Any<String>,
"deleted_at": null,
"email": "test@test123.com",
"first_name": null,
"id": StringMatching /\\^usr_\\*/,
"last_name": null,
"metadata": null,
"role": "member",
"updated_at": Any<String>,
}
`;
exports[`/admin/users POST /admin/users updates a user 1`] = `
Object {
"api_token": null,
"created_at": Any<String>,
"deleted_at": null,
"email": "member@test.com",
"first_name": "karl",
"id": "member-user",
"last_name": "user",
"metadata": null,
"password_hash": null,
"role": "member",
"updated_at": Any<String>,
}
`;

View File

@@ -0,0 +1,394 @@
const jwt = require("jsonwebtoken")
const path = require("path")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const userSeeder = require("../../helpers/user-seeder")
const adminSeeder = require("../../helpers/admin-seeder")
jest.setTimeout(30000)
describe("/admin/invites", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("GET /admin/users", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("lists invites", async () => {
const api = useApi()
const response = await api
.get("/admin/invites", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.invites).toMatchSnapshot([
{
id: "memberInvite",
user_email: "invite-member@test.com",
role: "member",
accepted: false,
token: expect.any(String),
expires_at: expect.any(String),
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: "adminInvite",
user_email: "invite-admin@test.com",
role: "admin",
accepted: false,
token: expect.any(String),
created_at: expect.any(String),
expires_at: expect.any(String),
updated_at: expect.any(String),
},
])
})
})
describe("POST /admin/invites", () => {
let user
beforeEach(async () => {
const api = useApi()
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
const response = await api
.post(
"/admin/users",
{
email: "test@forgottenPassword.com",
role: "member",
password: "test123453",
},
{
headers: { Authorization: "Bearer test_token" },
}
)
.catch((err) => console.log(err))
user = response.data.user
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
describe("Invitations", () => {
it("create an invite with the specified emails and role", async () => {
const api = useApi()
const payload = {
user: "test@medusa-commerce.com",
role: "admin",
}
const createReponse = await api
.post("/admin/invites", payload, {
headers: { Authorization: "Bearer test_token" },
})
.catch((err) => console.log(err))
const response = await api
.get("/admin/invites", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
expect(createReponse.status).toEqual(200)
expect(response.data.invites).toEqual(
expect.arrayContaining([
expect.objectContaining({ user_email: payload.user }),
])
)
expect(response.status).toEqual(200)
})
it("updates invite with new role", async () => {
const api = useApi()
const payload = {
user: "invite-member@test.com",
role: "admin",
}
const createReponse = await api
.post("/admin/invites", payload, {
headers: { Authorization: "Bearer test_token" },
})
.catch((err) => console.log(err))
const response = await api
.get("/admin/invites", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
expect(createReponse.status).toEqual(200)
expect(response.data.invites).toEqual(
expect.arrayContaining([
expect.objectContaining({
user_email: payload.user,
role: "admin",
}),
])
)
expect(response.status).toEqual(200)
})
it("resends invite", async () => {
const api = useApi()
const id = "memberInvite"
const resendResponse = await api
.post(
`/admin/invites/${id}/resend`,
{},
{
headers: { Authorization: "Bearer test_token" },
}
)
.catch((err) => console.log(err))
expect(resendResponse.status).toEqual(200)
})
it("creates a user successfully when accepting an invite (unauthorized endpoint)", async () => {
const api = useApi()
const inviteResponse = await api.get("/admin/invites", {
headers: { Authorization: "Bearer test_token" },
})
const { token, ...rest } = inviteResponse.data.invites[0]
const user = {
first_name: "test",
last_name: "testesen",
password: "supersecret",
}
const payload = { token, user }
const createResponse = await api
.post("/admin/invites/accept", payload)
.catch((err) => console.log(err))
const userResponse = await api.get("/admin/users", {
headers: { Authorization: "Bearer test_token" },
})
const newUser = userResponse.data.users.find(
(usr) => usr.email == rest.user_email
)
expect(newUser).toEqual(expect.objectContaining({ role: rest.role }))
expect(createResponse.status).toEqual(200)
})
it("creates a user successfully with new role after updating invite (unauthorized endpoint)", async () => {
const api = useApi()
const inviteResponse = await api.get("/admin/invites", {
headers: { Authorization: "Bearer test_token" },
})
const { token, ...rest } = inviteResponse.data.invites.find(
(inv) => inv.role === "member"
)
const user = {
first_name: "test",
last_name: "testesen",
password: "supersecret",
}
const updatePayload = {
user: rest.user_email,
role: "admin",
}
const updateResponse = await api
.post("/admin/invites", updatePayload, {
headers: { Authorization: "Bearer test_token" },
})
.catch((err) => console.log(err))
const payload = { token, user }
const createResponse = await api.post("/admin/invites/accept", payload)
const userResponse = await api.get("/admin/users", {
headers: { Authorization: "Bearer test_token" },
})
const newUser = userResponse.data.users.find(
(usr) => usr.email == rest.user_email
)
expect(newUser).toEqual(expect.objectContaining({ role: "admin" }))
expect(updateResponse.status).toEqual(200)
expect(createResponse.status).toEqual(200)
})
it("Fails to accept an invite given an invalid token (unauthorized endpoint)", async () => {
expect.assertions(2)
const api = useApi()
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnZpdGVfaWQiOiJpbnZpdGVfMDFGSFFWNlpBOERRRlgySjM3UVo5SjZTOTAiLCJyb2xlIjoiYWRtaW4iLCJ1c2VyX2VtYWlsIjoic2ZAc2RmLmNvbSIsImlhdCI6MTYzMzk2NDAyMCwiZXhwIjoxNjM0NTY4ODIwfQ.ZsmDvunBxhRW1iRqvfEfWixJLZ1zZVzaEYST38Vbl00"
await api
.post("/admin/invites/accept", {
token,
user: {
first_name: "test",
last_name: "testesen",
password: "supersecret",
},
})
.catch((err) => {
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual("Token is not valid")
})
})
it("fails to accept an already accepted invite (unauthorized endpoint)", async () => {
expect.assertions(4)
const api = useApi()
const inviteResponse = await api.get("/admin/invites", {
headers: { Authorization: "Bearer test_token" },
})
const { token } = inviteResponse.data.invites[0]
const user = {
first_name: "test",
last_name: "testesen",
password: "supersecret",
}
const payload = { token, user }
const createResponse = await api.post("/admin/invites/accept", payload)
const secondPayload = {
user: {
first_name: "testesens",
last_name: "test",
password: "testesens",
},
token,
}
await api.post("/admin/invites/accept", secondPayload).catch((err) => {
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual("Invalid invite")
expect(err.response.data.type).toEqual("invalid_data")
})
expect(createResponse.status).toEqual(200)
})
})
})
describe("DELETE /admin/users", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("/admin/invites Deletes an invite", async () => {
const api = useApi()
const inviteId = "memberInvite"
const invitesBeforeDeleteRequest = await api.get("/admin/invites", {
headers: {
Authorization: "Bearer test_token",
},
})
const invitesBeforeDelete = invitesBeforeDeleteRequest.data.invites
const response = await api
.delete(`/admin/invites/${inviteId}`, {
headers: { Authorization: "Bearer test_token" },
})
.catch((err) => console.log(err))
const invitesAfterDeleteRequest = await api.get("/admin/invites", {
headers: {
Authorization: "Bearer test_token",
},
})
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: inviteId,
object: "invite",
deleted: true,
})
const invitesAfterDelete = invitesAfterDeleteRequest.data.invites
expect(invitesAfterDelete.length).toEqual(invitesBeforeDelete.length - 1)
expect(invitesBeforeDelete).toEqual(
expect.arrayContaining([expect.objectContaining({ id: inviteId })])
)
expect(invitesAfterDelete).toEqual(
expect.not.arrayContaining([expect.objectContaining({ id: inviteId })])
)
})
})
})

View File

@@ -0,0 +1,356 @@
const jwt = require("jsonwebtoken")
const path = require("path")
const setupServer = require("../../../helpers/setup-server")
const { useApi } = require("../../../helpers/use-api")
const { initDb, useDb } = require("../../../helpers/use-db")
const userSeeder = require("../../helpers/user-seeder")
const adminSeeder = require("../../helpers/admin-seeder")
jest.setTimeout(30000)
describe("/admin/users", () => {
let medusaProcess
let dbConnection
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd })
medusaProcess = await setupServer({ cwd })
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
medusaProcess.kill()
})
describe("GET /admin/users", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("returns user by id", async () => {
const api = useApi()
const response = await api.get("/admin/users/admin_user", {
headers: { Authorization: "Bearer test_token " },
})
expect(response.status).toEqual(200)
expect(response.data.user).toMatchSnapshot({
id: "admin_user",
email: "admin@medusa.js",
api_token: "test_token",
role: "admin",
created_at: expect.any(String),
updated_at: expect.any(String),
})
})
it("lists users", async () => {
const api = useApi()
const response = await api
.get("/admin/users", {
headers: {
Authorization: "Bearer test_token",
},
})
.catch((err) => {
console.log(err)
})
expect(response.status).toEqual(200)
expect(response.data.users).toMatchSnapshot([
{
id: "admin_user",
email: "admin@medusa.js",
api_token: "test_token",
role: "admin",
created_at: expect.any(String),
updated_at: expect.any(String),
},
{
id: "member-user",
role: "member",
email: "member@test.com",
first_name: "member",
last_name: "user",
created_at: expect.any(String),
updated_at: expect.any(String),
},
])
})
})
describe("POST /admin/users", () => {
let user
beforeEach(async () => {
const api = useApi()
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
const response = await api
.post(
"/admin/users",
{
email: "test@forgottenPassword.com",
role: "member",
password: "test123453",
},
{
headers: { Authorization: "Bearer test_token" },
}
)
.catch((err) => console.log(err))
user = response.data.user
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("creates a user", async () => {
const api = useApi()
const payload = {
email: "test@test123.com",
role: "member",
password: "test123",
}
const response = await api
.post("/admin/users", payload, {
headers: { Authorization: "Bearer test_token" },
})
.catch((err) => console.log(err))
expect(response.status).toEqual(200)
expect(response.data.user).toMatchSnapshot({
id: expect.stringMatching(/^usr_*/),
created_at: expect.any(String),
updated_at: expect.any(String),
role: "member",
email: "test@test123.com",
})
})
it("updates a user", async () => {
const api = useApi()
const updateResponse = await api
.post(
"/admin/users/member-user",
{ first_name: "karl" },
{
headers: { Authorization: "Bearer test_token " },
}
)
.catch((err) => console.log(err.response.data.message))
expect(updateResponse.status).toEqual(200)
expect(updateResponse.data.user).toMatchSnapshot({
id: "member-user",
created_at: expect.any(String),
updated_at: expect.any(String),
role: "member",
email: "member@test.com",
first_name: "karl",
last_name: "user",
})
})
describe("Password reset", () => {
it("Doesn't fail when generating password reset token (unauthorized endpoint)", async () => {
const api = useApi()
const resp = await api
.post("/admin/users/password-token", {
email: user.email,
})
.catch((err) => {
console.log(err)
})
expect(resp.data).toEqual("")
expect(resp.status).toEqual(204)
})
it("Resets the password given a valid token (unauthorized endpoint)", async () => {
const api = useApi()
const expiry = Math.floor(Date.now() / 1000) + 60 * 15
const dbUser = await dbConnection.manager.query(
`SELECT * FROM public.user WHERE email = '${user.email}'`
)
const token_payload = {
user_id: user.id,
email: user.email,
exp: expiry,
}
const token = jwt.sign(token_payload, dbUser[0].password_hash)
const result = await api
.post("/admin/users/reset-password", {
token,
email: "test@forgottenpassword.com",
password: "new password",
})
.catch((err) => console.log(err))
const loginResult = await api.post("admin/auth", {
email: "test@forgottenpassword.com",
password: "new password",
})
expect(result.status).toEqual(200)
expect(result.data.user).toEqual(
expect.objectContaining({
email: user.email,
role: user.role,
})
)
expect(result.data.user.password_hash).toEqual(undefined)
expect(loginResult.status).toEqual(200)
expect(loginResult.data.user).toEqual(
expect.objectContaining({
email: user.email,
role: user.role,
})
)
})
it("Resets the password given a valid token without including email(unauthorized endpoint)", async () => {
const api = useApi()
const expiry = Math.floor(Date.now() / 1000) + 60 * 15
const dbUser = await dbConnection.manager.query(
`SELECT * FROM public.user WHERE email = '${user.email}'`
)
const token_payload = {
user_id: user.id,
email: user.email,
exp: expiry,
}
const token = jwt.sign(token_payload, dbUser[0].password_hash)
const result = await api
.post("/admin/users/reset-password", {
token,
password: "new password",
})
.catch((err) => console.log(err.response.data.message))
const loginResult = await api.post("admin/auth", {
email: user.email,
password: "new password",
})
expect(result.status).toEqual(200)
expect(result.data.user).toEqual(
expect.objectContaining({
email: user.email,
role: user.role,
})
)
expect(result.data.user.password_hash).toEqual(undefined)
expect(loginResult.status).toEqual(200)
expect(loginResult.data.user).toEqual(
expect.objectContaining({
email: user.email,
role: user.role,
})
)
})
it("Fails to Reset the password given an invalid token (unauthorized endpoint)", async () => {
expect.assertions(2)
const api = useApi()
const token = "test.test.test"
await api
.post("/admin/users/reset-password", {
token,
email: "test@forgottenpassword.com",
password: "new password",
})
.catch((err) => {
expect(err.response.status).toEqual(400)
expect(err.response.data.message).toEqual("invalid token")
})
})
})
})
describe("DELETE /admin/users", () => {
beforeEach(async () => {
await adminSeeder(dbConnection)
await userSeeder(dbConnection)
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("Deletes a user", async () => {
const api = useApi()
const userId = "member-user"
const usersBeforeDeleteResponse = await api.get("/admin/users", {
headers: {
Authorization: "Bearer test_token",
},
})
const usersBeforeDelete = usersBeforeDeleteResponse.data.users
const response = await api
.delete(`/admin/users/${userId}`, {
headers: { Authorization: "Bearer test_token" },
})
.catch((err) => console.log(err))
const usersAfterDeleteResponse = await api.get("/admin/users", {
headers: {
Authorization: "Bearer test_token",
},
})
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: userId,
object: "user",
deleted: true,
})
const usersAfterDelete = usersAfterDeleteResponse.data.users
expect(usersAfterDelete.length).toEqual(usersBeforeDelete.length - 1)
expect(usersBeforeDelete).toEqual(
expect.arrayContaining([expect.objectContaining({ id: userId })])
)
expect(usersAfterDelete).toEqual(
expect.not.arrayContaining([expect.objectContaining({ id: userId })])
)
})
})
})

View File

@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/admin/auth creates store session correctly 1`] = `
exports[`/store/auth creates store session correctly 1`] = `
Object {
"billing_address_id": null,
"created_at": Any<String>,

View File

@@ -6,7 +6,7 @@ const { initDb, useDb } = require("../../../helpers/use-db")
jest.setTimeout(30000)
describe("/admin/auth", () => {
describe("/store/auth", () => {
let medusaProcess
let dbConnection

View File

@@ -11,6 +11,7 @@ module.exports = async (connection, data = {}) => {
id: "admin_user",
email: "admin@medusa.js",
api_token: "test_token",
role: "admin",
password_hash,
...data,
})

View File

@@ -0,0 +1,53 @@
const { User, Invite } = require("@medusajs/medusa")
import jwt from "jsonwebtoken"
const generateToken = (data) => {
return jwt.sign(data, "test", {
expiresIn: "7d",
})
}
const expires_at = new Date()
expires_at.setDate(expires_at.getDate() + 8)
module.exports = async (connection, data = {}) => {
const manager = connection.manager
const memberUser = await manager.create(User, {
id: "member-user",
role: "member",
email: "member@test.com",
first_name: "member",
last_name: "user",
})
await manager.save(memberUser)
const memberInvite = await manager.create(Invite, {
id: "memberInvite",
user_email: "invite-member@test.com",
role: "member",
token: generateToken({
invite_id: "memberInvite",
role: "member",
user_email: "invite-member@test.com",
}),
accepted: false,
expires_at: expires_at,
})
await manager.save(memberInvite)
const adminInvite = await manager.create(Invite, {
id: "adminInvite",
user_email: "invite-admin@test.com",
role: "admin",
accepted: false,
token: generateToken({
invite_id: "adminInvite",
role: "admin",
user_email: "invite-admin@test.com",
}),
expires_at: expires_at,
})
await manager.save(adminInvite)
}

View File

@@ -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
}

View File

@@ -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",
})
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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")
})
})
})

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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,
})
}

View File

@@ -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"

View File

@@ -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 })
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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

View File

@@ -11,11 +11,6 @@ describe("POST /admin/users/password-token", () => {
payload: {
email: "vandijk@test.dk",
},
adminSession: {
jwt: {
userId: IdMap.getId("admin_user"),
},
},
})
})

View File

@@ -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"
)

View File

@@ -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",
}
)
})

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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"`
)
}
}

View File

@@ -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}`
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm"
import { Invite } from "../models/invite"
@EntityRepository(Invite)
export class InviteRepository extends Repository<Invite> {}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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",
})
})
})
})

View File

@@ -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

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
import { Invite } from "../models/invite"
export type ListInvite = Omit<Invite, "beforeInsert"> & {
token: string
}

View File

@@ -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"