xforge · tài liệu FE

Dựng khung CMS #

Hướng dẫn từng bước dựng khung của CMS / admin: client SDK, đăng nhập, chọn store, menu, điều hướng theo key, quyền, gói, realtime, lỗi, ngôn ngữ. Màn hình bên trong khung (danh sách, form của module) ở Màn hình chung từ mô tả — khung chỉ cần biết mở màn hình nào cho mỗi key.

Trang này là hướng dẫn ráp nối. Hợp đồng (tên route, field, mã lỗi) nằm ở Phần chung và CMS / admin — khi hai bên khác nhau, hợp đồng đúng. Ví dụ viết bằng TypeScript thuần, không phụ thuộc framework: ghép vào React / Vue / Svelte theo cách của bạn.

1. Khung gồm những gì #

Thứ tự dựng khuyên dùng: §2 → §3 → §4 → §5 → §6 → §7 → §8 → §9 → §10 → §11 → §12. Mỗi bước chạy được trước khi sang bước sau.

2. SDK và cấu hình #

  1. Tải thư mục SDK (Tải SDK) — api.ts + runtime.ts (hai file import lẫn nhau), ĐÚNG phiên bản server đang chạy. Không sửa tay; bản server mới → tải lại.
  2. Server mở CORS cho domain của CMS, hoặc CMS chạy trên tên miền đã gán cho khách (Phần chung §1.1).
  3. Biết database:
    • CMS chạy trên tên miền đã gán cho một database → không cần gửi x-database (server tự chọn theo tên miền).
    • Một CMS phục vụ nhiều khách → hỏi mã ở màn đăng nhập (hoặc lấy từ đường dẫn / tên miền con) và gửi x-database ở MỌI request.
// config.ts
export const API_URL = "https://api.example.com"; // không có "/" cuối
export const DATABASE_FROM_HOST = true; // tên miền đã gán database?

3. Client và trạng thái #

3.1. Một client cho cả ứng dụng #

// api.ts
import { type SessionStore, type Tokens, XforgeClient } from "./sdk/api.ts";
import { API_URL } from "./config.ts";
import { state } from "./state.ts";

const SESSION = "xforge.session";
const session: SessionStore = {
  get: () =>
    JSON.parse(localStorage.getItem(SESSION) ?? "null") as Tokens | null,
  set: (t) =>
    t
      ? localStorage.setItem(SESSION, JSON.stringify(t))
      : localStorage.removeItem(SESSION),
};

export const api = new XforgeClient({
  baseUrl: API_URL,
  session,
  // Gọi trước MỖI request: đổi store / ngôn ngữ chỉ cần đổi state.
  headers: () => ({
    ...(state.database ? { "x-database": state.database } : {}),
    ...(state.store ? { "x-store": state.store } : {}),
    ...(state.lang ? { "x-lang": state.lang } : {}),
  }),
  idempotency: true,
  onSessionExpired: () => signOutLocally("expired"),
  deviceTokens: {
    get: (k) => localStorage.getItem(`xforge.device.${k}`),
    set: (k, t) =>
      t
        ? localStorage.setItem(`xforge.device.${k}`, t)
        : localStorage.removeItem(`xforge.device.${k}`),
  },
});

export { session };

3.2. Trạng thái ứng dụng #

Giữ một chỗ (store của framework, signal, context…). Tối thiểu:

// state.ts
import type { XforgeClient } from "./sdk/api.ts";

type Data<M extends keyof XforgeClient> = XforgeClient[M] extends
  (...a: never[]) => Promise<{ data: infer D }> ? D : never;

export const state = {
  database: undefined as string | undefined,
  store: localStorage.getItem("xforge.store") ?? undefined,
  /** Ngôn ngữ giao diện + nội dung gửi qua `x-lang` (§10). */
  lang: localStorage.getItem("xforge.lang") ?? undefined,
  me: null as Data<"getMe"> | null,
  stores: [] as Data<"listMyStores">,
  menu: [] as Data<"listMenu">,
  plan: null as Data<"getStorePlan"> | null,
};

export function setStore(code: string) {
  state.store = code;
  localStorage.setItem("xforge.store", code);
}

