@goplusvn/core 0.1.85 → 0.1.87
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
|
@@ -94,12 +94,22 @@ export interface ImportDialogProps {
|
|
|
94
94
|
apiUrl: string;
|
|
95
95
|
/**
|
|
96
96
|
* Nguồn file mẫu — chọn MỘT:
|
|
97
|
-
* - `templateApiUrl`: GET mẫu do server dựng (nhiều sheet, chứng từ),
|
|
98
|
-
* - `config`: dựng mẫu ngay ở client từ EntityConfig
|
|
99
|
-
*
|
|
97
|
+
* - `templateApiUrl`: GET mẫu do server dựng (nhiều sheet, chứng từ),
|
|
98
|
+
* - `config`: dựng mẫu ngay ở client từ EntityConfig, hoặc
|
|
99
|
+
* - `onDownloadTemplate`: app tự dựng (mẫu phụ thuộc cấu hình đang chạy —
|
|
100
|
+
* danh sách ca, danh sách món… — nên không khai trước bằng config được,
|
|
101
|
+
* mà app lại không tự resolve được `xlsx`; core có).
|
|
100
102
|
*/
|
|
101
103
|
templateApiUrl?: string;
|
|
102
104
|
config?: EntityConfig;
|
|
105
|
+
onDownloadTemplate?: () => void | Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Nội dung riêng của app chèn NGAY DƯỚI khối kết quả — chỗ đặt nút "Tải file
|
|
108
|
+
* lỗi". Nhận nguyên văn thân JSON server trả, vì mỗi nghiệp vụ đính kèm một
|
|
109
|
+
* kiểu chi tiết khác nhau (dòng xem trước, lý do từng dòng…) mà dialog không
|
|
110
|
+
* nên biết.
|
|
111
|
+
*/
|
|
112
|
+
renderResultExtra?: (raw: unknown) => ReactNode;
|
|
103
113
|
/** Không có quyền nhập thì không render gì. */
|
|
104
114
|
canImport?: boolean;
|
|
105
115
|
onSuccess?: () => void;
|
|
@@ -132,6 +142,8 @@ export function ImportDialog({
|
|
|
132
142
|
apiUrl,
|
|
133
143
|
templateApiUrl,
|
|
134
144
|
config,
|
|
145
|
+
onDownloadTemplate,
|
|
146
|
+
renderResultExtra,
|
|
135
147
|
canImport = true,
|
|
136
148
|
onSuccess,
|
|
137
149
|
tasksPath,
|
|
@@ -140,6 +152,7 @@ export function ImportDialog({
|
|
|
140
152
|
const [file, setFile] = useState<File | null>(null);
|
|
141
153
|
const [loading, setLoading] = useState(false);
|
|
142
154
|
const [result, setResult] = useState<NormalizedResult | null>(null);
|
|
155
|
+
const [rawResult, setRawResult] = useState<unknown>(null);
|
|
143
156
|
const [format, setFormat] = useState<ImportOptions["format"]>("xlsx");
|
|
144
157
|
const successTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
145
158
|
|
|
@@ -164,6 +177,7 @@ export function ImportDialog({
|
|
|
164
177
|
(selectedFile: File) => {
|
|
165
178
|
setFile(selectedFile);
|
|
166
179
|
setResult(null);
|
|
180
|
+
setRawResult(null);
|
|
167
181
|
if (configMode) {
|
|
168
182
|
const extension = selectedFile.name.split(".").pop()?.toLowerCase();
|
|
169
183
|
if (extension === "json") setFormat("json");
|
|
@@ -177,6 +191,7 @@ export function ImportDialog({
|
|
|
177
191
|
const clearSelectedFile = () => {
|
|
178
192
|
setFile(null);
|
|
179
193
|
setResult(null);
|
|
194
|
+
setRawResult(null);
|
|
180
195
|
};
|
|
181
196
|
|
|
182
197
|
const onDrop = useCallback(
|
|
@@ -216,6 +231,10 @@ export function ImportDialog({
|
|
|
216
231
|
|
|
217
232
|
const handleDownloadTemplate = async () => {
|
|
218
233
|
try {
|
|
234
|
+
if (onDownloadTemplate) {
|
|
235
|
+
await onDownloadTemplate();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
219
238
|
if (config) {
|
|
220
239
|
const blob = await generateXLSXTemplate(config);
|
|
221
240
|
triggerBlobDownload(blob, `${config.name}_template.xlsx`);
|
|
@@ -242,6 +261,7 @@ export function ImportDialog({
|
|
|
242
261
|
|
|
243
262
|
setLoading(true);
|
|
244
263
|
setResult(null);
|
|
264
|
+
setRawResult(null);
|
|
245
265
|
|
|
246
266
|
try {
|
|
247
267
|
// Chế độ config kiểm tra ở client trước, để người dùng thấy lỗi cột mà
|
|
@@ -297,6 +317,7 @@ export function ImportDialog({
|
|
|
297
317
|
setOpen(false);
|
|
298
318
|
setFile(null);
|
|
299
319
|
setResult(null);
|
|
320
|
+
setRawResult(null);
|
|
300
321
|
setLoading(false);
|
|
301
322
|
return;
|
|
302
323
|
}
|
|
@@ -307,6 +328,7 @@ export function ImportDialog({
|
|
|
307
328
|
|
|
308
329
|
const normalized = normalizeImportResult(raw);
|
|
309
330
|
setResult(normalized);
|
|
331
|
+
setRawResult(raw);
|
|
310
332
|
|
|
311
333
|
if (normalized.success && normalized.importedCount > 0) {
|
|
312
334
|
if (successTimeoutRef.current) clearTimeout(successTimeoutRef.current);
|
|
@@ -314,6 +336,7 @@ export function ImportDialog({
|
|
|
314
336
|
setOpen(false);
|
|
315
337
|
setFile(null);
|
|
316
338
|
setResult(null);
|
|
339
|
+
setRawResult(null);
|
|
317
340
|
onSuccess?.();
|
|
318
341
|
successTimeoutRef.current = null;
|
|
319
342
|
}, 2000);
|
|
@@ -353,6 +376,7 @@ export function ImportDialog({
|
|
|
353
376
|
if (!open) {
|
|
354
377
|
setFile(null);
|
|
355
378
|
setResult(null);
|
|
379
|
+
setRawResult(null);
|
|
356
380
|
}
|
|
357
381
|
}, [open]);
|
|
358
382
|
|
|
@@ -554,13 +578,23 @@ export function ImportDialog({
|
|
|
554
578
|
</AlertDescription>
|
|
555
579
|
</Alert>
|
|
556
580
|
)}
|
|
581
|
+
|
|
582
|
+
{result && renderResultExtra?.(rawResult)}
|
|
557
583
|
</div>
|
|
558
584
|
|
|
559
585
|
<DialogFooter className="flex flex-row sm:justify-end gap-2 space-x-0 pt-2">
|
|
560
|
-
<Button
|
|
586
|
+
<Button
|
|
587
|
+
variant="outline"
|
|
588
|
+
className="flex-1 sm:flex-none"
|
|
589
|
+
onClick={() => setOpen(false)}
|
|
590
|
+
>
|
|
561
591
|
Đóng
|
|
562
592
|
</Button>
|
|
563
|
-
<Button
|
|
593
|
+
<Button
|
|
594
|
+
className="flex-1 sm:flex-none"
|
|
595
|
+
onClick={handleImport}
|
|
596
|
+
disabled={!file || loading}
|
|
597
|
+
>
|
|
564
598
|
{loading ? "Đang import..." : "Import"}
|
|
565
599
|
</Button>
|
|
566
600
|
</DialogFooter>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { buildNavigationForSession, filterNavigationTree } from "../index";
|
|
4
|
+
|
|
5
|
+
import type { NavigationType, PermissionMap, Session } from "../../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Menu mẫu: một mục xem thường và một mục CHỈ dành cho người có quyền nhập.
|
|
9
|
+
* Đúng hình dạng đã làm lộ lỗ hổng ở tanloc-spartronics (2026-08-20).
|
|
10
|
+
*/
|
|
11
|
+
const NAVIGATION: NavigationType[] = [
|
|
12
|
+
{
|
|
13
|
+
title: "Căn-tin",
|
|
14
|
+
items: [
|
|
15
|
+
{
|
|
16
|
+
title: "Đặt suất ăn",
|
|
17
|
+
iconName: "UtensilsCrossed",
|
|
18
|
+
href: "/ordering",
|
|
19
|
+
resource: "ordering",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
title: "Nhập đăng ký",
|
|
23
|
+
iconName: "FileSpreadsheet",
|
|
24
|
+
href: "/ordering/import",
|
|
25
|
+
resource: "ordering",
|
|
26
|
+
action: "import",
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function sessionWith(permissionMap: PermissionMap): Session {
|
|
33
|
+
return { user: { id: "u1", roles: ["staff"], permissionMap } } as Session;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const titles = (nav: NavigationType[]) =>
|
|
37
|
+
nav.flatMap((group) => group.items.map((item) => item.title));
|
|
38
|
+
|
|
39
|
+
describe("buildNavigationForSession", () => {
|
|
40
|
+
it("giấu mục khai action mà người dùng không có quyền đó", () => {
|
|
41
|
+
const nav = buildNavigationForSession(
|
|
42
|
+
NAVIGATION,
|
|
43
|
+
sessionWith({ ordering: ["view", "create"] }),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(titles(nav)).toEqual(["Đặt suất ăn"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("hiện mục khai action khi người dùng có ĐÚNG action đó", () => {
|
|
50
|
+
const nav = buildNavigationForSession(
|
|
51
|
+
NAVIGATION,
|
|
52
|
+
sessionWith({ ordering: ["view", "import"] }),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
expect(titles(nav)).toEqual(["Đặt suất ăn", "Nhập đăng ký"]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("có action nhưng không xem được resource thì rụng cả nhóm", () => {
|
|
59
|
+
const nav = buildNavigationForSession(
|
|
60
|
+
NAVIGATION,
|
|
61
|
+
sessionWith({ employees: ["view"] }),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(nav).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("admin thấy nguyên cây, không đụng tới action", () => {
|
|
68
|
+
const session = { user: { id: "u1", roles: ["admin"] } } as Session;
|
|
69
|
+
|
|
70
|
+
expect(buildNavigationForSession(NAVIGATION, session)).toEqual(NAVIGATION);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("chưa đăng nhập ⇒ menu rỗng", () => {
|
|
74
|
+
expect(buildNavigationForSession(NAVIGATION, null)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("filterNavigationTree", () => {
|
|
79
|
+
it("không có `can` thì mục khai action bị ẩn kèm cảnh báo (fail-closed)", () => {
|
|
80
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
81
|
+
|
|
82
|
+
const nav = filterNavigationTree(NAVIGATION, new Set(["ordering"]));
|
|
83
|
+
|
|
84
|
+
expect(titles(nav)).toEqual(["Đặt suất ăn"]);
|
|
85
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
86
|
+
warn.mockRestore();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("nhận `can` thì hỏi đúng cặp (resource, action)", () => {
|
|
90
|
+
const can = vi.fn(
|
|
91
|
+
(resource: string, action: string) =>
|
|
92
|
+
resource === "ordering" && action === "import",
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const nav = filterNavigationTree(NAVIGATION, new Set(["ordering"]), {
|
|
96
|
+
can,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(titles(nav)).toEqual(["Đặt suất ăn", "Nhập đăng ký"]);
|
|
100
|
+
expect(can).toHaveBeenCalledExactlyOnceWith("ordering", "import");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("mục lồng nhau cũng được kiểm theo action đã khai", () => {
|
|
104
|
+
const nested: NavigationType[] = [
|
|
105
|
+
{
|
|
106
|
+
title: "Căn-tin",
|
|
107
|
+
items: [
|
|
108
|
+
{
|
|
109
|
+
title: "Quản trị",
|
|
110
|
+
iconName: "Settings",
|
|
111
|
+
items: [
|
|
112
|
+
{ title: "Thực đơn", href: "/menus", resource: "ordering" },
|
|
113
|
+
{
|
|
114
|
+
title: "Nhập đăng ký",
|
|
115
|
+
href: "/ordering/import",
|
|
116
|
+
resource: "ordering",
|
|
117
|
+
action: "import",
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const nav = filterNavigationTree(nested, new Set(["ordering"]), {
|
|
126
|
+
can: () => false,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(nav[0].items[0].items?.map((item) => item.title)).toEqual([
|
|
130
|
+
"Thực đơn",
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/navigation/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Core navigation utilities for GoERP platform
|
|
3
3
|
// Consolidated from packages/shared/navigation
|
|
4
4
|
|
|
5
|
-
import { getAccessibleResources } from "../auth";
|
|
5
|
+
import { checkPermission, getAccessibleResources } from "../auth";
|
|
6
6
|
|
|
7
7
|
import type {
|
|
8
8
|
NavigationType,
|
|
@@ -11,20 +11,72 @@ import type {
|
|
|
11
11
|
Session,
|
|
12
12
|
} from "../types";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Cách kiểm MỘT cặp (resource, action) cho mục menu tự khai `action`.
|
|
16
|
+
*
|
|
17
|
+
* `accessibleCodes` chỉ là tập resource của ĐÚNG MỘT action (thường là "view"),
|
|
18
|
+
* nên tự nó không trả lời được câu "người này có `ordering:import` không".
|
|
19
|
+
* `buildNavigationForSession` tự dựng hàm này từ session; app gọi thẳng
|
|
20
|
+
* `filterNavigationTree` thì truyền vào.
|
|
21
|
+
*/
|
|
22
|
+
export interface NavigationFilterOptions {
|
|
23
|
+
can?: (resourceCode: string, actionCode: string) => boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Một mục menu có được hiện không.
|
|
28
|
+
*
|
|
29
|
+
* Mục KHÔNG khai `resource` ⇒ công khai. Mục khai `resource` mà không khai
|
|
30
|
+
* `action` ⇒ so với `accessibleCodes` như trước nay.
|
|
31
|
+
*
|
|
32
|
+
* Mục khai `action` (vd `resource: "ordering", action: "import"`) phải kiểm
|
|
33
|
+
* ĐÚNG action đó: `accessibleCodes` dựng theo "view" nên mọi người xem được
|
|
34
|
+
* `ordering` sẽ thấy mục "Nhập đăng ký" rồi bấm vào bị cổng gác trang đá về
|
|
35
|
+
* trang chủ — đúng cái bẫy "menu hứa một đằng, trang trả lời một nẻo" mà
|
|
36
|
+
* `buildNavigationForSession` sinh ra để dẹp (tanloc-spartronics, 2026-08-20).
|
|
37
|
+
*
|
|
38
|
+
* Không có `can` thì FAIL-CLOSED kèm cảnh báo: ẩn một mục còn đỡ hơn mời người
|
|
39
|
+
* dùng bấm vào chỗ họ không có quyền, và cảnh báo giữ cho nó không im lặng —
|
|
40
|
+
* sidebar trắng trơn không lời giải thích là chế độ hỏng tệ nhất của app RBAC.
|
|
41
|
+
*/
|
|
42
|
+
function isItemVisible(
|
|
43
|
+
item: NavigationRootItem | NavigationNestedItem,
|
|
44
|
+
accessibleCodes: Set<string>,
|
|
45
|
+
options?: NavigationFilterOptions,
|
|
46
|
+
): boolean {
|
|
47
|
+
if (!item.resource) return true;
|
|
48
|
+
if (!item.action) return accessibleCodes.has(item.resource);
|
|
49
|
+
|
|
50
|
+
if (!options?.can) {
|
|
51
|
+
console.warn(
|
|
52
|
+
`[goerp/navigation] Mục "${item.title}" khai action "${item.action}" ` +
|
|
53
|
+
`nhưng filterNavigationTree không nhận được \`options.can\` nên không ` +
|
|
54
|
+
`kiểm được — đã ẩn. Dùng buildNavigationForSession(navigation, session) ` +
|
|
55
|
+
`hoặc truyền { can } vào.`,
|
|
56
|
+
);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return options.can(item.resource, item.action);
|
|
61
|
+
}
|
|
62
|
+
|
|
14
63
|
/**
|
|
15
64
|
* Filter navigation items based on permissions recursively
|
|
16
65
|
*/
|
|
17
66
|
export function filterNavigationItems(
|
|
18
67
|
items: NavigationRootItem[],
|
|
19
68
|
accessibleCodes: Set<string>,
|
|
69
|
+
options?: NavigationFilterOptions,
|
|
20
70
|
): NavigationRootItem[];
|
|
21
71
|
export function filterNavigationItems(
|
|
22
72
|
items: NavigationNestedItem[],
|
|
23
73
|
accessibleCodes: Set<string>,
|
|
74
|
+
options?: NavigationFilterOptions,
|
|
24
75
|
): NavigationNestedItem[];
|
|
25
76
|
export function filterNavigationItems(
|
|
26
77
|
items: NavigationRootItem[] | NavigationNestedItem[],
|
|
27
78
|
accessibleCodes: Set<string>,
|
|
79
|
+
options?: NavigationFilterOptions,
|
|
28
80
|
): (NavigationRootItem | NavigationNestedItem)[] {
|
|
29
81
|
return items.reduce<(NavigationRootItem | NavigationNestedItem)[]>(
|
|
30
82
|
(acc, item) => {
|
|
@@ -34,9 +86,9 @@ export function filterNavigationItems(
|
|
|
34
86
|
return acc;
|
|
35
87
|
}
|
|
36
88
|
|
|
37
|
-
// Default Deny:
|
|
38
|
-
//
|
|
39
|
-
if (item
|
|
89
|
+
// Default Deny: mục khai resource mà không đạt quyền thì rụng. Mục khai
|
|
90
|
+
// thêm `action` được kiểm theo đúng action đó (xem isItemVisible).
|
|
91
|
+
if (!isItemVisible(item, accessibleCodes, options)) {
|
|
40
92
|
return acc;
|
|
41
93
|
}
|
|
42
94
|
|
|
@@ -45,6 +97,7 @@ export function filterNavigationItems(
|
|
|
45
97
|
const filteredChildren = filterNavigationItems(
|
|
46
98
|
item.items as (NavigationRootItem | NavigationNestedItem)[],
|
|
47
99
|
accessibleCodes,
|
|
100
|
+
options,
|
|
48
101
|
);
|
|
49
102
|
|
|
50
103
|
// Only add parent if it has visible children
|
|
@@ -72,6 +125,7 @@ export function filterNavigationItems(
|
|
|
72
125
|
export function filterNavigationTree(
|
|
73
126
|
navigation: NavigationType[],
|
|
74
127
|
accessibleCodes: Set<string>,
|
|
128
|
+
options?: NavigationFilterOptions,
|
|
75
129
|
): NavigationType[] {
|
|
76
130
|
if (accessibleCodes.has("*")) {
|
|
77
131
|
return navigation;
|
|
@@ -80,7 +134,11 @@ export function filterNavigationTree(
|
|
|
80
134
|
const filteredNavigation: NavigationType[] = [];
|
|
81
135
|
|
|
82
136
|
for (const group of navigation) {
|
|
83
|
-
const filteredItems = filterNavigationItems(
|
|
137
|
+
const filteredItems = filterNavigationItems(
|
|
138
|
+
group.items,
|
|
139
|
+
accessibleCodes,
|
|
140
|
+
options,
|
|
141
|
+
);
|
|
84
142
|
|
|
85
143
|
if (filteredItems.length > 0) {
|
|
86
144
|
filteredNavigation.push({
|
|
@@ -116,6 +174,10 @@ export function filterNavigationTree(
|
|
|
116
174
|
* thành sidebar trắng trơn không lời giải thích — chế độ hỏng tệ nhất của
|
|
117
175
|
* app RBAC: người dùng tưởng bị thu hồi quyền và đi báo lỗi sai chỗ. Cứ để
|
|
118
176
|
* lỗi nổi lên error boundary.
|
|
177
|
+
* 4. **Mục tự khai `action` được kiểm theo ĐÚNG action đó**, không theo mốc
|
|
178
|
+
* chung ở (2). Ví dụ "Nhập đăng ký" (`resource: "ordering"`,
|
|
179
|
+
* `action: "import"`) chỉ hiện với ai có `ordering:import`, dù cả phòng đều
|
|
180
|
+
* có `ordering:view`.
|
|
119
181
|
*
|
|
120
182
|
* Core KHÔNG tự đọc session: app dùng better-auth, app dùng next-auth, và cách
|
|
121
183
|
* lấy session là thứ duy nhất khác nhau thật giữa chúng. App truyền session vào
|
|
@@ -136,5 +198,8 @@ export function buildNavigationForSession(
|
|
|
136
198
|
return filterNavigationTree(
|
|
137
199
|
navigation,
|
|
138
200
|
getAccessibleResources(session, options.action ?? "view"),
|
|
201
|
+
// Mục tự khai `action` được kiểm theo ĐÚNG action đó, không theo mốc chung
|
|
202
|
+
// ở trên: `accessibleCodes` chỉ là tập resource của một action duy nhất.
|
|
203
|
+
{ can: (resource, action) => checkPermission(session, resource, action) },
|
|
139
204
|
);
|
|
140
205
|
}
|