From d1b8f4b50b372b6d8dc29a630ae6568b5e5053de Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Wed, 8 Dec 2021 10:15:22 +0100 Subject: [PATCH] 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 Co-authored-by: olivermrbl --- .../admin/__snapshots__/auth.js.snap | 1 + .../admin/__snapshots__/invite.js.snap | 30 ++ .../admin/__snapshots__/user.js.snap | 76 ++++ .../api/__tests__/admin/invite.js | 394 ++++++++++++++++++ integration-tests/api/__tests__/admin/user.js | 356 ++++++++++++++++ .../store/__snapshots__/auth.js.snap | 2 +- integration-tests/api/__tests__/store/auth.js | 2 +- integration-tests/api/helpers/admin-seeder.js | 1 + integration-tests/api/helpers/user-seeder.js | 53 +++ packages/medusa/src/api/routes/admin/index.js | 10 +- .../admin/invites/__tests__/accept-invite.js | 38 ++ .../admin/invites/__tests__/create-invite.js | 29 ++ .../admin/invites/__tests__/resend-invite.js | 31 ++ .../api/routes/admin/invites/accept-invite.ts | 75 ++++ .../api/routes/admin/invites/create-invite.ts | 46 ++ .../api/routes/admin/invites/delete-invite.ts | 28 ++ .../src/api/routes/admin/invites/index.ts | 45 ++ .../api/routes/admin/invites/list-invites.ts | 28 ++ .../api/routes/admin/invites/resend-invite.ts | 24 ++ .../src/api/routes/admin/orders/index.ts | 2 +- .../api/routes/admin/store/update-store.ts | 10 + .../users/__tests__/reset-password-token.js | 5 - .../admin/users/__tests__/reset-password.js | 4 +- .../admin/users/__tests__/update-user.js | 6 +- .../src/api/routes/admin/users/create-user.ts | 25 +- .../src/api/routes/admin/users/index.ts | 18 +- .../api/routes/admin/users/reset-password.ts | 62 ++- .../src/api/routes/admin/users/update-user.ts | 21 +- packages/medusa/src/index.js | 1 + .../1633512755401-extended_user_api.ts | 58 +++ packages/medusa/src/models/invite.ts | 63 +++ packages/medusa/src/models/store.ts | 3 + packages/medusa/src/models/user.ts | 16 +- packages/medusa/src/repositories/invite.ts | 5 + .../medusa/src/services/__mocks__/invite.js | 23 + .../medusa/src/services/__mocks__/user.js | 2 +- .../medusa/src/services/__tests__/invite.js | 219 ++++++++++ packages/medusa/src/services/invite.ts | 291 +++++++++++++ packages/medusa/src/services/user.ts | 12 +- packages/medusa/src/types/invites.ts | 5 + packages/medusa/src/types/user.ts | 10 +- 41 files changed, 2081 insertions(+), 49 deletions(-) create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/invite.js.snap create mode 100644 integration-tests/api/__tests__/admin/__snapshots__/user.js.snap create mode 100644 integration-tests/api/__tests__/admin/invite.js create mode 100644 integration-tests/api/__tests__/admin/user.js create mode 100644 integration-tests/api/helpers/user-seeder.js create mode 100644 packages/medusa/src/api/routes/admin/invites/__tests__/accept-invite.js create mode 100644 packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js create mode 100644 packages/medusa/src/api/routes/admin/invites/__tests__/resend-invite.js create mode 100644 packages/medusa/src/api/routes/admin/invites/accept-invite.ts create mode 100644 packages/medusa/src/api/routes/admin/invites/create-invite.ts create mode 100644 packages/medusa/src/api/routes/admin/invites/delete-invite.ts create mode 100644 packages/medusa/src/api/routes/admin/invites/index.ts create mode 100644 packages/medusa/src/api/routes/admin/invites/list-invites.ts create mode 100644 packages/medusa/src/api/routes/admin/invites/resend-invite.ts create mode 100644 packages/medusa/src/migrations/1633512755401-extended_user_api.ts create mode 100644 packages/medusa/src/models/invite.ts create mode 100644 packages/medusa/src/repositories/invite.ts create mode 100644 packages/medusa/src/services/__mocks__/invite.js create mode 100644 packages/medusa/src/services/__tests__/invite.js create mode 100644 packages/medusa/src/services/invite.ts create mode 100644 packages/medusa/src/types/invites.ts diff --git a/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap index 0824e3c74c..6943e2454f 100644 --- a/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap +++ b/integration-tests/api/__tests__/admin/__snapshots__/auth.js.snap @@ -10,6 +10,7 @@ Object { "id": "admin_user", "last_name": null, "metadata": null, + "role": "admin", "updated_at": Any, } `; diff --git a/integration-tests/api/__tests__/admin/__snapshots__/invite.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/invite.js.snap new file mode 100644 index 0000000000..d4f7f72fbb --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/invite.js.snap @@ -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, + "deleted_at": null, + "expires_at": Any, + "id": "memberInvite", + "metadata": null, + "role": "member", + "token": Any, + "updated_at": Any, + "user_email": "invite-member@test.com", + }, + Object { + "accepted": false, + "created_at": Any, + "deleted_at": null, + "expires_at": Any, + "id": "adminInvite", + "metadata": null, + "role": "admin", + "token": Any, + "updated_at": Any, + "user_email": "invite-admin@test.com", + }, +] +`; diff --git a/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap b/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap new file mode 100644 index 0000000000..d476d4bd01 --- /dev/null +++ b/integration-tests/api/__tests__/admin/__snapshots__/user.js.snap @@ -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, + "deleted_at": null, + "email": "admin@medusa.js", + "first_name": null, + "id": "admin_user", + "last_name": null, + "metadata": null, + "role": "admin", + "updated_at": Any, + }, + Object { + "api_token": null, + "created_at": Any, + "deleted_at": null, + "email": "member@test.com", + "first_name": "member", + "id": "member-user", + "last_name": "user", + "metadata": null, + "role": "member", + "updated_at": Any, + }, +] +`; + +exports[`/admin/users GET /admin/users returns user by id 1`] = ` +Object { + "api_token": "test_token", + "created_at": Any, + "deleted_at": null, + "email": "admin@medusa.js", + "first_name": null, + "id": "admin_user", + "last_name": null, + "metadata": null, + "role": "admin", + "updated_at": Any, +} +`; + +exports[`/admin/users POST /admin/users creates a user 1`] = ` +Object { + "api_token": null, + "created_at": Any, + "deleted_at": null, + "email": "test@test123.com", + "first_name": null, + "id": StringMatching /\\^usr_\\*/, + "last_name": null, + "metadata": null, + "role": "member", + "updated_at": Any, +} +`; + +exports[`/admin/users POST /admin/users updates a user 1`] = ` +Object { + "api_token": null, + "created_at": Any, + "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, +} +`; diff --git a/integration-tests/api/__tests__/admin/invite.js b/integration-tests/api/__tests__/admin/invite.js new file mode 100644 index 0000000000..d4f0189fdf --- /dev/null +++ b/integration-tests/api/__tests__/admin/invite.js @@ -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 })]) + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/admin/user.js b/integration-tests/api/__tests__/admin/user.js new file mode 100644 index 0000000000..5baab404e4 --- /dev/null +++ b/integration-tests/api/__tests__/admin/user.js @@ -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 })]) + ) + }) + }) +}) diff --git a/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap b/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap index ad2c9beab7..0cad033c53 100644 --- a/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap +++ b/integration-tests/api/__tests__/store/__snapshots__/auth.js.snap @@ -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, diff --git a/integration-tests/api/__tests__/store/auth.js b/integration-tests/api/__tests__/store/auth.js index f0cc71384c..b2599aa996 100644 --- a/integration-tests/api/__tests__/store/auth.js +++ b/integration-tests/api/__tests__/store/auth.js @@ -6,7 +6,7 @@ const { initDb, useDb } = require("../../../helpers/use-db") jest.setTimeout(30000) -describe("/admin/auth", () => { +describe("/store/auth", () => { let medusaProcess let dbConnection diff --git a/integration-tests/api/helpers/admin-seeder.js b/integration-tests/api/helpers/admin-seeder.js index 4a6fcfce82..660bad0470 100644 --- a/integration-tests/api/helpers/admin-seeder.js +++ b/integration-tests/api/helpers/admin-seeder.js @@ -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, }) diff --git a/integration-tests/api/helpers/user-seeder.js b/integration-tests/api/helpers/user-seeder.js new file mode 100644 index 0000000000..bdea83131c --- /dev/null +++ b/integration-tests/api/helpers/user-seeder.js @@ -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) +} diff --git a/packages/medusa/src/api/routes/admin/index.js b/packages/medusa/src/api/routes/admin/index.js index 1ce749b289..fe04926ada 100644 --- a/packages/medusa/src/api/routes/admin/index.js +++ b/packages/medusa/src/api/routes/admin/index.js @@ -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 } diff --git a/packages/medusa/src/api/routes/admin/invites/__tests__/accept-invite.js b/packages/medusa/src/api/routes/admin/invites/__tests__/accept-invite.js new file mode 100644 index 0000000000..a73c9d5cbf --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/__tests__/accept-invite.js @@ -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", + }) + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js b/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js new file mode 100644 index 0000000000..2ac765fe93 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/__tests__/create-invite.js @@ -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() + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/invites/__tests__/resend-invite.js b/packages/medusa/src/api/routes/admin/invites/__tests__/resend-invite.js new file mode 100644 index 0000000000..2a0f61ee59 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/__tests__/resend-invite.js @@ -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") + }) + }) +}) diff --git a/packages/medusa/src/api/routes/admin/invites/accept-invite.ts b/packages/medusa/src/api/routes/admin/invites/accept-invite.ts new file mode 100644 index 0000000000..132e518939 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/accept-invite.ts @@ -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 +} diff --git a/packages/medusa/src/api/routes/admin/invites/create-invite.ts b/packages/medusa/src/api/routes/admin/invites/create-invite.ts new file mode 100644 index 0000000000..b57d0b79d9 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/create-invite.ts @@ -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 +} diff --git a/packages/medusa/src/api/routes/admin/invites/delete-invite.ts b/packages/medusa/src/api/routes/admin/invites/delete-invite.ts new file mode 100644 index 0000000000..d48aa310fc --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/delete-invite.ts @@ -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, + }) +} diff --git a/packages/medusa/src/api/routes/admin/invites/index.ts b/packages/medusa/src/api/routes/admin/invites/index.ts new file mode 100644 index 0000000000..5a1e1d301c --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/index.ts @@ -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" diff --git a/packages/medusa/src/api/routes/admin/invites/list-invites.ts b/packages/medusa/src/api/routes/admin/invites/list-invites.ts new file mode 100644 index 0000000000..2a49c73a74 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/list-invites.ts @@ -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 }) +} diff --git a/packages/medusa/src/api/routes/admin/invites/resend-invite.ts b/packages/medusa/src/api/routes/admin/invites/resend-invite.ts new file mode 100644 index 0000000000..18702a0f22 --- /dev/null +++ b/packages/medusa/src/api/routes/admin/invites/resend-invite.ts @@ -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) +} diff --git a/packages/medusa/src/api/routes/admin/orders/index.ts b/packages/medusa/src/api/routes/admin/orders/index.ts index 0bd8cb193b..022ca63135 100644 --- a/packages/medusa/src/api/routes/admin/orders/index.ts +++ b/packages/medusa/src/api/routes/admin/orders/index.ts @@ -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() diff --git a/packages/medusa/src/api/routes/admin/store/update-store.ts b/packages/medusa/src/api/routes/admin/store/update-store.ts index 4c86222f3b..2f6b88789b 100644 --- a/packages/medusa/src/api/routes/admin/store/update-store.ts +++ b/packages/medusa/src/api/routes/admin/store/update-store.ts @@ -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 diff --git a/packages/medusa/src/api/routes/admin/users/__tests__/reset-password-token.js b/packages/medusa/src/api/routes/admin/users/__tests__/reset-password-token.js index 106a65db87..4d55b5226b 100644 --- a/packages/medusa/src/api/routes/admin/users/__tests__/reset-password-token.js +++ b/packages/medusa/src/api/routes/admin/users/__tests__/reset-password-token.js @@ -11,11 +11,6 @@ describe("POST /admin/users/password-token", () => { payload: { email: "vandijk@test.dk", }, - adminSession: { - jwt: { - userId: IdMap.getId("admin_user"), - }, - }, }) }) diff --git a/packages/medusa/src/api/routes/admin/users/__tests__/reset-password.js b/packages/medusa/src/api/routes/admin/users/__tests__/reset-password.js index 6d1a0d1a9e..1054578fb4 100644 --- a/packages/medusa/src/api/routes/admin/users/__tests__/reset-password.js +++ b/packages/medusa/src/api/routes/admin/users/__tests__/reset-password.js @@ -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" ) diff --git a/packages/medusa/src/api/routes/admin/users/__tests__/update-user.js b/packages/medusa/src/api/routes/admin/users/__tests__/update-user.js index ac58dd4317..15c461e881 100644 --- a/packages/medusa/src/api/routes/admin/users/__tests__/update-user.js +++ b/packages/medusa/src/api/routes/admin/users/__tests__/update-user.js @@ -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", } ) }) diff --git a/packages/medusa/src/api/routes/admin/users/create-user.ts b/packages/medusa/src/api/routes/admin/users/create-user.ts index 704046e9ea..d50ad88bc6 100644 --- a/packages/medusa/src/api/routes/admin/users/create-user.ts +++ b/packages/medusa/src/api/routes/admin/users/create-user.ts @@ -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 diff --git a/packages/medusa/src/api/routes/admin/users/index.ts b/packages/medusa/src/api/routes/admin/users/index.ts index 459639c977..da8e2b1883 100644 --- a/packages/medusa/src/api/routes/admin/users/index.ts +++ b/packages/medusa/src/api/routes/admin/users/index.ts @@ -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 } diff --git a/packages/medusa/src/api/routes/admin/users/reset-password.ts b/packages/medusa/src/api/routes/admin/users/reset-password.ts index d0f5a3823e..94595acaff 100644 --- a/packages/medusa/src/api/routes/admin/users/reset-password.ts +++ b/packages/medusa/src/api/routes/admin/users/reset-password.ts @@ -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 diff --git a/packages/medusa/src/api/routes/admin/users/update-user.ts b/packages/medusa/src/api/routes/admin/users/update-user.ts index a8f141b8ff..eae209dbba 100644 --- a/packages/medusa/src/api/routes/admin/users/update-user.ts +++ b/packages/medusa/src/api/routes/admin/users/update-user.ts @@ -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 } diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index f85051e82d..4198496d81 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -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" diff --git a/packages/medusa/src/migrations/1633512755401-extended_user_api.ts b/packages/medusa/src/migrations/1633512755401-extended_user_api.ts new file mode 100644 index 0000000000..8abcc42315 --- /dev/null +++ b/packages/medusa/src/migrations/1633512755401-extended_user_api.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class extendedUserApi1633512755401 implements MigrationInterface { + name = "extendedUserApi1633512755401" + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"` + ) + } +} diff --git a/packages/medusa/src/models/invite.ts b/packages/medusa/src/models/invite.ts new file mode 100644 index 0000000000..0e0f0b1c83 --- /dev/null +++ b/packages/medusa/src/models/invite.ts @@ -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}` + } +} diff --git a/packages/medusa/src/models/store.ts b/packages/medusa/src/models/store.ts index d00540cb34..802373266b 100644 --- a/packages/medusa/src/models/store.ts +++ b/packages/medusa/src/models/store.ts @@ -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 diff --git a/packages/medusa/src/models/user.ts b/packages/medusa/src/models/user.ts index 8dba94cfac..a19b0888ff 100644 --- a/packages/medusa/src/models/user.ts +++ b/packages/medusa/src/models/user.ts @@ -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 diff --git a/packages/medusa/src/repositories/invite.ts b/packages/medusa/src/repositories/invite.ts new file mode 100644 index 0000000000..85f023a09b --- /dev/null +++ b/packages/medusa/src/repositories/invite.ts @@ -0,0 +1,5 @@ +import { EntityRepository, Repository } from "typeorm" +import { Invite } from "../models/invite" + +@EntityRepository(Invite) +export class InviteRepository extends Repository {} diff --git a/packages/medusa/src/services/__mocks__/invite.js b/packages/medusa/src/services/__mocks__/invite.js new file mode 100644 index 0000000000..e1aab5cb6c --- /dev/null +++ b/packages/medusa/src/services/__mocks__/invite.js @@ -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 diff --git a/packages/medusa/src/services/__mocks__/user.js b/packages/medusa/src/services/__mocks__/user.js index d733a498b8..968e714551 100644 --- a/packages/medusa/src/services/__mocks__/user.js +++ b/packages/medusa/src/services/__mocks__/user.js @@ -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) } diff --git a/packages/medusa/src/services/__tests__/invite.js b/packages/medusa/src/services/__tests__/invite.js new file mode 100644 index 0000000000..a375a8f356 --- /dev/null +++ b/packages/medusa/src/services/__tests__/invite.js @@ -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", + }) + }) + }) +}) diff --git a/packages/medusa/src/services/invite.ts b/packages/medusa/src/services/invite.ts new file mode 100644 index 0000000000..27f491c0bf --- /dev/null +++ b/packages/medusa/src/services/invite.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 diff --git a/packages/medusa/src/services/user.ts b/packages/medusa/src/services/user.ts index 81682a3c1d..733934eca1 100644 --- a/packages/medusa/src/services/user.ts +++ b/packages/medusa/src/services/user.ts @@ -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 { + async list(selector: FilterableUserProps, config = {}): Promise { 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 { - 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, diff --git a/packages/medusa/src/types/invites.ts b/packages/medusa/src/types/invites.ts new file mode 100644 index 0000000000..b908e21c3c --- /dev/null +++ b/packages/medusa/src/types/invites.ts @@ -0,0 +1,5 @@ +import { Invite } from "../models/invite" + +export type ListInvite = Omit & { + token: string +} diff --git a/packages/medusa/src/types/user.ts b/packages/medusa/src/types/user.ts index ba137a2ef4..495b1ccd46 100644 --- a/packages/medusa/src/types/user.ts +++ b/packages/medusa/src/types/user.ts @@ -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"