Data<"getMe"> lấy kiểu data thẳng từ SDK — server thêm field thì kiểu tự có. Code phải BỎ QUA field lạ (hợp đồng v1 chỉ thêm, không bớt).

4. Đăng nhập #

Màn đăng nhập KHÔNG cần token. Trước khi vẽ, đọc cấu hình công khai để biết có "Quên mật khẩu" không, độ dài mật khẩu tối thiểu (CMS §1.7):

const { data: pub } = await api.getPublicConfigs();
const policy = pub["core.auth_policy"] as
  | { passwordMinLength?: number; passwordReset?: boolean }
  | undefined;

4.1. Mật khẩu → (chọn store) → (MFA) → token #

Luồng đầy đủ: CMS §1.1. Viết thành một máy trạng thái nhỏ:

import { isApiError } from "./sdk/api.ts";

type Step =
  | { kind: "password" }
  | { kind: "store"; stores: { code: string; name: string }[] }
  | { kind: "mfa"; ticket: string; methods: string[]; sms: boolean }
  | { kind: "done" };

let pending: { login: string; password: string } | null = null; // chỉ trong bộ nhớ

async function submitPassword(login: string, password: string): Promise<Step> {
  pending = { login, password };
  try {
    const { data } = await api.login({ body: { login, password } });
    if ("mfaRequired" in data && data.mfaRequired) {
      return {
        kind: "mfa",
        ticket: data.ticket,
        methods: data.methods,
        sms: data.sms,
      };
    }
    return await finishLogin(data);
  } catch (e) {
    if (isApiError(e, "store_required")) {
      return { kind: "store", stores: e.details.stores ?? [] };
    }
    throw e; // invalid_credentials, account_locked… → §9
  }
}

async function chooseStore(code: string): Promise<Step> {
  setStore(code); // header x-store cho lần login sau
  return await submitPassword(pending!.login, pending!.password);
}

async function submitMfa(ticket: string, code: string, remember: boolean) {
  const { data } = await api.verifyMfa({
    body: { ticket, code, rememberDevice: remember },
  });
  return await finishLogin(data);
}

async function finishLogin(data: {
  accessToken: string;
  refreshToken: string | null;
  store?: string | null;
}): Promise<Step> {
  pending = null; // xoá mật khẩu khỏi bộ nhớ
  session.set({
    accessToken: data.accessToken,
    refreshToken: data.refreshToken,
  });
  if (data.store) setStore(data.store);
  await enterApp(); // §5
  return { kind: "done" };
}

Kiểu thật của login / verifyMfa xem trong api.ts — đoạn trên rút gọn để dễ đọc. Màn MFA:

4.2. Passkey, quên mật khẩu, xác minh email #

URL của hai trang link trong thư do người vận hành server cấu hình — báo họ đường thật của CMS.

5. Vào ứng dụng (sau khi có token) #

Một hàm duy nhất, gọi sau đăng nhập, khi mở lại tab (đã có session), và sau khi đổi store:

export async function enterApp(): Promise<void> {
  const { data: me } = await api.getMe();
  state.me = me;

  // 1. Cổng bắt buộc — token lúc này chỉ gọi được vài route (CMS §1.8).
  if (me.passwordChangeRequired) return router.go("/change-password");
  if (me.mfaEnrollmentRequired) return router.go("/mfa-setup");

  // 2. Store + ngôn ngữ.
  if (me.store) setStore(me.store);
  state.lang ??= me.lang ?? me.defaultLang ?? undefined;

  // 3. Dữ liệu khung — song song.
  const [stores, menu, plan] = await Promise.all([
    api.listMyStores(),
    api.listMenu(),
    api.getStorePlan(),
  ]);
  state.stores = stores.data;
  state.menu = menu.data;
  state.plan = plan.data;

  // 4. Realtime + màn hình đang mở.
  openRealtime(); // §8
  router.go(router.pendingTarget() ?? "/");
}

6. Chọn và đổi store #

export async function switchStore(code: string): Promise<void> {
  closeRealtime();
  setStore(code); // header x-store đổi → lần gọi kế tiếp SDK tự switchStore
  screenCache.clearMembership(); // §7.3: mô tả màn hình vẫn dùng được, menu thì không
  await enterApp();
}

