текст перекладу
У попередній частині цієї серії ми налаштували базову структуру для багат vendor-маркетплейсу за допомогою Medusa.js 2.0. Ми вивчали створення продавців і роботу з кількома списками товарів. У цій частині ми зануримось глибше в реалізацію функціональності Super Admin, яка надає привілейованим користувачам (як власникам сайту або операторам платформи) розширене керування та нагляд.
Зокрема, ми розглянемо:
- API для створення Super Admin
- Надання Super Admin доступу до всіх товарів та замовлень
- Можливість видавати себе за будь-якого продавця
Почнемо!
Крок 1: Створення API для створення Super Admin
Super Admin — це особливий обліковий запис користувача, який має повний доступ до функціональності платформи. Нам потрібен безпечний і простий спосіб створити такий обліковий запис. Для цього ми відкриємо захищений кінцевий пункт для створення Super Admin.
- Створення нового маршруту
src/api/create-super-store/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import {
CreateStoreInput,
createStoreWorkflow,
} from "../../workflows/create-store";
import { SUPER_ADMIN_STORE_NAME } from "src/constants";
// curl -X POST http://localhost:9000/create-super-store -d '{ "email":"[email protected]", "password": "123"}' -H 'Content-Type: application/json' -H 'Authorization: 123'
export async function POST(
req: MedusaRequest,
res: MedusaResponse
): Promise {
const { result } = await createStoreWorkflow(req.scope).run({
input: {
...req.body,
is_super_admin: true,
store_name: SUPER_ADMIN_STORE_NAME,
},
});
res.json({
message: "Ok",
});
}
src/constants.ts
export const SUPER_ADMIN_STORE_NAME = "SUPER ADMIN STORE";
2. Захистіть маршрут за допомогою API-ключа
Цей маршрут буде захищений за допомогою API-ключа, визначеного в змінній середовища API_KEY, щоб тільки ви як системний адміністратор мали права створювати супер-адмінів:
src/api/middlewares.ts
{
method: ["POST"],
matcher: "/create-super-store",
middlewares: [checkApiKey],
},
src/api/middlewares/check-api-key.ts
import type {
MedusaNextFunction,
MedusaRequest,
MedusaResponse,
} from "@medusajs/framework/http";
export async function checkApiKey(req: MedusaRequest, res: MedusaResponse, next: MedusaNextFunction) {
const apiKey = req.headers.authorization;
if (apiKey !== process.env.API_KEY) {
return res.status(403).json({ error: "Wrong api key" });
}
next();
};
3. Оновіть createStoreWorkflow
Основна ідея тут полягає в тому, щоб НЕ пов'язувати користувача з магазином для Super Admin. Це допоможе реалізувати повний доступ до всіх товарів та замовлень для Super Admin.
текст перекладу
Також записи Store та User для Super Admin міститимуть {issuperadmin: true} у своїх метаданих.
src/workflows/create-store/index.ts
import {
createWorkflow,
transform,
when,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk";
import { createStoresWorkflow } from "@medusajs/medusa/core-flows";
import { linkUserToStoreStep } from "./steps/link-user-to-store";
import { createUserStep } from "./steps/create-user";
import { getSalesChannelStep } from "./steps/get-sales-channel";
export type CreateStoreInput = {
store_name: string;
// first_name: string;
// last_name: string;
email: string;
password: string;
is_super_admin?: boolean;
};
export const createStoreWorkflow = createWorkflow(
"create-store",
(input: CreateStoreInput) => {
const salesChannel = getSalesChannelStep();
const storesData = transform({ input, salesChannel }, (data) => [
{
name: data.input.store_name,
supported_currencies: [{ currency_code: "usd", is_default: true }],
default_sales_channel_id: data.salesChannel.id,
metadata: data.input.is_super_admin
? { is_super_admin: true }
: undefined,
},
]);
const stores = createStoresWorkflow.runAsStep({
input: {
stores: storesData,
},
});
const store = stores[0];
const { user, registerResponse } = createUserStep(input);
// супер-адміністратори не мають зв'язку з магазином
// тому вони можуть бачити всі продукти та замовлення
const userStoreLinkArray = when(input, (input) => {
return !input.is_super_admin;
}).then(() => {
return linkUserToStoreStep({ userId: user.id, storeId: store.id });
});
return new WorkflowResponse({
store,
user,
userStoreLinkArray,
registerResponse,
});
}
);
src/workflows/create-store/steps/create-user.ts
...
// 1. створення користувача
const user = await userService.createUsers({
...input,
metadata: input.is_super_admin ? { is_super_admin: true } : undefined,
});
...
Як майбутнє покращення, доцільно, щоб всі Super Admin (якщо їх багато) мали той самий (єдиний) запис Store.
Крок 2: Надання Super Admin доступу до всіх товарів і замовлень
Однією з головних привілеїв Super Admin є можливість бачити все, що відбувається на маркетплейсі. Це включає всі товари, представлені кожним продавцем, та всі замовлення, що були оформлені.
текст перекладу
Ідея полягає в тому, що оскільки у користувача Super Admin немає зв'язку з магазином, отже, не буде отримано store_id, і нічого не буде для фільтрації, тому всі Продукти/Замовлення будуть повернуті.
Також, коли відкривається сторінка профілю магазину, Super Admin має бачити свій власний магазин.
Щоб це реалізувати, потрібно оновити два посередники:
src/api/middlewares/add-store-id-to-filterable-fields.ts
import {
type MedusaNextFunction,
type MedusaRequest,
type MedusaResponse,
} from "@medusajs/framework/http";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
import { UserDTO } from "@medusajs/framework/types";
import { SUPER_ADMIN_STORE_NAME } from "src/constants";
export async function addStoreIdToFilterableFields(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
const loggedInUser = req.scope.resolve("loggedInUser", {
allowUnregistered: true,
}) as UserDTO;
if (!loggedInUser) {
return next();
}
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY);
const { data: users } = await query.graph({
entity: "user",
fields: ["id", "email", "store.*"],
filters: {
id: [loggedInUser.id],
},
});
const store = users[0].store;
if (!req.filterableFields) {
req.filterableFields = {};
}
if (store) {
// встановлюємо 'filterableFields', щоб потім посередник 'maybeApplyLinkFilter' обробив його
req.filterableFields["store_id"] = store.id;
// супер-адмін?
} else if (req.url.includes("/admin/stores") && req.method === "GET") {
req.filterableFields["store_name"] = SUPER_ADMIN_STORE_NAME;
}
return next();
}
src/api/middlewares/move-ids-to-query-from-filterable-fields.ts
import {
type MedusaNextFunction,
type MedusaRequest,
type MedusaResponse,
} from "@medusajs/framework/http";
export async function moveIdsToQueryFromFilterableFields(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
if (!req.filterableFields) {
return next();
}
if (req.filterableFields.id) {
// робимо це, інакше 'filterableFields' буде перезаписано в
// https://github.com/medusajs/medusa/blob/develop/packages/medusa/src/api/admin/products/middlewares.ts#L49
req.query["id"] = req.filterableFields.id as string[];
// це для /admin/stores, щоб отримати лише магазин одного користувача
} else if (req.filterableFields.store_id) {
req.query["id"] = req.filterableFields.store_id as string;
// супер-адмін?
} else if (req.filterableFields.store_name) {
req.query["name"] = req.filterableFields.store_name as string;
}
return next();
}
Крок 3: Імітація будь-якого продавця
Іноді найкращий спосіб допомогти продавцю вирішити проблему — це «поставити себе на його місце» і побачити те, що бачить він. Це і є імітація: Super Admin може увійти в систему як продавець, не знаючи його реального пароля.
1.
текст перекладу
Створення нової сторінки для Продавців
Щоб спростити процес, ми створимо нову сторінку для Продавців на панелі адміністратора, з якої ми будемо здійснювати імітацію.
src/admin/routes/merchants/page.tsx
import { defineRouteConfig } from "@medusajs/admin-sdk";
import { StatusBadge, Heading } from "@medusajs/ui";
import { useEffect, useMemo, useState } from "react";
import { Table } from "../../components/table";
import { Container } from "../../components/container";
import { Users, Snooze, UserGroup } from "@medusajs/icons";
import { useFetch, VoidQuery } from "../../../utils/queries";
import { storeImpersonation } from "../../../utils/impersonate";
type MerchantsItem = {
id: string;
store_name: string;
user_email: number;
status: "active" | "inactive";
can_impersonate: boolean;
};
type MerchantsResponse = {
merchants: MerchantsItem[];
};
type ImpersonateQuery = {
userId?: string;
};
type ImpersonateUserResponse = {
email: string;
id: string;
};
type ImpersonateResponse = {
impersionated_as: ImpersonateUserResponse;
};
const MerchantPage = () => {
const [currentPage, setCurrentPage] = useState(0);
const { data: merchantsResponse } = useFetch(
`/admin/merchants`,
[],
{}
);
const { data: impersonateResponse, refetch: impersonate } = useFetch<
ImpersonateQuery,
ImpersonateResponse
>(`/admin/impersonate`, [], {}, { runOnMount: false });
const columns = [
{
key: "store_name",
label: "Store name",
},
{
key: "user_email",
label: "Email",
},
{
key: "status",
label: "Status",
render: (value: unknown) => {
const isEnabled = value === "active";
return (
{isEnabled ? "Active" : "Inactive"}
);
},
},
];
useEffect(() => {
if (impersonateResponse?.impersionated_as) {
storeImpersonation(impersonateResponse?.impersionated_as.email);
window.location.href = "/app";
}
}, [impersonateResponse]);
const actions = useMemo(() => {
if (!merchantsResponse?.merchants) {
return [];
}
return merchantsResponse?.merchants.map((merchant) => {
const items = [
{
icon: ,
label: "Deactivate",
onClick: () => {
alert("Coming soon");
},
},
];
if (merchant.can_impersonate) {
items.push({
icon: ,
label: "Impersonate",
onClick: () => {
impersonate({ userId: merchant.id });
},
});
}
return items;
});
}, [merchantsResponse?.merchants]);
return (
Merchants
); }; export const config = defineRouteConfig({ label: "Merchants", icon: UserGroup, }); export default MerchantPage;
На цій сторінці буде список всіх продавців, з якого Super Admin може імітувати будь-якого через меню дій. Як тільки продавець буде імітований, у локальному сховищі з'явиться прапор:
src/utils/impersonate.ts
const IMPERSIONATED_AS_KEY = "IMPERSIONATED_AS";
export const storeImpersonation = (email: string) => {
localStorage.setItem(IMPERSIONATED_AS_KEY, email);
};
export const isImpersonated = () => {
return !!localStorage.getItem(IMPERSIONATED_AS_KEY);
};
2.
текст перекладу
Створення нових API маршрутів
Для реалізації функції імітації необхідно створити 3 API маршрути:
- отримання продавців
- імітація
- скидання імітації
Отримання продавців
src/api/admin/merchants/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
import { UserDTO } from "@medusajs/framework/types";
import { retrieveMerchantsWorkflow } from "src/workflows/retrieve-merchants";
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const loggedInUser = req.scope.resolve("loggedInUser") as UserDTO;
const { result } = await retrieveMerchantsWorkflow(req.scope).run({
input: {
userId: loggedInUser.id,
},
});
res.json({
merchants: result,
});
};
src/workflows/retrieve-merchants/index.ts
import {
createWorkflow,
transform,
WorkflowResponse,
} from "@medusajs/framework/workflows-sdk";
import { getStoreStep } from "../link-product-to-store/steps/get-store";
import { retrieveMerchantsStep } from "./steps/retrieve-merchants";
export type RetrieveMerchantsWorkflowInput = {
userId: string;
};
export const retrieveMerchantsWorkflow = createWorkflow(
"retrieve-merchants",
(input: RetrieveMerchantsWorkflowInput) => {
const store = getStoreStep(input.userId);
const isSuperAdmin = transform({ store }, (data) => !data.store);
const merchants = retrieveMerchantsStep({
userId: input.userId,
isSuperAdmin,
});
return new WorkflowResponse(merchants);
}
);
src/workflows/retrieve-merchants/steps/retrieve-merchants.ts
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk";
import { ContainerRegistrationKeys } from "@medusajs/framework/utils";
export type RetrieveMerchantsStepInput = {
userId: string;
isSuperAdmin: boolean;
};
export const retrieveMerchantsStep = createStep(
"retrieve-merchants",
async (
{ userId, isSuperAdmin }: RetrieveMerchantsStepInput,
{ container }
) => {
const query = container.resolve(ContainerRegistrationKeys.QUERY);
const { data: users } = await query.graph({
entity: "user",
fields: ["id", "email", "store.name"],
filters: isSuperAdmin ? {} : { id: userId },
});
const merchants = users
.filter((u) => !!u.store)
.map((u) => ({
id: u.id,
store_name: u.store.name,
user_email: u.email,
status: "active",
can_impersonate: isSuperAdmin,
}));
return new StepResponse(merchants);
}
);
Імітація
Ми просто додамо поле impersonateuserid в об'єкт сесії:
src/api/admin/impersonate/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { IUserModuleService } from "@medusajs/framework/types";
import { Modules } from "@medusajs/framework/utils";
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const query: any = req.query as any;
const userId = query.userId;
const userService = req.scope.resolve(Modules.USER);
const userToImpersonate = await userService.retrieveUser(userId);
if (!userToImpersonate) {
return res.status(404).send("User not found");
}
// Перевірка, чи має поточний користувач право на імітацію
if (!canImpersonate(userId)) {
return res.status(403).send("Forbidden");
}
req.session.impersonate_user_id = userToImpersonate.id;
res.status(200).json({
impersionated_as: {
email: userToImpersonate.email,
id: userToImpersonate.id,
},
});
};
const canImpersonate = (userId: string) => {
// TODO: додати власну валідацію
return true;
};
Скидання імітації
src/api/admin/impersonate-reset/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework";
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
delete req.session.impersonate_user_id;
res.status(200).send();
};
текст перекладу
3.
текст перекладу
Обмеження доступу до маршрутів тільки для Super Admin
Ми хочемо обмежити доступ до маршрутів імперсонації тільки для Super Admin. Для цього ми підключимо нове посередницьке програмне забезпечення (middleware) до цих маршрутів:
src/api/middlewares/only-for-super-admin.ts
import {
type MedusaNextFunction,
type MedusaRequest,
type MedusaResponse,
} from "@medusajs/framework/http";
import { UserDTO } from "@medusajs/framework/types";
import { MedusaError } from "@medusajs/framework/utils";
export async function onlyForSuperAdmins(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
const loggedInUser = req.scope.resolve("loggedInUser", {
allowUnregistered: true,
}) as UserDTO;
if (!loggedInUser) {
throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Unauthorized");
}
const isSuperAdmin = loggedInUser.metadata?.is_super_admin;
if (!isSuperAdmin) {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not allowed");
}
return next();
}
src/api/middlewares.ts
...
{
method: ["POST"],
matcher: "/admin/impersonate",
middlewares: [onlyForSuperAdmins],
},
{
method: ["POST"],
matcher: "/admin/impersonate-reset",
middlewares: [onlyForSuperAdmins],
},
...
4. Додавання панелі імперсонації
Щоб Super Admin було зрозуміло, якому користувачеві він зараз імперсонує, ми додамо панель у головний заголовок (Header) макету. Проблема в тому, що для цього потрібно модифікувати оригінальний код Medusa Dashboard.
Щоб уникнути цього, ми застосуємо певну «хакерську» техніку, змінюючи зкомпільований код Dashboard.
- Додати скрипт патча
patch-admin.sh
# додати блок для імперсонації
sed -i '' '2738s/.*//' node_modules/@medusajs/dashboard/dist/app.mjs
sed -i '' '2739s/.*//' node_modules/@medusajs/dashboard/dist/app.mjs
sed -i '' '2740s/.*//' node_modules/@medusajs/dashboard/dist/app.mjs
sed -i '' '2738s|.*|var MainLayout=()=>{const impersonateKey="IMPERSIONATED_AS";const removeImpersonate=async()=>{localStorage.removeItem(impersonateKey);await fetch("/admin/impersonate-reset");window.location.href="/app"};const impersionatedAs=localStorage.getItem(impersonateKey);const children=[];if(impersionatedAs){children.push(jsx14("div",{className:"flex justify-between bg-ui-tag-purple-icon px-2 py-1 h-8 text-ui-fg-on-inverted",children:[jsx14("p",{children:`Impersonated as ${impersionatedAs}`}),jsx14("button",{onClick:removeImpersonate,className:"border border-ui-tag-neutral-border px-2",children:"Remove Impersonation"})]}));}children.push(jsx14(Shell,{children:jsx14(MainSidebar,{})}));return jsx14("div",{children});};|' node_modules/@medusajs/dashboard/dist/app.mjs
# # Очищення кешу vite
rm -rf node_modules/@medusajs/admin-bundler/node_modules/.vite
Зверніть увагу, що рядки 2738–2740 можуть змінитися після випуску нової версії Medusa.
- Застосувати скрипт патча
Скрипт патча має застосовуватись після кожної команди yarn
або npm install
.
package.json
"scripts": {
"postinstall": "sh patch-admin.sh",
5. Демонстрація
Сторінка Merchants
Підсумок
Повний вихідний код функціоналу Super Admin доступний у PR за посиланням https://github.com/IgorKhomenko/medusa2-marketplace-demo/pull/9
У цій частині нашої серії Medusa.js 2.0 multivendor marketplace ми ознайомилися з:
- Створенням користувача Super Admin.
- Дозволом для Super Admin переглядати всі продукти та замовлення, незалежно від того, хто є їх власником.
- Імперсонацією продавця, щоб усунути неполадки або виконати завдання від імені цього продавця.
Ці функції є важливими для будь-якої надійної платформи для ринку, де операторам платформи необхідний контроль і можливості для надання підтримки.
текст перекладу
У наступній частині
У наступному випуску ми розглянемо деякі більш складні функції, які необхідні для кожного маркетплейсу.
Залишайтеся з нами, і не забувайте повідомити в коментарях, якщо є інші теми чи функції, які ви хочете, щоб ми розглянули в майбутніх статтях!
Хочете підтримати мене? Купіть мені чашку кави https://www.buymeacoffee.com/khomenkoigor
Перекладено з: Building a multivendor marketplace with Medusa.js 2.0: Super Admin