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ì #
- Một SDK client (
XforgeClient) cho cả ứng dụng — tự lo phiên, refresh, đổi store;sessionở localStorage (dùng chung giữa các tab); headerx-database/x-store/x-langlấy từ trạng thái ứng dụng (§3). - Trạng thái ứng dụng:
database,store,me,stores,menu,plan(§3.2). - Router:
- công khai:
/login,/forgot-password,/reset-password,/verify-email(§4); - cổng bắt buộc:
/change-password,/mfa-setup(§5); - màn hình:
/s/:key,/s/:key/:id,/go(cửa vào từ thông báo) (§7).
- công khai:
- Menu từ
listMenu(); mỗikeymở theo thứ tự:- màn hình tay của bạn (đăng ký theo
key); - màn hình chung khi mục có
screen(Màn hình chung); - "chưa hỗ trợ".
- màn hình tay của bạn (đăng ký theo
- Một luồng realtime cho cả ứng dụng:
core.menu_changed, thông báo, sự kiện của module (§8). - Xử lý lỗi chung: phiên, cổng bắt buộc, quyền, gói, bảo trì, lỗi máy chủ (§9).
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 #
- 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. - 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).
- 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.
- CMS chạy trên tên miền đã gán cho một database → không cần gửi
// 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 };
sessionphải DÙNG CHUNG giữa các tab (localStorage): hai tab cùng refresh thì tab chậm đọc lại token tab kia vừa lưu (CMS §2.2).- KHÔNG tự viết interceptor refresh / đổi store — SDK đã làm (Phần chung §1.2).
idempotency: true: mọi lời gọi ghi mangIdempotency-Key; lần SDK tự chạy lại dùng đúng khoá cũ (Phần chung §1.2.1).
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:
methodscótotp→ ô mã 6 số;recovery_code→ link "Dùng mã khôi phục" (verifyMfa({ body: { ticket, recoveryCode } }));passkey→ nút passkey.sms: true→ nút "Gửi mã qua SMS" (sendMfaSmsCode), rồiverifyMfa({ body: { ticket, smsCode } }).- Vé hết hạn sau 5 phút → về bước mật khẩu.
- Ô "Ghi nhớ thiết bị này" =
rememberDevice: true; SDK tự lưu và tự gửi token thiết bị ở lần sau (CMS §1.10).
4.2. Passkey, quên mật khẩu, xác minh email #
- Nút "Đăng nhập bằng passkey":
CMS §1.2 — xong
cũng gọi
finishLogin. - Trang
/forgot-passwordvà/reset-password?token=…: CMS §1.5. Xoátokenkhỏi thanh địa chỉ (history.replaceState) ngay khi đọc, không ghi vào analytics. - Trang
/verify-email?token=…: CMS §1.6.
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() ?? "/");
}
- Mở lại tab:
session.get()có token → gọienterApp()thẳng; 401 mà refresh thất bại thì SDK gọionSessionExpired→ về/login. passwordChangeRequired/mfaEnrollmentRequired: KHÔNG tải menu — mọi route khác trả 403. Đổi mật khẩu / đăng ký yếu tố xong, server trả cặp token mới →session.set(...)NGAY rồi gọi lạienterApp()(CMS §1.8).getStorePlan:features: null= mọi tính năng;state(active/grace/expired) để hiện banner gia hạn (CMS §5).
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();
}
- Bộ chọn store dùng
state.stores(listMyStores):code,name,timezone,currency,defaultLang,languages. - KHÔNG gọi
switchStoretay — SDK gặp 401token_store_mismatchsẽ tự đổi và chạy lại (CMS §1.3). - Quyền, menu, gói đều THEO STORE: đọc lại cả ba (
enterAppđã làm). - "Hôm nay", khoảng ngày: theo
timezonecủa store, không theo máy người dùng (Phần chung §2).
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)}`,
})),
}));
}
- Giữ nguyên thứ tự server trả;
group/iconlạ không làm vỡ menu. - Mọi
keycó thể có ở bản server này: Danh mục menu.
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" };
}
not-in-menu: người dùng mở link cũ, vừa mất quyền, module vừa tắt → về trang chủ, báo ngắn "Màn hình không còn dùng được".no-screen: mục có trong menu mà chưa có màn hình → trang "Chưa hỗ trợ màn hình này" (kèmkeyđể báo đội CMS). Đừng ẩn mục menu: người dùng biết tính năng có, chỉ CMS chưa làm.screen.type === "custom": module nói rõ màn hình phải làm tay — không có màn tay thì cũng làno-screen.- Màn hình chung: Màn hình chung từ mô tả.
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:
- Vẽ lại menu.
- Màn hình đang mở có
keykhông còn trong menu → về trang chủ. screen.revisioncủ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
}
- MỘT luồng cho cả ứng dụng (tối đa 5 / người). Nhiều tab: cân nhắc
BroadcastChannelđể một tab giữ luồng. - Đổi store / đăng xuất:
closeRealtime()rồi mở lại. - Hộp thư thông báo (
listMyNotifications):link: { key, id }→/s/:key/:id. Web Push: CMS §6.
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);
}
- Lỗi THEO Ô (
validation_failed,duplicate_value,error.at) do form tự xử lý — xem Màn hình chung §6. forbidden(details.missing) /condition_not_met: báo, không đăng xuất.version_conflict: hỏi người dùng tải lại bản mới.
10. Ngôn ngữ #
Hai thứ khác nhau (Phần chung §3):
- Chữ giao diện của khung (nút "Lưu", "Huỷ"…): i18n của CMS.
- Chữ do server trả: nhãn menu, nhãn màn hình chung,
messagecủa lỗi, nội dung dịch được — theox-lang.
Đổ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)));
- Quyền có điều kiện (
conditionalPermissions): HIỆN nút; server có thể trả 403condition_not_mettheo từng bản ghi. - Quyền theo store: đổi store →
getMe()lại (enterAppđã làm).
13. Checklist khung #
- Một
XforgeClient,sessionở localStorage,idempotency: true, không interceptor tự viết. - Đăng nhập đủ nhánh:
store_required, MFA (TOTP / mã khôi phục / SMS / passkey), ghi nhớ thiết bị, khoá đăng nhập (account_locked, đếm ngược). - Cổng
passwordChangeRequired/mfaEnrollmentRequiredtrước khi tải menu. - Đổi store đọc lại
getMe,listMenu,getStorePlan, mở lại realtime. - Menu từ
listMenu(), không ghi cứng;group/iconlạ không vỡ. -
/s/:keychọn màn tay → màn hình chung → "chưa hỗ trợ". - Màn "Module của cửa hàng" (§7.4) khi có
core.manage_store_modules. - Một luồng realtime;
core.menu_changedvà mỗireadyđọc lại menu. - Lỗi chung theo
code, hiệnmessage; 5xx có mã tra log. - Đổi ngôn ngữ đọc lại menu + mô tả màn hình.
- Trước khi ghép server thật: CMS §12.