7. Menu và điều hướng theo key #

7.1. Vẽ menu #

listMenu() đã lọc sẵn: module đã bật ở store, người dùng có quyền, gói có tính năng; đã xếp theo group → order → key; label đã dịch theo x-lang (CMS §3).

const GROUP_LABEL: Record<string, string> = {
  catalog: "Danh mục",
  inventory: "Kho",
  sales: "Bán hàng",
  // group lạ → hiện chính mã, đừng giấu mục
};
const ICON: Record<string, string> = { tag: "🏷", "clipboard-list": "📋" };

function menuTree(items: typeof state.menu) {
  const groups = new Map<string, typeof items>();
  for (const it of items) {
    const g = it.group ?? "";
    groups.set(g, [...(groups.get(g) ?? []), it]);
  }
  return [...groups].map(([g, list]) => ({
    title: g ? GROUP_LABEL[g] ?? g : "Khác",
    items: list.map((it) => ({
      key: it.key,
      label: it.label,
      icon: (it.icon && ICON[it.icon]) ?? "•",
      href: `/s/${encodeURIComponent(it.key)}`,
    })),
  }));
}

7.2. Một route cho mọi màn hình #

/s/:key        màn hình của mục menu
/s/:key/:id    mở thẳng một bản ghi (link từ thông báo, push, chia sẻ)
/go?store=&key=&id=   cửa vào từ thông báo / Web Push: đổi store nếu cần → /s/:key/:id

Chọn màn hình cho key theo thứ tự:

import { CustomScreens } from "./screens/registry.ts"; // màn tay theo key

type Resolved =
  | { kind: "custom"; component: unknown }
  | { kind: "generic"; key: string; revision: string }
  | { kind: "unsupported"; reason: "not-in-menu" | "no-screen" };

export function resolveScreen(key: string): Resolved {
  const item = state.menu.find((m) => m.key === key);
  if (!item) return { kind: "unsupported", reason: "not-in-menu" };
  // 1. Màn tay của bạn thắng (màn đặc thù, hoặc muốn đẹp hơn bản chung).
  const custom = CustomScreens[key];
  if (custom) return { kind: "custom", component: custom };
  // 2. Module mô tả màn hình → màn hình chung.
  if (item.screen && item.screen.type !== "custom") {
    return { kind: "generic", key, revision: item.screen.revision };
  }
  // 3. Không ai vẽ được.
  return { kind: "unsupported", reason: "no-screen" };
}

7.3. Khi menu đổi #

Đọc lại listMenu() khi: nhận core.menu_changed, mỗi ready của luồng realtime (§8), sau khi đổi store. Sau khi đọc lại:

  1. Vẽ lại menu.
  2. Màn hình đang mở có key không còn trong menu → về trang chủ.
  3. screen.revision của mục đang mở khác bản đã cache → tải lại mô tả (Màn hình chung §2).

7.4. Module của cửa hàng (bật / tắt theo store) #

Menu đã tự lọc module bị tắt ở mọi tầng (nhà cung cấp và admin của khách) — khung KHÔNG cần biết trước có module nào. Việc thêm duy nhất là một màn tự viết "Module của cửa hàng" cho người có core.manage_store_modules: công tắc theo reason của listCurrentStoreModules(), 409 store_module_in_use thì hỏi lại rồi gửi cả nhóm, module có thể từ chối khi còn việc dở (vd payments còn khoản thu đang chờ — 409 payment_intents_pending). Chi tiết: CMS §3.2. Tắt xong, người đang thao tác gọi lại listMenu() ngay; người khác nhận core.menu_changed.

8. Realtime: một luồng cho cả ứng dụng #

import { eventStream } from "./sdk/runtime.ts";

let live: { close(): void } | null = null;

