@goplusvn/core 0.1.45 → 0.1.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.45",
4
+ "version": "0.1.47",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "./assets/*": "./src/assets/*",
38
38
  "./styles/*": "./src/styles/*",
39
+ "./auth/api-handler": "./src/auth/api-handler.ts",
39
40
  "./auth/proxy-gate": "./src/auth/proxy-gate.ts",
40
41
  "./rbac/route-handlers": "./src/rbac/route-handlers.ts",
41
42
  "./rbac/permissions-version": "./src/rbac/permissions-version.ts",
@@ -54,6 +55,26 @@
54
55
  "./utils/cccd-parser": "./src/utils/cccd-parser.ts",
55
56
  "./configs/status": "./src/configs/status.ts",
56
57
  "./configs/entities": "./src/configs/entities/index.ts",
58
+ "./ui/primitives/button": "./src/ui/primitives/button.tsx",
59
+ "./ui/primitives/input": "./src/ui/primitives/input.tsx",
60
+ "./ui/primitives/switch": "./src/ui/primitives/switch.tsx",
61
+ "./ui/primitives/label": "./src/ui/primitives/label.tsx",
62
+ "./ui/primitives/dialog": "./src/ui/primitives/dialog.tsx",
63
+ "./ui/primitives/tabs": "./src/ui/primitives/tabs.tsx",
64
+ "./ui/primitives/sidebar": "./src/ui/primitives/sidebar.tsx",
65
+ "./ui/primitives/select": "./src/ui/primitives/select.tsx",
66
+ "./ui/forms/multi-select": "./src/ui/forms/multi-select.tsx",
67
+ "./ui/data-display/collapsible": "./src/ui/data-display/collapsible.tsx",
68
+ "./system/services/settings-service": "./src/system/services/settings-service.ts",
69
+ "./system/services/system-category-service": "./src/system/services/system-category-service.ts",
70
+ "./system/pages/system-settings-page": "./src/system/pages/system-settings-page.tsx",
71
+ "./system/pages/system-category-page": "./src/system/pages/system-category-page.tsx",
72
+ "./rbac/role-service": "./src/rbac/role-service.ts",
73
+ "./rbac/resource-service": "./src/rbac/resource-service.ts",
74
+ "./infrastructure/cron/cron-manager": "./src/infrastructure/cron/cron-manager.ts",
75
+ "./infrastructure/cron/types": "./src/infrastructure/cron/types.ts",
76
+ "./crud/pages/entity-crud-page": "./src/crud/pages/entity-crud-page.tsx",
77
+ "./auth/auth-service": "./src/auth/auth-service.ts",
57
78
  "./package.json": "./package.json",
58
79
  "./providers/brand-theme": "./src/providers/brand-theme.ts"
