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:
@@ -10,6 +10,7 @@ Object {
|
||||
"id": "admin_user",
|
||||
"last_name": null,
|
||||
"metadata": null,
|
||||
"role": "admin",
|
||||
"updated_at": Any<String>,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -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>,
|
||||
}
|
||||
`;
|
||||
394
integration-tests/api/__tests__/admin/invite.js
Normal file
394
integration-tests/api/__tests__/admin/invite.js
Normal 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 })])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
356
integration-tests/api/__tests__/admin/user.js
Normal file
356
integration-tests/api/__tests__/admin/user.js
Normal 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 })])
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>,
|
||||
|
||||
@@ -6,7 +6,7 @@ const { initDb, useDb } = require("../../../helpers/use-db")
|
||||
|
||||
jest.setTimeout(30000)
|
||||
|
||||
describe("/admin/auth", () => {
|
||||
describe("/store/auth", () => {
|
||||
let medusaProcess
|
||||
let dbConnection
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
53
integration-tests/api/helpers/user-seeder.js
Normal file
53
integration-tests/api/helpers/user-seeder.js
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
45
packages/medusa/src/api/routes/admin/invites/index.ts
Normal file
45
packages/medusa/src/api/routes/admin/invites/index.ts
Normal 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"
|
||||
28
packages/medusa/src/api/routes/admin/invites/list-invites.ts
Normal file
28
packages/medusa/src/api/routes/admin/invites/list-invites.ts
Normal 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 })
|
||||
}
|
||||
@@ -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"`
|
||||
)
|
||||
}
|
||||
}
|
||||
63
packages/medusa/src/models/invite.ts
Normal file
63
packages/medusa/src/models/invite.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
5
packages/medusa/src/repositories/invite.ts
Normal file
5
packages/medusa/src/repositories/invite.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { EntityRepository, Repository } from "typeorm"
|
||||
import { Invite } from "../models/invite"
|
||||
|
||||
@EntityRepository(Invite)
|
||||
export class InviteRepository extends Repository<Invite> {}
|
||||
23
packages/medusa/src/services/__mocks__/invite.js
Normal file
23
packages/medusa/src/services/__mocks__/invite.js
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
219
packages/medusa/src/services/__tests__/invite.js
Normal file
219
packages/medusa/src/services/__tests__/invite.js
Normal 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",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
291
packages/medusa/src/services/invite.ts
Normal file
291
packages/medusa/src/services/invite.ts
Normal 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
|
||||
@@ -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,
|
||||
|
||||
5
packages/medusa/src/types/invites.ts
Normal file
5
packages/medusa/src/types/invites.ts
Normal file
@@ -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