export function openRealtime() {
  live?.close();
  live = eventStream((lastEventId) => api.streamMyEvents({ lastEventId }), {
    // Mỗi lần (nối lại) xong: sự kiện chỉ là gợi ý — đọc lại thứ đang hiển thị.
    onOpen: () => {
      reloadMenu();
      refreshNotificationBadge();
      currentScreen()?.refresh();
    },
    onEvent: ({ event, data }) => {
      if (event === "core.menu_changed") reloadMenu();
      else if (event === "core.notification") refreshNotificationBadge();
      else currentScreen()?.onEvent(event, data); // `<module>.<topic>` — tham chiếu
    },
    onError: () => {/* 401 / 403: phiên chết — onSessionExpired lo */},
  });
}

export function closeRealtime() {
  live?.close();
  live = null;
}

async function reloadMenu() {
  state.menu = (await api.listMenu()).data;
  // §7.3: vẽ lại, đá khỏi màn hình mất quyền, so revision
}

9. Xử lý lỗi chung #

Một chỗ bắt lỗi chưa màn hình nào xử lý. Rẽ nhánh theo code, hiện message (server đã dịch); danh sách đầy đủ: Mã lỗi, giải thích: Phần chung §1.3.

import { ApiError, isApiError } from "./sdk/api.ts";

export function handleError(e: unknown): void {
  if (!(e instanceof ApiError)) return toast("Mất kết nối, thử lại sau.");
  switch (e.code) {
    case "password_change_required":
      return router.go("/change-password");
    case "mfa_enrollment_required":
      return router.go("/mfa-setup");
    case "module_not_enabled":
    case "module_not_installed":
    case "feature_not_in_plan":
    case "menu_entry_not_found":
      void reloadMenu();
      router.go("/");
      return toast(e.message);
    case "subscription_expired":
      return router.go("/plan"); // màn gói + hoá đơn vẫn gọi được
    case "database_maintenance":
    case "rate_limited":
      return retryLater(e); // details.retryAfter giây
    case "account_disabled":
    case "store_inactive":
      return signOutLocally(e.code);
  }
  if (e.status >= 500) {
    // x-request-id giúp đội vận hành tra log — đọc từ response nếu bạn tự giữ.
    return toast(`${e.message} (mã lỗi máy chủ)`);
  }
  toast(e.message);
}

10. Ngôn ngữ #

Hai thứ khác nhau (Phần chung §3):

Đổi ngôn ngữ:

export async function setLang(lang: string) {
  state.lang = lang;
  localStorage.setItem("xforge.lang", lang);
  state.menu = (await api.listMenu()).data; // nhãn menu đổi theo
  screenCache.dropLanguage(); // mô tả màn hình cache theo ngôn ngữ
  currentScreen()?.refresh();
}

Danh sách ngôn ngữ chọn được: languages của store (getMe() / listMyStores()).

11. Đăng xuất #

export async function signOut() {
  const t = session.get();
  if (t?.refreshToken) {
    await api.logout({ body: { refreshToken: t.refreshToken } }).catch(
      () => {},
    );
  }
  signOutLocally("logout");
}

export function signOutLocally(_reason: string) {
  closeRealtime();
  session.set(null);
  screenCache.clear();
  Object.assign(state, { me: null, menu: [], plan: null, stores: [] });
  router.go("/login");
}

Giữ xforge.device.* (ghi nhớ thiết bị) — đó là chủ ý của người dùng.

12. Quyền và gói: ẩn nút #

Server luôn kiểm; ẩn nút chỉ để giao diện gọn. Một hàm cho cả màn hình tay lẫn màn hình chung (CMS §3):

import { OPERATION_PERMISSIONS } from "./sdk/api.ts";

const has = (p: string) =>
  !!state.me && (state.me.permissions.includes(p as never) ||
    state.me.conditionalPermissions.includes(p as never));

/** Màn tay: theo tên method SDK. */
export const can = (op: keyof typeof OPERATION_PERMISSIONS) =>
  !!state.me?.superuser || OPERATION_PERMISSIONS[op].every(has);

/** Màn hình chung: theo `operations[id]` của mô tả (method SDK chưa có). */
export const canOperation = (
  op: { permissions: string[]; features: string[] },
) =>
  (!!state.me?.superuser || op.permissions.every(has)) &&
  (state.plan?.features == null ||
    op.features.every((f) => state.plan!.features!.includes(f as never)));

13. Checklist khung #

Nguồn: content/guides/cms-frame.md