diff --git a/.changeset/perfect-pigs-report.md b/.changeset/perfect-pigs-report.md new file mode 100644 index 0000000000..5d5e657d0e --- /dev/null +++ b/.changeset/perfect-pigs-report.md @@ -0,0 +1,6 @@ +--- +"@medusajs/medusa": patch +"@medusajs/core-flows": patch +--- + +feat: Create user account diff --git a/integration-tests/api/__tests__/admin/user.js b/integration-tests/api/__tests__/admin/user.js index 03fb2ff84e..920cd7c910 100644 --- a/integration-tests/api/__tests__/admin/user.js +++ b/integration-tests/api/__tests__/admin/user.js @@ -1,232 +1,411 @@ const jwt = require("jsonwebtoken") -const path = require("path") -const setupServer = require("../../../environment-helpers/setup-server") -const { useApi } = require("../../../environment-helpers/use-api") -const { initDb, useDb } = require("../../../environment-helpers/use-db") - -const userSeeder = require("../../../helpers/user-seeder") -const adminSeeder = require("../../../helpers/admin-seeder") +const { medusaIntegrationTestRunner } = require("medusa-test-utils") const { - simpleAnalyticsConfigFactory, -} = require("../../../factories/simple-analytics-config-factory") -const startServerWithEnvironment = - require("../../../environment-helpers/start-server-with-environment").default + createAdminUser, + adminHeaders, +} = require("../../../helpers/create-admin-user") +const { breaking } = require("../../../helpers/breaking") +const { ModuleRegistrationName } = require("@medusajs/modules-sdk") jest.setTimeout(30000) -const adminReqConfig = { - headers: { - "x-medusa-access-token": "test_token", - }, -} +let userSeeder = {} +let simpleAnalyticsConfigFactory = {} -describe("/admin/users", () => { - let medusaProcess - let dbConnection +medusaIntegrationTestRunner({ + // env: { MEDUSA_FF_MEDUSA_V2: true }, + testSuite: ({ dbConnection, getContainer, api }) => { + let container + let userModuleService - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - dbConnection = await initDb({ cwd }) - medusaProcess = await setupServer({ cwd }) - }) + beforeAll(() => { + userSeeder = require("../../../helpers/user-seeder") + simpleAnalyticsConfigFactory = require("../../../factories/simple-analytics-config-factory") + }) - afterAll(async () => { - const db = useDb() - await db.shutdown() - medusaProcess.kill() - }) - - describe("GET /admin/users", () => { beforeEach(async () => { - await adminSeeder(dbConnection) - await userSeeder(dbConnection) + container = getContainer() + + userModuleService = container.resolve(ModuleRegistrationName.USER) + + await createAdminUser(dbConnection, adminHeaders, container) }) - afterEach(async () => { - const db = useDb() - await db.teardown() - }) + describe("GET /admin/users/:id", () => { + beforeEach(async () => { + await breaking(async () => { + await userSeeder(dbConnection) + }) + }) - it("returns user by id", async () => { - const api = useApi() + it("should return user by id", async () => { + const response = await api.get("/admin/users/admin_user", adminHeaders) - const response = await api.get("/admin/users/admin_user", adminReqConfig) - - expect(response.status).toEqual(200) - expect(response.data.user).toEqual( - expect.objectContaining({ + const v1Response = { id: "admin_user", email: "admin@medusa.js", api_token: "test_token", role: "admin", created_at: expect.any(String), updated_at: expect.any(String), - }) - ) + } + + const v2Response = { + id: "admin_user", + email: "admin@medusa.js", + created_at: expect.any(String), + updated_at: expect.any(String), + } + + expect(response.status).toEqual(200) + expect(response.data.user).toEqual( + expect.objectContaining( + breaking( + () => v1Response, + () => v2Response + ) + ) + ) + }) }) - it("lists users", async () => { - const api = useApi() + describe("GET /admin/users", () => { + beforeEach(async () => { + await breaking( + async () => { + await userSeeder(dbConnection) + }, + async () => { + await userModuleService.create({ + id: "member-user", + email: "member@test.com", + first_name: "member", + last_name: "user", + }) + } + ) + }) - const response = await api - .get("/admin/users", adminReqConfig) - .catch((err) => { - console.log(err) - }) + it("should list users", async () => { + const response = await api + .get("/admin/users", adminHeaders) + .catch((err) => { + console.log(err) + }) - expect(response.status).toEqual(200) + expect(response.status).toEqual(200) - expect(response.data.users).toEqual( - expect.arrayContaining([ + const v1Response = [ expect.objectContaining({ id: "admin_user", email: "admin@medusa.js", + created_at: expect.any(String), + updated_at: expect.any(String), api_token: "test_token", role: "admin", + }), + expect.objectContaining({ + id: "member-user", + email: "member@test.com", + first_name: "member", + last_name: "user", + created_at: expect.any(String), + updated_at: expect.any(String), + role: "member", + }), + ] + + const v2Response = [ + expect.objectContaining({ + id: "admin_user", + email: "admin@medusa.js", created_at: expect.any(String), updated_at: expect.any(String), }), expect.objectContaining({ 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), }), - ]) - ) - }) + ] - it("lists users that match the free text search", async () => { - const api = useApi() - - const response = await api.get("/admin/users?q=member", adminReqConfig) - - expect(response.status).toEqual(200) - - expect(response.data.users.length).toEqual(1) - expect(response.data.users).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - 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), - }), - ]) - ) - }) - - it("orders users by created_at", async () => { - const api = useApi() - - const response = await api.get( - "/admin/users?order=created_at", - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data.users.length).toBeGreaterThan(0) - - for (let i = 0; i < response.data.users.length - 1; i++) { - const user1 = response.data.users[i] - const user2 = response.data.users[i + 1] - - const date1 = new Date(user1.created_at) - const date2 = new Date(user2.created_at) - - expect(date1.getTime()).toBeLessThanOrEqual(date2.getTime()) - } - }) - }) - - 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", - }, - adminReqConfig + expect(response.data.users).toEqual( + expect.arrayContaining( + breaking( + () => v1Response, + () => v2Response + ) + ) ) - .catch((err) => console.log(err)) + }) - user = response.data.user + // TODO: Free text search not supported in 2.0 yet + it("should list users that match the free text search", async () => { + const response = await api.get("/admin/users?q=member", adminHeaders) + + expect(response.status).toEqual(200) + + expect(response.data.users.length).toEqual(1) + expect(response.data.users).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "member-user", + email: "member@test.com", + first_name: "member", + last_name: "user", + created_at: expect.any(String), + updated_at: expect.any(String), + role: "member", + }), + ]) + ) + }) }) - afterEach(async () => { - const db = useDb() - await db.teardown() - }) + describe("POST /admin/users", () => { + let token - it("creates a user", async () => { - const api = useApi() + beforeEach(async () => { + token = await breaking( + () => null, + async () => { + const emailPassResponse = await api.post("/auth/admin/emailpass", { + email: "test@test123.com", + password: "test123", + }) - const payload = { - email: "test@test123.com", - role: "member", - password: "test123", - } + return emailPassResponse.data.token + } + ) + }) - const response = await api - .post("/admin/users", payload, adminReqConfig) - .catch((err) => console.log(err)) - - expect(response.status).toEqual(200) - expect(response.data.user).toEqual( - expect.objectContaining({ - id: expect.stringMatching(/^usr_*/), - created_at: expect.any(String), - updated_at: expect.any(String), - role: "member", + it("should create a user", async () => { + const payload = { email: "test@test123.com", - }) - ) - }) + ...breaking( + () => ({ role: "member", password: "test123" }), + () => ({}) + ), + } - it("updates a user", async () => { - const api = useApi() - - const updateResponse = await api - .post( - "/admin/users/member-user", - { first_name: "karl" }, - adminReqConfig + // In V2, the flow to create an authenticated user depends on the token or session of a previously created auth user + const headers = breaking( + () => adminHeaders, + () => { + return { + headers: { Authorization: `Bearer ${token}` }, + } + } ) - .catch((err) => console.log(err.response.data.message)) - expect(updateResponse.status).toEqual(200) - expect(updateResponse.data.user).toEqual( - expect.objectContaining({ - 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", + const response = await api + .post("/admin/users", payload, headers) + .catch((err) => console.log(err)) + + expect(response.status).toEqual(200) + expect(response.data.user).toEqual( + expect.objectContaining({ + id: expect.stringMatching( + breaking( + () => /^usr_*/, + () => /^user_*/ + ) + ), + created_at: expect.any(String), + updated_at: expect.any(String), + email: "test@test123.com", + ...breaking( + () => ({ role: "member" }), + () => ({}) + ), + }) + ) + }) + + // V2 only test + it.skip("should throw, if session/bearer auth is present for existing user", async () => { + const emailPassResponse = await api.post("/auth/admin/emailpass", { + email: "test@test123.com", + password: "test123", }) - ) + + const token = emailPassResponse.data.token + + const headers = (token) => ({ + headers: { Authorization: `Bearer ${token}` }, + }) + + // Create user + const res = await api + .post( + "/admin/users", + { + email: "test@test123.com", + }, + headers(token) + ) + .catch((err) => console.log(err)) + + const payload = { + email: "different@email.com", + } + + const { response: errorResponse } = await api + .post("/admin/users", payload, headers(res.data.token)) + .catch((err) => err) + + expect(errorResponse.status).toEqual(400) + expect(errorResponse.data.message).toEqual( + "Request carries authentication for an existing user" + ) + }) }) - describe("Password reset", () => { - it("Doesn't fail to fetch user when resetting password for an unknown email (unauthorized endpoint)", async () => { - const api = useApi() + describe("POST /admin/users/:id", () => { + beforeEach(async () => { + await breaking( + async () => { + await userSeeder(dbConnection) + }, + async () => { + await userModuleService.create([ + { + id: "member-user", + email: "member@test.com", + first_name: "member", + last_name: "user", + }, + ]) + } + ) + }) + it("should update a user", async () => { + const updateResponse = await api + .post( + "/admin/users/member-user", + { first_name: "karl" }, + adminHeaders + ) + .catch((err) => console.log(err.response.data.message)) + + expect(updateResponse.status).toEqual(200) + expect(updateResponse.data.user).toEqual( + expect.objectContaining({ + id: "member-user", + created_at: expect.any(String), + updated_at: expect.any(String), + email: "member@test.com", + first_name: "karl", + last_name: "user", + ...breaking( + () => ({ role: "member" }), + () => ({}) + ), + }) + ) + }) + }) + + describe("DELETE /admin/users", () => { + beforeEach(async () => { + await breaking( + async () => { + await userSeeder(dbConnection) + }, + async () => { + await userModuleService.create([ + { + id: "member-user", + email: "member@test.com", + first_name: "member", + last_name: "user", + }, + ]) + } + ) + }) + + it("Deletes a user", async () => { + const userId = "member-user" + + const response = await api.delete( + `/admin/users/${userId}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + id: userId, + object: "user", + deleted: true, + }) + }) + + // TODO: Migrate when analytics config is implemented in 2.0 + it.skip("Deletes a user and their analytics config", async () => { + const userId = "member-user" + + await simpleAnalyticsConfigFactory(dbConnection, { + user_id: userId, + }) + + const response = await api.delete( + `/admin/users/${userId}`, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data).toEqual({ + id: userId, + object: "user", + deleted: true, + }) + + const configs = await dbConnection.manager.query( + `SELECT * FROM public.analytics_config WHERE user_id = '${userId}'` + ) + + expect(configs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + created_at: expect.any(Date), + updated_at: expect.any(Date), + deleted_at: expect.any(Date), + id: expect.any(String), + user_id: userId, + opt_out: false, + anonymize: false, + }), + ]) + ) + }) + }) + + // TODO: Migrate when implemented in 2.0 + describe("POST /admin/users/reset-password + POST /admin/users/password-token", () => { + let user + beforeEach(async () => { + const response = await api + .post( + "/admin/users", + { + email: "test@forgottenPassword.com", + role: "member", + password: "test123453", + }, + adminHeaders + ) + .catch((err) => console.log(err)) + + user = response.data.user + }) + + it("Doesn't fail to fetch user when resetting password for an unknown email (unauthorized endpoint)", async () => { const resp = await api.post("/admin/users/password-token", { email: "test-doesnt-exist@test.com", }) @@ -235,8 +414,6 @@ describe("/admin/users", () => { }) 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, @@ -250,8 +427,6 @@ describe("/admin/users", () => { }) 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}'` @@ -296,8 +471,6 @@ describe("/admin/users", () => { }) 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}'` @@ -342,7 +515,6 @@ describe("/admin/users", () => { it("Fails to Reset the password given an invalid token (unauthorized endpoint)", async () => { expect.assertions(2) - const api = useApi() const token = "test.test.test" @@ -358,131 +530,5 @@ describe("/admin/users", () => { }) }) }) - }) - - 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", - adminReqConfig - ) - - const usersBeforeDelete = usersBeforeDeleteResponse.data.users - - const response = await api.delete(`/admin/users/${userId}`, { - headers: { "x-medusa-access-token": "test_token" }, - }) - - const usersAfterDeleteResponse = await api.get( - "/admin/users", - adminReqConfig - ) - - 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 })]) - ) - }) - }) -}) - -describe("[MEDUSA_FF_ANALYTICS] /admin/analytics-config", () => { - let medusaProcess - let dbConnection - - beforeAll(async () => { - const cwd = path.resolve(path.join(__dirname, "..", "..")) - const [process, connection] = await startServerWithEnvironment({ - cwd, - env: { MEDUSA_FF_ANALYTICS: true }, - }) - dbConnection = connection - medusaProcess = process - }) - - afterAll(async () => { - const db = useDb() - await db.shutdown() - - medusaProcess.kill() - }) - - describe("DELETE /admin/users", () => { - beforeEach(async () => { - await adminSeeder(dbConnection) - await userSeeder(dbConnection) - }) - - afterEach(async () => { - const db = useDb() - await db.teardown() - }) - - it("Deletes a user and their analytics config", async () => { - const api = useApi() - - const userId = "member-user" - - await simpleAnalyticsConfigFactory(dbConnection, { - user_id: userId, - }) - - const response = await api.delete( - `/admin/users/${userId}`, - adminReqConfig - ) - - expect(response.status).toEqual(200) - expect(response.data).toEqual({ - id: userId, - object: "user", - deleted: true, - }) - - const configs = await dbConnection.manager.query( - `SELECT * FROM public.analytics_config WHERE user_id = '${userId}'` - ) - - expect(configs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - created_at: expect.any(Date), - updated_at: expect.any(Date), - deleted_at: expect.any(Date), - id: expect.any(String), - user_id: userId, - opt_out: false, - anonymize: false, - }), - ]) - ) - }) - }) + }, }) diff --git a/packages/core-flows/src/user/workflows/create-user-account.ts b/packages/core-flows/src/user/workflows/create-user-account.ts new file mode 100644 index 0000000000..e2a93a47ce --- /dev/null +++ b/packages/core-flows/src/user/workflows/create-user-account.ts @@ -0,0 +1,31 @@ +import { CreateUserDTO, UserDTO } from "@medusajs/types" +import { + WorkflowData, + createWorkflow, + transform, +} from "@medusajs/workflows-sdk" +import { setAuthAppMetadataStep } from "../../auth/steps" +import { createUsersStep } from "../steps" + +type WorkflowInput = { + authUserId: string + userData: CreateUserDTO +} + +export const createUserAccountWorkflowId = "create-user-account" +export const createUserAccountWorkflow = createWorkflow( + createUserAccountWorkflowId, + (input: WorkflowData): WorkflowData => { + const users = createUsersStep([input.userData]) + + const user = transform(users, (users: UserDTO[]) => users[0]) + + setAuthAppMetadataStep({ + authUserId: input.authUserId, + key: "user_id", + value: user.id, + }) + + return user + } +) diff --git a/packages/core-flows/src/user/workflows/index.ts b/packages/core-flows/src/user/workflows/index.ts index 8cac98a8a6..5dd813b7a9 100644 --- a/packages/core-flows/src/user/workflows/index.ts +++ b/packages/core-flows/src/user/workflows/index.ts @@ -1,3 +1,5 @@ -export * from "./delete-users" +export * from "./create-user-account" export * from "./create-users" +export * from "./delete-users" export * from "./update-users" + diff --git a/packages/medusa/src/api-v2/admin/invites/accept/route.ts b/packages/medusa/src/api-v2/admin/invites/accept/route.ts index 3be66f52ae..de113a9a21 100644 --- a/packages/medusa/src/api-v2/admin/invites/accept/route.ts +++ b/packages/medusa/src/api-v2/admin/invites/accept/route.ts @@ -3,11 +3,10 @@ import { MedusaResponse, } from "../../../../types/routing" -import { AdminPostInvitesInviteAcceptReq } from "../validators" -import { IUserModuleService } from "@medusajs/types" -import { InviteWorkflow } from "@medusajs/types" -import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { acceptInviteWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { IUserModuleService, InviteWorkflow } from "@medusajs/types" +import { AdminPostInvitesInviteAcceptReq } from "../validators" export const POST = async ( req: AuthenticatedMedusaRequest, diff --git a/packages/medusa/src/api-v2/admin/users/middlewares.ts b/packages/medusa/src/api-v2/admin/users/middlewares.ts index 4694622926..1fcd15e911 100644 --- a/packages/medusa/src/api-v2/admin/users/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/users/middlewares.ts @@ -12,27 +12,27 @@ import { MiddlewareRoute } from "../../../types/middlewares" import { authenticate } from "../../../utils/authenticate-middleware" export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ - { - method: ["ALL"], - matcher: "/admin/users*", - middlewares: [authenticate("admin", ["bearer", "session"])], - }, { method: ["GET"], matcher: "/admin/users", middlewares: [ + authenticate("admin", ["bearer", "session"]), transformQuery(AdminGetUsersParams, QueryConfig.listTransformQueryConfig), ], }, { method: ["POST"], matcher: "/admin/users", - middlewares: [transformBody(AdminCreateUserRequest)], + middlewares: [ + authenticate("admin", ["bearer", "session"], { allowUnregistered: true }), + transformBody(AdminCreateUserRequest), + ], }, { method: ["GET"], matcher: "/admin/users/:id", middlewares: [ + authenticate("admin", ["bearer", "session"]), transformQuery( AdminGetUsersUserParams, QueryConfig.retrieveTransformQueryConfig @@ -43,6 +43,7 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ method: ["GET"], matcher: "/admin/users/me", middlewares: [ + authenticate("admin", ["bearer", "session"]), transformQuery( AdminGetUsersUserParams, QueryConfig.retrieveTransformQueryConfig @@ -52,6 +53,9 @@ export const adminUserRoutesMiddlewares: MiddlewareRoute[] = [ { method: ["POST"], matcher: "/admin/users/:id", - middlewares: [transformBody(AdminUpdateUserRequest)], + middlewares: [ + authenticate("admin", ["bearer", "session"]), + transformBody(AdminUpdateUserRequest), + ], }, ] diff --git a/packages/medusa/src/api-v2/admin/users/route.ts b/packages/medusa/src/api-v2/admin/users/route.ts index c37bd45db6..a078cb3ff2 100644 --- a/packages/medusa/src/api-v2/admin/users/route.ts +++ b/packages/medusa/src/api-v2/admin/users/route.ts @@ -1,14 +1,16 @@ +import { createUserAccountWorkflow } from "@medusajs/core-flows" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { CreateUserDTO, IAuthModuleService } from "@medusajs/types" +import { + ContainerRegistrationKeys, + MedusaError, + remoteQueryObjectFromString, +} from "@medusajs/utils" +import jwt from "jsonwebtoken" import { AuthenticatedMedusaRequest, MedusaResponse, } from "../../../types/routing" -import { - ContainerRegistrationKeys, - remoteQueryObjectFromString, -} from "@medusajs/utils" - -import { CreateUserDTO } from "@medusajs/types" -import { createUsersWorkflow } from "@medusajs/core-flows" export const GET = async ( req: AuthenticatedMedusaRequest, @@ -43,16 +45,30 @@ export const POST = async ( req: AuthenticatedMedusaRequest, res: MedusaResponse ) => { - const workflow = createUsersWorkflow(req.scope) + const authModuleService = req.scope.resolve( + ModuleRegistrationName.AUTH + ) + + // If `actor_id` is present, the request carries authentication for an existing user + if (req.auth.actor_id) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Request carries authentication for an existing user" + ) + } const input = { input: { - users: [req.validatedBody], + userData: req.validatedBody, + authUserId: req.auth.auth_user_id, }, } - const { result } = await workflow.run(input) + const { result } = await createUserAccountWorkflow(req.scope).run(input) - const [user] = result - res.status(200).json({ user }) + const { jwt_secret } = req.scope.resolve("configModule").projectConfig + const authUser = await authModuleService.retrieve(req.auth.auth_user_id) + const token = jwt.sign(authUser, jwt_secret) + + res.status(200).json({ user: result, token }) } diff --git a/packages/medusa/src/utils/authenticate-middleware.ts b/packages/medusa/src/utils/authenticate-middleware.ts index 112afb856d..327e07e987 100644 --- a/packages/medusa/src/utils/authenticate-middleware.ts +++ b/packages/medusa/src/utils/authenticate-middleware.ts @@ -187,7 +187,6 @@ const getAuthUserFromJwtToken = ( return verified as AuthUserDTO } } catch (err) { - console.error(err) return null } }