@goplusvn/core 0.1.26 → 0.1.27
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 +30 -0
- package/package.json +2 -1
- package/src/rbac/index.ts +1 -0
- package/src/rbac/permissions-version.ts +84 -0
- package/src/rbac/route-handlers.ts +31 -0
- package/src/ui/forms/multi-select.tsx +74 -16
- package/src/ui/shared/index.ts +1 -0
- package/src/ui/shared/permissions-version-watcher.tsx +100 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.27 — Live permissions + MultiSelect hết tràn badge
|
|
4
|
+
|
|
5
|
+
Cơ chế "phiên bản bộ quyền" (đúc từ vinhhoa): admin sửa vai trò/phân quyền →
|
|
6
|
+
mọi user đang đăng nhập nhận quyền mới trong ≤60s (tức thì khi focus lại tab).
|
|
7
|
+
- `@goerp/core/rbac/permissions-version`: `getPermissionsVersion(db)` /
|
|
8
|
+
`bumpPermissionsVersion(db)` — lưu SystemConfig key `rbac-permissions-version`,
|
|
9
|
+
schema-tolerant (thử cột audit createdBy/updatedAt/updatedBy → fallback tối
|
|
10
|
+
giản), best-effort không bao giờ throw.
|
|
11
|
+
- Route-handlers factory TỰ bump sau POST/PUT/DELETE role; thêm
|
|
12
|
+
`createPermissionsVersionHandler({ prisma, getSession })` cho endpoint
|
|
13
|
+
GET /api/rbac/permissions-version.
|
|
14
|
+
- `<PermissionsVersionWatcher />` (@goerp/core/ui): poll version + focus;
|
|
15
|
+
đổi → clear tab-content-cache + session.update() (jwt trigger="update" →
|
|
16
|
+
app forceRefresh quyền từ DB — vá cả đa instance vì cache in-memory
|
|
17
|
+
per-process) + router.refresh() + toast (tắt bằng silent).
|
|
18
|
+
App cần: mount watcher trong providers, route dùng handler factory, và jwt
|
|
19
|
+
callback xử lý trigger="update" bằng forceRefresh (vinhhoa đã có sẵn).
|
|
20
|
+
|
|
21
|
+
MultiSelect (tham khảo sersavan/shadcn-multi-select-component):
|
|
22
|
+
- Chip trên trigger WRAP xuống dòng mặc định khi chọn nhiều — trigger cao lên,
|
|
23
|
+
KHÔNG tràn ngang đè component bên cạnh; prop mới `singleLine` giữ 1 dòng
|
|
24
|
+
(chip co + cắt overflow) cho toolbar chật.
|
|
25
|
+
- Dropdown TỰ MỞ LÊN TRÊN khi sát đáy viewport (trước luôn mở xuống → bị cắt).
|
|
26
|
+
- Backspace khi ô tìm kiếm rỗng = bỏ chip cuối (chuẩn combobox).
|
|
27
|
+
- Props mới: `closeOnSelect` (đóng sau mỗi lần chọn), `deduplicateOptions`
|
|
28
|
+
(lọc option trùng value), `chipColor: "colorful" | "neutral"` (chip trầm
|
|
29
|
+
secondary cho form nghiêm túc; mặc định giữ bảng màu xoay vòng).
|
|
30
|
+
- Hardening: container `min-w-0 max-w-full`, trigger `overflow-hidden`,
|
|
31
|
+
chip max-w 8.5rem. Lưu ý chỗ dùng trong flex-row: ô bọc cần `min-w-0`.
|
|
32
|
+
|
|
3
33
|
## 0.1.26 — Bộ 3 trang RBAC redesign hoàn chỉnh (bar navy + thẻ + full width)
|
|
4
34
|
|
|
5
35
|
Thiết kế duyệt qua preview vinhhoa, đồng bộ ngôn ngữ trang chi tiết đơn bán hàng:
|
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.
|
|
4
|
+
"version": "0.1.27",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"./styles/*": "./src/styles/*",
|
|
39
39
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
40
40
|
"./rbac/route-handlers": "./src/rbac/route-handlers.ts",
|
|
41
|
+
"./rbac/permissions-version": "./src/rbac/permissions-version.ts",
|
|
41
42
|
"./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
|
|
42
43
|
"./errors/app-error": "./src/errors/app-error.ts",
|
|
43
44
|
"./errors/error-handler": "./src/errors/error-handler.ts",
|
package/src/rbac/index.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// "Phiên bản bộ quyền" toàn hệ thống — lưu trong bảng SystemConfig của app
|
|
3
|
+
// (DI prisma, schema-tolerant). Giải quyết bài toán: admin sửa vai trò/phân
|
|
4
|
+
// quyền nhưng user đang đăng nhập không nhận quyền mới (session client không
|
|
5
|
+
// refetch, tab-content-cache giữ UI cũ, cache quyền in-memory không lan qua
|
|
6
|
+
// replica). Client mount <PermissionsVersionWatcher /> (từ @goerp/core/ui)
|
|
7
|
+
// poll version; đổi → clear tab cache + session.update() (forceRefresh quyền
|
|
8
|
+
// từ DB) + router.refresh() → quyền áp trong ≤ poll interval, không cần
|
|
9
|
+
// re-login.
|
|
10
|
+
//
|
|
11
|
+
// Bump được gọi TỰ ĐỘNG trong route-handlers factory (POST/PUT/DELETE role);
|
|
12
|
+
// app có route tự viết (vd vinhhoa) thì gọi bumpPermissionsVersion(db) ở mọi
|
|
13
|
+
// điểm ghi RBAC (cập nhật/xóa vai trò, gán vai trò cho user).
|
|
14
|
+
|
|
15
|
+
const DEFAULT_KEY = "rbac-permissions-version";
|
|
16
|
+
|
|
17
|
+
export interface PermissionsVersionOptions {
|
|
18
|
+
/** SystemConfig key — mặc định "rbac-permissions-version". */
|
|
19
|
+
key?: string;
|
|
20
|
+
/** Giá trị ghi vào createdBy/updatedBy nếu schema SystemConfig yêu cầu. */
|
|
21
|
+
auditUser?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getPermissionsVersion(
|
|
25
|
+
db: any,
|
|
26
|
+
opts?: PermissionsVersionOptions,
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const key = opts?.key ?? DEFAULT_KEY;
|
|
29
|
+
try {
|
|
30
|
+
const row = await db.systemConfig.findUnique({
|
|
31
|
+
where: { key },
|
|
32
|
+
select: { value: true },
|
|
33
|
+
});
|
|
34
|
+
return row?.value ?? "0";
|
|
35
|
+
} catch {
|
|
36
|
+
// App không có bảng systemConfig → coi như version tĩnh (watcher no-op)
|
|
37
|
+
return "0";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bump version (best-effort, KHÔNG BAO GIỜ throw — không được làm fail
|
|
43
|
+
* request ghi RBAC chính). Schema-tolerant: thử create/update đầy đủ cột
|
|
44
|
+
* audit (createdBy/updatedAt/updatedBy — vd vinhhoa yêu cầu NOT NULL),
|
|
45
|
+
* thất bại thì thử bản tối giản (schema không có các cột đó).
|
|
46
|
+
*/
|
|
47
|
+
export async function bumpPermissionsVersion(
|
|
48
|
+
db: any,
|
|
49
|
+
opts?: PermissionsVersionOptions,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
const key = opts?.key ?? DEFAULT_KEY;
|
|
52
|
+
const v = Date.now().toString();
|
|
53
|
+
const now = new Date();
|
|
54
|
+
const auditUser = opts?.auditUser ?? "system";
|
|
55
|
+
const description =
|
|
56
|
+
"Phiên bản bộ quyền RBAC — tự bump khi vai trò/phân quyền thay đổi để client đang đăng nhập tự làm mới session";
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await db.systemConfig.upsert({
|
|
60
|
+
where: { key },
|
|
61
|
+
update: { value: v, updatedAt: now, updatedBy: auditUser },
|
|
62
|
+
create: {
|
|
63
|
+
key,
|
|
64
|
+
value: v,
|
|
65
|
+
type: "string",
|
|
66
|
+
category: "rbac",
|
|
67
|
+
description,
|
|
68
|
+
createdBy: auditUser,
|
|
69
|
+
updatedAt: now,
|
|
70
|
+
updatedBy: auditUser,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
} catch {
|
|
74
|
+
try {
|
|
75
|
+
await db.systemConfig.upsert({
|
|
76
|
+
where: { key },
|
|
77
|
+
update: { value: v },
|
|
78
|
+
create: { key, value: v },
|
|
79
|
+
});
|
|
80
|
+
} catch (e) {
|
|
81
|
+
console.warn("[rbac] bumpPermissionsVersion failed (bỏ qua):", e);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
// });
|
|
15
15
|
|
|
16
16
|
import { getRolesData, type RoleServiceSchema } from "./role-service";
|
|
17
|
+
import {
|
|
18
|
+
bumpPermissionsVersion,
|
|
19
|
+
getPermissionsVersion,
|
|
20
|
+
} from "./permissions-version";
|
|
17
21
|
|
|
18
22
|
type MaybePromise<T> = T | Promise<T>;
|
|
19
23
|
|
|
@@ -88,6 +92,7 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
88
92
|
await writePermissions(tx, created.code, permissions);
|
|
89
93
|
return created;
|
|
90
94
|
});
|
|
95
|
+
await bumpPermissionsVersion(prisma);
|
|
91
96
|
return json({ id: role.id, code: role.code, name: role.name }, 201);
|
|
92
97
|
} catch (e) {
|
|
93
98
|
return fail(e, req);
|
|
@@ -151,6 +156,8 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
151
156
|
await writePermissions(tx, updated.code, permissions);
|
|
152
157
|
return updated;
|
|
153
158
|
});
|
|
159
|
+
// Báo hiệu client đang đăng nhập tự refresh quyền (watcher poll version)
|
|
160
|
+
await bumpPermissionsVersion(prisma);
|
|
154
161
|
return json({ id: role.id, code: role.code, name: role.name });
|
|
155
162
|
} catch (e) {
|
|
156
163
|
return fail(e, req);
|
|
@@ -172,6 +179,7 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
172
179
|
await tx.rolePermission.deleteMany({ where: { roleCode: role.code } });
|
|
173
180
|
await tx.role.delete({ where: { id } });
|
|
174
181
|
});
|
|
182
|
+
await bumpPermissionsVersion(prisma);
|
|
175
183
|
return new Response(null, { status: 204 });
|
|
176
184
|
} catch (e) {
|
|
177
185
|
return fail(e, req);
|
|
@@ -180,3 +188,26 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
180
188
|
|
|
181
189
|
return { GET, PUT, DELETE };
|
|
182
190
|
}
|
|
191
|
+
|
|
192
|
+
// GET /api/rbac/permissions-version — client watcher poll; chỉ cần đăng nhập.
|
|
193
|
+
export function createPermissionsVersionHandler(deps: {
|
|
194
|
+
prisma: any;
|
|
195
|
+
getSession: () => MaybePromise<any | null>;
|
|
196
|
+
}) {
|
|
197
|
+
const { prisma, getSession } = deps;
|
|
198
|
+
|
|
199
|
+
async function GET() {
|
|
200
|
+
const session = await getSession();
|
|
201
|
+
if (!session?.user?.id) return json({ error: "Unauthorized" }, 401);
|
|
202
|
+
const v = await getPermissionsVersion(prisma);
|
|
203
|
+
return new Response(JSON.stringify({ v }), {
|
|
204
|
+
status: 200,
|
|
205
|
+
headers: {
|
|
206
|
+
"content-type": "application/json",
|
|
207
|
+
"cache-control": "no-store",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { GET };
|
|
213
|
+
}
|
|
@@ -46,6 +46,21 @@ interface MultiSelectProps {
|
|
|
46
46
|
* (thứ tự đóng băng trong lúc mở — toggle không làm nhảy vị trí).
|
|
47
47
|
*/
|
|
48
48
|
selectedFirst?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Mặc định chip WRAP xuống dòng khi chọn nhiều (trigger cao lên, không tràn
|
|
51
|
+
* ngang — theo pattern sersavan/shadcn-multi-select-component). Đặt true để
|
|
52
|
+
* giữ 1 dòng cố định: chip co + cắt bằng overflow (hợp cho toolbar chật).
|
|
53
|
+
*/
|
|
54
|
+
singleLine?: boolean;
|
|
55
|
+
/** Đóng dropdown ngay sau mỗi lần chọn (mặc định giữ mở để chọn tiếp). */
|
|
56
|
+
closeOnSelect?: boolean;
|
|
57
|
+
/** Loại bỏ option trùng value (giữ option đầu tiên). */
|
|
58
|
+
deduplicateOptions?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Màu chip trên trigger: "colorful" (xoay vòng bảng màu — mặc định) hoặc
|
|
61
|
+
* "neutral" (secondary trầm, hợp form/report nghiêm túc).
|
|
62
|
+
*/
|
|
63
|
+
chipColor?: "colorful" | "neutral";
|
|
49
64
|
/**
|
|
50
65
|
* Async mode: nạp options từ server theo từ khoá tìm kiếm (debounce 300ms).
|
|
51
66
|
* Khi truyền prop này, `options` chỉ dùng làm nguồn label ban đầu (có thể []),
|
|
@@ -164,6 +179,10 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
164
179
|
disabled = false,
|
|
165
180
|
responsive = false,
|
|
166
181
|
selectedFirst = false,
|
|
182
|
+
singleLine = false,
|
|
183
|
+
closeOnSelect = false,
|
|
184
|
+
deduplicateOptions = false,
|
|
185
|
+
chipColor = "colorful",
|
|
167
186
|
loadOptions,
|
|
168
187
|
loadingText = "Đang tải...",
|
|
169
188
|
className,
|
|
@@ -175,6 +194,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
175
194
|
const [internalValue, setInternalValue] =
|
|
176
195
|
useState<OptionValue[]>(defaultValue);
|
|
177
196
|
const [open, setOpen] = useState(false);
|
|
197
|
+
// Mở lên trên khi sát đáy viewport (dropdown ~360px bị cắt nếu luôn mở xuống)
|
|
198
|
+
const [dropUp, setDropUp] = useState(false);
|
|
178
199
|
const [searchValue, setSearchValue] = useState("");
|
|
179
200
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
180
201
|
// Snapshot selection tại thời điểm MỞ dropdown (cho selectedFirst)
|
|
@@ -317,14 +338,27 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
317
338
|
|
|
318
339
|
// Nguồn danh sách hiển thị (selectedFirst: đóng băng thứ tự theo snapshot lúc mở)
|
|
319
340
|
const sourceOptions = useMemo(() => {
|
|
320
|
-
|
|
321
|
-
if (
|
|
341
|
+
let base = hasAsync ? (remoteOptions ?? []) : options;
|
|
342
|
+
if (deduplicateOptions) {
|
|
343
|
+
const seen = new Set<OptionValue>();
|
|
344
|
+
base = base.filter((o) =>
|
|
345
|
+
seen.has(o.value) ? false : (seen.add(o.value), true),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (hasAsync || !selectedFirst) return base;
|
|
322
349
|
const snap = new Set(openSnapshot);
|
|
323
350
|
const sel: MultiSelectOption[] = [];
|
|
324
351
|
const unsel: MultiSelectOption[] = [];
|
|
325
|
-
for (const o of
|
|
352
|
+
for (const o of base) (snap.has(o.value) ? sel : unsel).push(o);
|
|
326
353
|
return [...sel, ...unsel];
|
|
327
|
-
}, [
|
|
354
|
+
}, [
|
|
355
|
+
options,
|
|
356
|
+
hasAsync,
|
|
357
|
+
remoteOptions,
|
|
358
|
+
selectedFirst,
|
|
359
|
+
openSnapshot,
|
|
360
|
+
deduplicateOptions,
|
|
361
|
+
]);
|
|
328
362
|
|
|
329
363
|
// Filter options based on search value (async: server đã lọc)
|
|
330
364
|
const filteredOptions = useMemo(() => {
|
|
@@ -483,8 +517,9 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
483
517
|
? value.filter((v) => v !== optionValue)
|
|
484
518
|
: [...value, optionValue];
|
|
485
519
|
updateValue(newValue);
|
|
520
|
+
if (closeOnSelect) setOpen(false);
|
|
486
521
|
},
|
|
487
|
-
[value, filteredOptions, updateValue],
|
|
522
|
+
[value, filteredOptions, updateValue, closeOnSelect],
|
|
488
523
|
);
|
|
489
524
|
|
|
490
525
|
const moveHighlight = useCallback(
|
|
@@ -578,7 +613,7 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
578
613
|
);
|
|
579
614
|
|
|
580
615
|
return (
|
|
581
|
-
<div ref={containerRef} className="relative w-full">
|
|
616
|
+
<div ref={containerRef} className="relative w-full min-w-0 max-w-full">
|
|
582
617
|
{/* Screen Reader Live Regions */}
|
|
583
618
|
<div className="sr-only">
|
|
584
619
|
<div aria-live="polite" aria-atomic="true" role="status">
|
|
@@ -597,18 +632,30 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
597
632
|
aria-expanded={open}
|
|
598
633
|
disabled={disabled}
|
|
599
634
|
className={cn(
|
|
600
|
-
"w-full justify-between h-auto min-h-9 px-3 py-1.5",
|
|
635
|
+
"w-full max-w-full justify-between overflow-hidden h-auto min-h-9 px-3 py-1.5",
|
|
601
636
|
responsiveSettings.compactMode && "min-h-8 text-sm",
|
|
602
637
|
className,
|
|
603
638
|
)}
|
|
604
639
|
id={id}
|
|
605
640
|
onClick={(e) => {
|
|
606
641
|
e.stopPropagation();
|
|
607
|
-
if (!open
|
|
642
|
+
if (!open) {
|
|
643
|
+
if (selectedFirst) setOpenSnapshot([...value]);
|
|
644
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
645
|
+
if (rect) {
|
|
646
|
+
const spaceBelow = window.innerHeight - rect.bottom;
|
|
647
|
+
setDropUp(spaceBelow < 360 && rect.top > spaceBelow);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
608
650
|
setOpen(!open);
|
|
609
651
|
}}
|
|
610
652
|
>
|
|
611
|
-
<div
|
|
653
|
+
<div
|
|
654
|
+
className={cn(
|
|
655
|
+
"flex min-w-0 max-w-full items-center gap-1",
|
|
656
|
+
singleLine ? "flex-nowrap overflow-hidden" : "flex-wrap",
|
|
657
|
+
)}
|
|
658
|
+
>
|
|
612
659
|
{value.length === 0 ? (
|
|
613
660
|
<span className="text-muted-foreground">{placeholder}</span>
|
|
614
661
|
) : isAllSelected ? (
|
|
@@ -625,8 +672,10 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
625
672
|
key={String(opt.value)}
|
|
626
673
|
variant="secondary"
|
|
627
674
|
className={cn(
|
|
628
|
-
"min-w-0 max-w-[
|
|
629
|
-
|
|
675
|
+
"min-w-0 max-w-[8.5rem] shrink gap-1 pr-1 font-normal",
|
|
676
|
+
chipColor === "neutral"
|
|
677
|
+
? "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary"
|
|
678
|
+
: CHIP_COLORS[chipIndex % CHIP_COLORS.length],
|
|
630
679
|
)}
|
|
631
680
|
>
|
|
632
681
|
<span className="truncate">{opt.label}</span>
|
|
@@ -681,11 +730,12 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
681
730
|
{open && (
|
|
682
731
|
<div
|
|
683
732
|
ref={dropdownRef}
|
|
684
|
-
className=
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
733
|
+
className={cn(
|
|
734
|
+
"absolute left-0 z-[100] w-full min-w-[15rem] rounded-md border bg-popover/95 backdrop-blur-md text-popover-foreground shadow-lg shadow-black/5 animate-in fade-in-0 zoom-in-95",
|
|
735
|
+
dropUp
|
|
736
|
+
? "bottom-full mb-1 slide-in-from-bottom-1"
|
|
737
|
+
: "top-full mt-1 slide-in-from-top-1",
|
|
738
|
+
)}
|
|
689
739
|
>
|
|
690
740
|
<div className="flex flex-col">
|
|
691
741
|
{/* Search Input */}
|
|
@@ -719,6 +769,14 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
719
769
|
e.preventDefault();
|
|
720
770
|
const opt = filteredOptions[highlightedIndex];
|
|
721
771
|
if (opt && !opt.disabled) handleSelect(opt.value);
|
|
772
|
+
} else if (
|
|
773
|
+
e.key === "Backspace" &&
|
|
774
|
+
searchValue === "" &&
|
|
775
|
+
value.length > 0
|
|
776
|
+
) {
|
|
777
|
+
// Chuẩn combobox: Backspace khi search rỗng = bỏ chip cuối
|
|
778
|
+
e.preventDefault();
|
|
779
|
+
updateValue(value.slice(0, -1));
|
|
722
780
|
}
|
|
723
781
|
}}
|
|
724
782
|
onClick={(e) => {
|
package/src/ui/shared/index.ts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Theo dõi "phiên bản bộ quyền" (xem @goerp/core/rbac/permissions-version).
|
|
4
|
+
// Mount 1 lần trong providers của app (BÊN TRONG SessionProvider +
|
|
5
|
+
// TabContentCacheProvider):
|
|
6
|
+
//
|
|
7
|
+
// <PermissionsVersionWatcher />
|
|
8
|
+
//
|
|
9
|
+
// Poll endpoint version mỗi pollMs + khi tab được focus. Khi version đổi:
|
|
10
|
+
// 1. Xóa tab-content-cache (UI đã render với quyền cũ) — giữ tab đang mở.
|
|
11
|
+
// 2. session.update() → NextAuth jwt trigger="update" → app forceRefresh
|
|
12
|
+
// quyền từ DB (vá luôn đa instance: cache in-memory instance khác chưa
|
|
13
|
+
// bị invalidate thì forceRefresh vẫn đọc thẳng DB).
|
|
14
|
+
// 3. router.refresh() → RSC render lại (menu, nút, trang) với session mới.
|
|
15
|
+
// → User đang đăng nhập nhận quyền mới trong ≤ pollMs, không cần re-login.
|
|
16
|
+
|
|
17
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
18
|
+
import { useRouter } from "next/navigation";
|
|
19
|
+
import { useSession } from "next-auth/react";
|
|
20
|
+
import { toast } from "sonner";
|
|
21
|
+
|
|
22
|
+
import { useTabContentCache } from "../layout/tab-content-cache";
|
|
23
|
+
|
|
24
|
+
export interface PermissionsVersionWatcherProps {
|
|
25
|
+
/** Endpoint trả về { v } — mặc định /api/rbac/permissions-version. */
|
|
26
|
+
endpoint?: string;
|
|
27
|
+
/** Chu kỳ poll (ms) — mặc định 60s. */
|
|
28
|
+
pollMs?: number;
|
|
29
|
+
/** Tắt toast thông báo khi quyền đổi. */
|
|
30
|
+
silent?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function PermissionsVersionWatcher({
|
|
34
|
+
endpoint = "/api/rbac/permissions-version",
|
|
35
|
+
pollMs = 60_000,
|
|
36
|
+
silent = false,
|
|
37
|
+
}: PermissionsVersionWatcherProps = {}) {
|
|
38
|
+
const { status, update } = useSession();
|
|
39
|
+
const router = useRouter();
|
|
40
|
+
const { clearAllCache } = useTabContentCache();
|
|
41
|
+
const lastSeenRef = useRef<string | null>(null);
|
|
42
|
+
const busyRef = useRef(false);
|
|
43
|
+
|
|
44
|
+
const check = useCallback(async () => {
|
|
45
|
+
if (busyRef.current || document.visibilityState === "hidden") return;
|
|
46
|
+
busyRef.current = true;
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(endpoint, { cache: "no-store" });
|
|
49
|
+
if (!res.ok) return;
|
|
50
|
+
const data = (await res.json()) as { v?: string };
|
|
51
|
+
const v = data?.v;
|
|
52
|
+
if (!v) return;
|
|
53
|
+
|
|
54
|
+
if (lastSeenRef.current === null) {
|
|
55
|
+
// Lần đầu sau khi mount: chỉ ghi nhận mốc, không refresh
|
|
56
|
+
lastSeenRef.current = v;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (v === lastSeenRef.current) return;
|
|
60
|
+
|
|
61
|
+
lastSeenRef.current = v;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
clearAllCache();
|
|
65
|
+
sessionStorage.removeItem("tab-content-cache");
|
|
66
|
+
} catch {
|
|
67
|
+
// best-effort
|
|
68
|
+
}
|
|
69
|
+
await update(); // jwt trigger="update" → forceRefresh quyền từ DB
|
|
70
|
+
router.refresh();
|
|
71
|
+
if (!silent) {
|
|
72
|
+
toast.info("Quyền truy cập của bạn vừa được cập nhật", {
|
|
73
|
+
description: "Giao diện đã được làm mới theo bộ quyền mới.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Mạng lỗi thì bỏ qua — lần poll sau thử lại
|
|
78
|
+
} finally {
|
|
79
|
+
busyRef.current = false;
|
|
80
|
+
}
|
|
81
|
+
}, [endpoint, silent, clearAllCache, router, update]);
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (status !== "authenticated") return;
|
|
85
|
+
|
|
86
|
+
check();
|
|
87
|
+
const interval = setInterval(check, pollMs);
|
|
88
|
+
const onFocus = () => check();
|
|
89
|
+
window.addEventListener("focus", onFocus);
|
|
90
|
+
document.addEventListener("visibilitychange", onFocus);
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
clearInterval(interval);
|
|
94
|
+
window.removeEventListener("focus", onFocus);
|
|
95
|
+
document.removeEventListener("visibilitychange", onFocus);
|
|
96
|
+
};
|
|
97
|
+
}, [status, pollMs, check]);
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|