@goplusvn/core 0.1.84 → 0.1.86
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/CHANGELOG.md +36 -0
- package/package.json +1 -1
- package/src/crud/components/crud-detail-dialog.tsx +1 -1
- package/src/crud/components/crud-dialog.tsx +27 -1
- package/src/crud/components/crud-field-renderer.tsx +2 -2
- package/src/navigation/__tests__/navigation.test.ts +133 -0
- package/src/navigation/index.ts +70 -5
- package/src/types/index.ts +1 -0
- package/src/ui/feedback/index.tsx +4 -1
- package/src/ui/primitives/dialog.tsx +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
## 0.1.85 — Hộp thoại: gỡ bẫy class `sm:` khiến trang không ghi đè được
|
|
2
|
+
|
|
3
|
+
Sửa lỗi giao diện do 0.1.84 gây ra. `DialogContent` đổi mặc định thành
|
|
4
|
+
`p-4 sm:p-6` và `sm:max-w-lg`; `cn()` là `twMerge` mà twMerge **chỉ khử xung đột
|
|
5
|
+
trong cùng một variant**, nên `sm:p-6` / `sm:max-w-lg` sống sót khi trang truyền
|
|
6
|
+
`p-0` / `max-w-2xl`, rồi thắng ở ≥640px vì nằm trong media query.
|
|
7
|
+
|
|
8
|
+
Hậu quả trên app đã nâng (thingtodo): mọi hộp thoại CRUD bị bóp từ 672px xuống
|
|
9
|
+
512px, cộng thêm 24px padding thừa nên đường kẻ header/footer thụt vào không
|
|
10
|
+
chạm mép, và nút X mặc định của Radix lộ ra (trước đó bị header sticky che).
|
|
11
|
+
|
|
12
|
+
**Đổi**
|
|
13
|
+
|
|
14
|
+
- `ui/primitives/dialog.tsx` — `p-4 sm:p-6` → `p-6`; `sm:max-w-lg` → `max-w-lg`;
|
|
15
|
+
bỏ `max-w-[calc(100%-2rem)]` thừa. Giữ nguyên phần chống tràn của 0.1.84
|
|
16
|
+
(`max-h-[calc(100dvh-2rem)]`, `overscroll-contain`, `w-[calc(100%-2rem)] sm:w-full`)
|
|
17
|
+
vì chúng không xung đột với class trang truyền vào.
|
|
18
|
+
- `ui/feedback/index.tsx` — `AlertDialogContent` dính đúng bẫy đó, sửa y hệt.
|
|
19
|
+
LƯU Ý cho app đang dùng: hộp thoại khai `max-w-sm` / `max-w-md` **không**
|
|
20
|
+
prefix từ nay hẹp đúng như đã khai, thay vì bị `sm:max-w-lg` kéo lên 512px.
|
|
21
|
+
- `crud/components/crud-dialog.tsx` — `formWidth` từng nội suy
|
|
22
|
+
`` max-w-${...} ``: Tailwind quét class TĨNH nên class đó không bao giờ được
|
|
23
|
+
sinh ra. Thay bằng bảng ánh xạ tĩnh. Thêm `w-full` (tràn viền trên điện thoại,
|
|
24
|
+
đi cùng `h-[100vh] rounded-none`) và ẩn tường minh nút X bằng
|
|
25
|
+
`[&>button.absolute]:hidden` — trước đây nó chỉ *vô tình* bị header che.
|
|
26
|
+
Đóng bằng "Huỷ" hoặc Esc; footer sticky nên "Huỷ" luôn thấy.
|
|
27
|
+
- `crud/components/crud-detail-dialog.tsx` — thêm `w-full` cho nhất quán.
|
|
28
|
+
|
|
29
|
+
**Quy tắc rút ra**: class mặc định trong primitive mà trang có thể muốn ghi đè
|
|
30
|
+
thì PHẢI để dạng không prefix. Muốn khác nhau theo bề rộng màn hình thì đặt ở
|
|
31
|
+
phía trang, đừng đặt ở mặc định.
|
|
32
|
+
|
|
33
|
+
Đã dựng thật với `core:link`: vinhhoa (từ 0.1.78) và tanloc-spartronics
|
|
34
|
+
(từ 0.1.83) `type-check` + `next build` đều xanh; không mất API công khai, không
|
|
35
|
+
đòi cột/bảng DB mới.
|
|
36
|
+
|
|
1
37
|
# Changelog
|
|
2
38
|
|
|
3
39
|
## 0.1.81 — Cây workspace kiểu explorer, xoá/khôi phục lộ ra, thêm thành viên bằng dialog
|
package/package.json
CHANGED
|
@@ -269,7 +269,7 @@ export function CrudDetailDialog({
|
|
|
269
269
|
|
|
270
270
|
return (
|
|
271
271
|
<Dialog open={open} onOpenChange={onOpenChange} modal={true}>
|
|
272
|
-
<DialogContent className="max-w-2xl max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0 gap-0">
|
|
272
|
+
<DialogContent className="w-full max-w-2xl max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0 gap-0">
|
|
273
273
|
{/* Header */}
|
|
274
274
|
<DialogHeader className="bg-background border-b px-4 sm:px-6 pt-4 sm:pt-6 pb-3 sm:pb-4 text-left space-y-0 shrink-0">
|
|
275
275
|
<div className="flex items-center gap-2 sm:gap-3 min-w-0">
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from "react";
|
|
4
4
|
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
5
|
+
import { cn } from "../../utils";
|
|
5
6
|
|
|
6
7
|
import type { EntityConfig } from "../../types";
|
|
7
8
|
|
|
@@ -16,6 +17,22 @@ import { DynamicIcon } from "../../ui";
|
|
|
16
17
|
import { CrudForm } from "./crud-form";
|
|
17
18
|
import { useCrudConfig } from "./crud-context";
|
|
18
19
|
|
|
20
|
+
// Tailwind quét class TĨNH — `max-w-${...}` sẽ KHÔNG được sinh ra, hộp thoại
|
|
21
|
+
// mất luôn giới hạn bề rộng. Phải liệt kê nguyên chuỗi.
|
|
22
|
+
const FORM_WIDTH_CLASS: Record<
|
|
23
|
+
NonNullable<EntityConfig["formWidth"]>,
|
|
24
|
+
string
|
|
25
|
+
> = {
|
|
26
|
+
sm: "max-w-sm",
|
|
27
|
+
md: "max-w-md",
|
|
28
|
+
lg: "max-w-lg",
|
|
29
|
+
xl: "max-w-xl",
|
|
30
|
+
"2xl": "max-w-2xl",
|
|
31
|
+
"3xl": "max-w-3xl",
|
|
32
|
+
"4xl": "max-w-4xl",
|
|
33
|
+
"5xl": "max-w-5xl",
|
|
34
|
+
};
|
|
35
|
+
|
|
19
36
|
interface CrudDialogProps {
|
|
20
37
|
open: boolean;
|
|
21
38
|
onOpenChange: (open: boolean) => void;
|
|
@@ -192,7 +209,16 @@ export function CrudDialog({
|
|
|
192
209
|
return (
|
|
193
210
|
<Dialog open={open} onOpenChange={onOpenChange} modal={true}>
|
|
194
211
|
<DialogContent
|
|
195
|
-
className=
|
|
212
|
+
className={cn(
|
|
213
|
+
// `w-full` là CỐ Ý: ghi đè mặc định `w-[calc(100%-2rem)]` của
|
|
214
|
+
// DialogContent để hộp thoại tràn viền trên điện thoại (đi cùng
|
|
215
|
+
// h-[100vh] + rounded-none), desktop vẫn bị max-w-* chặn lại.
|
|
216
|
+
"w-full max-h-[90vh] sm:max-h-[90vh] h-[100vh] sm:h-auto rounded-none sm:rounded-lg overflow-hidden flex flex-col p-0",
|
|
217
|
+
// Header dính (sticky z-10, nền đục) che mất nút X mặc định của
|
|
218
|
+
// Radix — ẩn hẳn cho tường minh. Đóng bằng "Huỷ" hoặc Esc.
|
|
219
|
+
"[&>button.absolute]:hidden",
|
|
220
|
+
FORM_WIDTH_CLASS[config.formWidth ?? "2xl"],
|
|
221
|
+
)}
|
|
196
222
|
onInteractOutside={(e) => {
|
|
197
223
|
// Prevent closing dialog when submitting or clicking on Popover
|
|
198
224
|
if (isSubmitting || showSuccess) {
|
|
@@ -484,9 +484,9 @@ export function CrudFieldRenderer({
|
|
|
484
484
|
name={field.name}
|
|
485
485
|
render={({ field: formField }) => (
|
|
486
486
|
<FormItem>
|
|
487
|
-
{field.label && <FormLabel>{field.label}</FormLabel>}
|
|
487
|
+
{field.label && <FormLabel className="font-semibold">{field.label}</FormLabel>}
|
|
488
488
|
<FormControl>
|
|
489
|
-
<div className="flex items-center
|
|
489
|
+
<div className="flex items-center h-10">
|
|
490
490
|
<Switch
|
|
491
491
|
checked={
|
|
492
492
|
field.type === "switch"
|
|
@@ -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
|
}
|
package/src/types/index.ts
CHANGED
|
@@ -336,6 +336,7 @@ export interface EntityConfig {
|
|
|
336
336
|
codeGeneration?: CodeGenerationConfig;
|
|
337
337
|
formSections?: FormSection[];
|
|
338
338
|
formMode?: FormModeConfig;
|
|
339
|
+
formWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl";
|
|
339
340
|
rowActions?: RowAction[];
|
|
340
341
|
include?: string[];
|
|
341
342
|
}
|
|
@@ -89,7 +89,10 @@ export function AlertDialogContent({
|
|
|
89
89
|
<AlertDialogPrimitive.Content
|
|
90
90
|
data-slot="alert-dialog-content"
|
|
91
91
|
className={cn(
|
|
92
|
-
"fixed top-[50%] left-[50%] z-50 w-
|
|
92
|
+
"fixed top-[50%] left-[50%] z-50 w-[calc(100%_-_2rem)] sm:w-full grid bg-background translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 max-w-lg",
|
|
93
|
+
// KHÔNG để `sm:p-6` / `sm:max-w-lg` làm mặc định: twMerge chỉ khử xung
|
|
94
|
+
// đột trong cùng variant, nên trang truyền `p-0` / `max-w-sm` sẽ bị
|
|
95
|
+
// class `sm:` đè ngược ở ≥640px (đúng lỗi 0.1.84 của DialogContent).
|
|
93
96
|
// Cùng lý do với DialogContent: bị neo giữa màn hình nên phải chặn
|
|
94
97
|
// chiều cao, không thì mô tả dài sẽ tràn ra ngoài và không cuộn được.
|
|
95
98
|
"max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
|
|
@@ -40,7 +40,7 @@ const DialogContent = React.forwardRef<
|
|
|
40
40
|
<DialogPrimitive.Content
|
|
41
41
|
ref={ref}
|
|
42
42
|
className={cn(
|
|
43
|
-
"fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-
|
|
43
|
+
"fixed left-[50%] top-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
|
44
44
|
// Hộp thoại bị neo `top-1/2 -translate-y-1/2`, nên nếu KHÔNG chặn chiều
|
|
45
45
|
// cao thì nội dung cao hơn màn hình sẽ tràn ra CẢ trên lẫn dưới và
|
|
46
46
|
// không cuộn được — hỏng trên điện thoại và trên tablet xoay ngang.
|
|
@@ -48,7 +48,12 @@ const DialogContent = React.forwardRef<
|
|
|
48
48
|
// class của trang đứng sau trong cn() nên vẫn thắng.
|
|
49
49
|
"max-h-[calc(100dvh_-_2rem)] overflow-y-auto overscroll-contain",
|
|
50
50
|
// Bề rộng: chừa 1rem mỗi bên trên màn hẹp thay vì dán sát mép.
|
|
51
|
-
|
|
51
|
+
// CẢNH BÁO (đã dính 0.1.84): mọi class mặc định ở đây mà trang có thể
|
|
52
|
+
// muốn ghi đè PHẢI để dạng KHÔNG prefix. `cn()` = twMerge chỉ khử xung
|
|
53
|
+
// đột trong CÙNG một variant — `sm:max-w-lg`/`sm:p-6` sẽ SỐNG SÓT khi
|
|
54
|
+
// trang truyền `max-w-2xl`/`p-0`, rồi thắng ở ≥640px vì nằm trong media
|
|
55
|
+
// query. Đó chính là lỗi bóp CrudDialog còn 512px + padding kép.
|
|
56
|
+
"w-[calc(100%_-_2rem)] sm:w-full max-w-lg",
|
|
52
57
|
className,
|
|
53
58
|
)}
|
|
54
59
|
{...props}
|