59
80
  },
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { createActionGuard, createApiHandler } from "../api-handler";
4
+
5
+ interface FakeSession {
6
+ user: { id: string };
7
+ perms: string[];
8
+ }
9
+
10
+ const makeDeps = (session: FakeSession | null) => ({
11
+ getSession: vi.fn(async () => session),
12
+ checkPermission: vi.fn(
13
+ (s: FakeSession, resource: string, action: string) =>
14
+ s.perms.includes(`${resource}:${action}`),
15
+ ),
16
+ });
17
+
18
+ const okHandler = vi.fn(async () =>
19
+ new Response(JSON.stringify({ ok: true }), { status: 200 }),
20
+ );
21
+
22
+ const req = () => new Request("http://test/api/x");
23
+
24
+ describe("createApiHandler", () => {
25
+ it("401 khi chưa đăng nhập", async () => {
26
+ const apiHandler = createApiHandler(makeDeps(null));
27
+ const route = apiHandler(okHandler);
28
+ const res = await route(req());
29
+ expect(res.status).toBe(401);
30
+ });
31
+
32
+ it("403 khi khai resource+action mà thiếu quyền", async () => {
33
+ const apiHandler = createApiHandler(
34
+ makeDeps({ user: { id: "u1" }, perms: [] }),
35
+ );
36
+ const route = apiHandler(okHandler, { resource: "customer", action: "create" });
37
+ const res = await route(req());
38
+ expect(res.status).toBe(403);
39
+ });
40
+
41
+ it("chạy handler khi đủ quyền; params mặc định cho route tĩnh", async () => {
42
+ const handler = vi.fn(async (_r: Request, ctx: any) => {
43
+ expect(await ctx.params).toEqual({});
44
+ expect(ctx.session.user.id).toBe("u1");
45
+ return new Response("ok");
46
+ });
47
+ const apiHandler = createApiHandler(
48
+ makeDeps({ user: { id: "u1" }, perms: ["customer:create"] }),
49
+ );
50
+ const route = apiHandler(handler, { resource: "customer", action: "create" });
51
+ const res = await route(req());
52
+ expect(res.status).toBe(200);
53
+ expect(handler).toHaveBeenCalledOnce();
54
+ });
55
+
56
+ it("public: bỏ qua authN, không gọi getSession", async () => {
57
+ const deps = makeDeps(null);
58
+ const apiHandler = createApiHandler(deps);
59
+ const route = apiHandler(okHandler, { public: true });
60
+ const res = await route(req());
61
+ expect(res.status).toBe(200);
62
+ expect(deps.getSession).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("assertResource được gọi lúc ĐĂNG KÝ (module load), không phải mỗi request", async () => {
66
+ const assertResource = vi.fn();
67
+ const apiHandler = createApiHandler({
68
+ ...makeDeps({ user: { id: "u1" }, perms: ["a:view"] }),
69
+ assertResource,
70
+ });
71
+ const route = apiHandler(okHandler, { resource: "a", action: "view" });
72
+ expect(assertResource).toHaveBeenCalledExactlyOnceWith("a");
73
+ await route(req());
74
+ await route(req());
75
+ expect(assertResource).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ it("wrap chạy NGOÀI→TRONG theo thứ tự mảng, quanh handler", async () => {
79
+ const order: string[] = [];
80
+ const mw = (tag: string) => async (next: () => Promise<Response>) => {
81
+ order.push(`${tag}:in`);
82
+ const r = await next();
83
+ order.push(`${tag}:out`);
84
+ return r;
85
+ };
86
+ const apiHandler = createApiHandler({
87
+ ...makeDeps({ user: { id: "u1" }, perms: [] }),
88
+ wrap: [mw("A"), mw("B")],
89
+ });
90
+ const route = apiHandler(async () => {
91
+ order.push("handler");
92
+ return new Response("ok");
93
+ });
94
+ await route(req());
95
+ expect(order).toEqual(["A:in", "B:in", "handler", "B:out", "A:out"]);
96
+ });
97
+
98
+ it("onError nhận lỗi từ handler và quyết định response", async () => {
99
+ const onError = vi.fn(async () => new Response("mapped", { status: 500 }));
100
+ const apiHandler = createApiHandler({
101
+ ...makeDeps({ user: { id: "u1" }, perms: [] }),
102
+ onError,
103
+ });
104
+ const boom = new Error("boom");
105
+ const route = apiHandler(async () => {
106
+ throw boom;
107
+ });
108
+ const res = await route(req());
109
+ expect(res.status).toBe(500);
110
+ expect(onError).toHaveBeenCalledWith(boom, expect.any(Request));
111
+ });
112
+
113
+ it("override unauthorized/forbidden (message riêng của app)", async () => {
114
+ const apiHandler = createApiHandler({
115
+ ...makeDeps(null),
116
+ unauthorized: () => new Response("custom-401", { status: 401 }),
117
+ });
118
+ const res = await apiHandler(okHandler)(req());
119
+ expect(await res.text()).toBe("custom-401");
120
+ });
121
+ });
122
+
123
+ describe("createActionGuard", () => {
124
+ it("null khi chưa đăng nhập; null khi thiếu quyền; session khi đủ", async () => {
125
+ const guardAnon = createActionGuard(makeDeps(null));
126
+ expect(await guardAnon("receipt", "view")).toBeNull();
127
+
128
+ const s = { user: { id: "u1" }, perms: ["receipt:view"] };
129
+ const guard = createActionGuard(makeDeps(s));
130
+ expect(await guard("receipt", "view")).toBe(s);
131
+ expect(await guard("receipt", "delete")).toBeNull();
132
+ });
133
+
134
+ it("action mặc định là view; assertResource chạy mỗi lần gọi", async () => {
135
+ const assertResource = vi.fn();
136
+ const s = { user: { id: "u1" }, perms: ["receipt:view"] };
137
+ const guard = createActionGuard({ ...makeDeps(s), assertResource });
138
+ expect(await guard("receipt")).toBe(s);
139
+ expect(assertResource).toHaveBeenCalledWith("receipt");
140
+ });
141
+ });
@@ -0,0 +1,141 @@
1
+ // @goerp/core/auth/api-handler — SKELETON cổng route/server-action cho app.
2
+ //
3
+ // Bằng chứng cần nó: block `getSession → 401 → checkPermission → 403` bị chép
4
+ // ~12 lần trong chính route-handlers của core, và mỗi app (vinhhoa, wu,
5
+ // thingtodo…) lại tự viết một `apiHandler` gói cùng các concern platform
6
+ // (authN, authZ + registry-assert, audit-context, branch-scope, persist lỗi).
7
+ //
8
+ // Core CHỈ giữ khung + thứ tự chạy; mọi thứ app-specific vào qua injection:
9
+ // - getSession / checkPermission: nguồn phiên + luật quyền của app.
10
+ // - assertResource: validate resource có trong Permission Registry
11
+ // (fail-closed dev, warn prod — app quyết trong callback).
12
+ // - wrap: chuỗi middleware bọc quanh handler (audit-context AsyncLocalStorage,
13
+ // branch-scope…) — core KHÔNG kéo các logic đó vào.
14
+ // - onError: mapper lỗi của app (serverError persist error_logs).
15
+ //
16
+ // Usage (app side):
17
+ // export const apiHandler = createApiHandler({
18
+ // getSession, checkPermission, assertResource,
19
+ // wrap: [withAuditContextMw, withBranchScopeMw],
20
+ // onError: (e, req) => serverError(e, req),
21
+ // })
22
+ // export const requirePermission = createActionGuard({ getSession, checkPermission, assertResource })
23
+
24
+ type MaybePromise<T> = T | Promise<T>;
25
+
26
+ export interface ApiHandlerContext<S> {
27
+ session: S;
28
+ params: Promise<Record<string, string>>;
29
+ }
30
+
31
+ export type ApiRouteHandler<S> = (
32
+ req: Request,
33
+ ctx: ApiHandlerContext<S>,
34
+ ) => Promise<Response>;
35
+
36
+ export interface ApiHandlerOptions {
37
+ /** Resource + action để authZ. Thiếu một trong hai → chỉ authN. */
38
+ resource?: string;
39
+ action?: string;
40
+ /** true → bỏ qua cả authN (endpoint public). */
41
+ public?: boolean;
42
+ }
43
+
44
+ /** Middleware bọc quanh handler — gọi next() để chạy tiếp chuỗi. */
45
+ export type ApiHandlerMiddleware<S> = (
46
+ next: () => Promise<Response>,
47
+ ctx: { req: Request; session: S | null },
48
+ ) => Promise<Response>;
49
+
50
+ export interface ApiHandlerFactoryDeps<S> {
51
+ getSession: () => MaybePromise<S | null>;
52
+ checkPermission: (session: S, resource: string, action: string) => boolean;
53
+ /** Validate resource đã khai trong registry — gọi lúc ĐĂNG KÝ handler
54
+ * (module load), không phải mỗi request. */
55
+ assertResource?: (resource: string) => void;
56
+ /** Chuỗi middleware chạy NGOÀI→TRONG theo thứ tự mảng, quanh handler. */
57
+ wrap?: ApiHandlerMiddleware<S>[];
58
+ /** Mapper lỗi của app (vd serverError). Default: JSON 500 generic. */
59
+ onError?: (error: unknown, req: Request) => MaybePromise<Response>;
60
+ /** Override response 401/403 (message riêng của app). */
61
+ unauthorized?: () => Response;
62
+ forbidden?: () => Response;
63
+ }
64
+
65
+ const json = (data: unknown, status: number) =>
66
+ new Response(JSON.stringify(data), {
67
+ status,
68
+ headers: { "content-type": "application/json" },
69
+ });
70
+
71
+ export function createApiHandler<S>(deps: ApiHandlerFactoryDeps<S>) {
72
+ const unauthorized =
73
+ deps.unauthorized ?? (() => json({ error: "Unauthorized" }, 401));
74
+ const forbidden =
75
+ deps.forbidden ??
76
+ (() => json({ error: "Bạn không có quyền thực hiện hành động này" }, 403));
77
+
78
+ return function apiHandler(
79
+ handler: ApiRouteHandler<S>,
80
+ options?: ApiHandlerOptions,
81
+ ) {
82
+ if (options?.resource) deps.assertResource?.(options.resource);
83
+
84
+ return async function route(
85
+ req: Request,
86
+ // Route tĩnh được Next gọi không có ctx.
87
+ routeCtx?: { params: Promise<Record<string, string>> },
88
+ ): Promise<Response> {
89
+ const params = routeCtx?.params ?? Promise.resolve({});
90
+ try {
91
+ if (options?.public) {
92
+ return await handler(req, { session: null as S, params });
93
+ }
94
+
95
+ const session = await deps.getSession();
96
+ if (!session) return unauthorized();
97
+
98
+ if (
99
+ options?.resource &&
100
+ options?.action &&
101
+ !deps.checkPermission(session, options.resource, options.action)
102
+ ) {
103
+ return forbidden();
104
+ }
105
+
106
+ const run = () => handler(req, { session, params });
107
+ const chain = (deps.wrap ?? []).reduceRight<() => Promise<Response>>(
108
+ (next, mw) => () => mw(next, { req, session }),
109
+ run,
110
+ );
111
+ return await chain();
112
+ } catch (error) {
113
+ if (deps.onError) return await deps.onError(error, req);
114
+ return json({ error: "Internal error" }, 500);
115
+ }
116
+ };
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Cổng cho SERVER ACTIONS — bản đối xứng của apiHandler: action trả dữ liệu
122
+ * (không phải Response) nên guard là hàm lấy-session-kèm-check-quyền, trả
123
+ * `Session` khi đủ quyền và `null` khi chưa đăng nhập/thiếu quyền.
124
+ */
125
+ export function createActionGuard<S>(
126
+ deps: Pick<
127
+ ApiHandlerFactoryDeps<S>,
128
+ "getSession" | "checkPermission" | "assertResource"
129
+ >,
130
+ ) {
131
+ return async function requirePermission(
132
+ resource: string,
133
+ action = "view",
134
+ ): Promise<S | null> {
135
+ deps.assertResource?.(resource);
136
+ const session = await deps.getSession();
137
+ if (!session) return null;
138
+ if (!deps.checkPermission(session, resource, action)) return null;
139
+ return session;
140
+ };
141
+ }
@@ -12,8 +12,6 @@ import type {
12
12
  NavigationType,
13
13
  } from "../../types";
14
14
 
15
- import { navigationsData as defaultNavigationsData } from "../../configs/data/navigations";
16
-
17
15
  import { ensureLocalizedPathname } from "../../utils";
18
16
  import { i18n } from "../../configs";
19
17
  import {
@@ -48,8 +46,9 @@ export function TopBarHeaderMenubar({
48
46
 
49
47
  const locale = (params.lang as LocaleType) || i18n.defaultLocale;
50
48
 
51
- // Use app-provided navigation, fall back to core default if not provided
52
- const navItems = navigation ?? defaultNavigationsData;
49
+ // Navigation do APP truyền vào core không bake menu demo nào nữa
50
+ // (trước 2026-07 default 781 dòng menu mẫu GoEat ship trong npm).
51
+ const navItems = navigation ?? [];
53
52
 
54
53
  // SAP Fiori Logic: Show first 5 items, group rest under "More"
55
54
  const MAX_VISIBLE_ITEMS = 5;
@@ -46,6 +46,8 @@ const STATUS_MAP: Record<string, { color: string; dotClass: string; label: strin
46
46
  accounting_revision: { color: "text-warning-text", dotClass: "bg-warning", label: "Chỉnh sửa" },
47
47
  waiting_supplier:{ color: "text-warning-text", dotClass: "bg-warning", label: "Chờ NCC" },
48
48
  partially_received: { color: "text-warning-text", dotClass: "bg-warning", label: "Nhận một phần" },
49
+ // Key dữ liệu THẬT của PurchaseOrder vinhhoa (bản local từng drift vì core thiếu key này — 2026-07 hợp nhất về core).
50
+ partial_received: { color: "text-warning-text", dotClass: "bg-warning", label: "Nhận một phần" },
49
51
  delivery_adjustment: { color: "text-warning-text", dotClass: "bg-warning", label: "Điều chỉnh giao hàng" },
50
52
 
51
53
  // Info / In-progress