@goplusvn/core 0.1.77 → 0.1.78
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 +2 -1
- package/src/guardrails/__tests__/guardrails.test.ts +82 -0
- package/src/guardrails/rules/rbac.ts +120 -0
- package/src/navigation/index.ts +49 -0
- package/src/rbac/__tests__/landing-path.test.ts +148 -0
- package/src/rbac/__tests__/route-handlers.test.ts +147 -0
- package/src/rbac/landing-path.ts +140 -0
- package/src/rbac/pages/role-form-page.tsx +99 -0
- package/src/rbac/route-handlers.ts +22 -1
- package/src/schemas/role.schema.ts +6 -0
- package/src/ui/auth/sign-in-form.tsx +36 -4
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +14 -2
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.78",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
"./import": "./src/import/index.ts",
|
|
55
55
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
56
56
|
"./rbac/route-handlers": "./src/rbac/route-handlers.ts",
|
|
57
|
+
"./rbac/landing-path": "./src/rbac/landing-path.ts",
|
|
57
58
|
"./workspace/route-handlers": "./src/workspace/route-handlers.ts",
|
|
58
59
|
"./rbac/permissions-version": "./src/rbac/permissions-version.ts",
|
|
59
60
|
"./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
|
|
@@ -343,6 +343,88 @@ describe("rule bắt được vi phạm thật", () => {
|
|
|
343
343
|
]);
|
|
344
344
|
});
|
|
345
345
|
|
|
346
|
+
/**
|
|
347
|
+
* Ca lỗi thật: tanloc-spartronics khởi tạo mà không chép
|
|
348
|
+
* `services/navigation-service.ts`, nên layout truyền thẳng cây menu xuống —
|
|
349
|
+
* vai `staff` thấy đủ mục, bấm vào mới bị cổng gác đá về (2026-08-15).
|
|
350
|
+
*/
|
|
351
|
+
const NAV_RULE = "rbac/nav-filtered-by-permission";
|
|
352
|
+
|
|
353
|
+
it(`${NAV_RULE} — layout cấp menu thô cho vỏ ứng dụng thì đỏ`, () => {
|
|
354
|
+
const results = run(
|
|
355
|
+
fixture({
|
|
356
|
+
"app/[lang]/(main)/layout.tsx": [
|
|
357
|
+
'import { navigations } from "@/data/navigations"',
|
|
358
|
+
"export default function L() {",
|
|
359
|
+
" return <MainLayout navigation={navigations} />",
|
|
360
|
+
"}",
|
|
361
|
+
].join("\n"),
|
|
362
|
+
}),
|
|
363
|
+
);
|
|
364
|
+
expect(resultOf(results, NAV_RULE).violations).toEqual([
|
|
365
|
+
"app/[lang]/(main)/layout.tsx — cấp menu cho vỏ ứng dụng mà không lọc",
|
|
366
|
+
]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it(`${NAV_RULE} — lọc bằng mốc rộng hơn cổng gác trang thì đỏ`, () => {
|
|
370
|
+
const results = run(
|
|
371
|
+
fixture({
|
|
372
|
+
"app/[lang]/(main)/layout.tsx": [
|
|
373
|
+
'import { getAccessibleResources } from "@goerp/core/auth"',
|
|
374
|
+
'import { filterNavigationTree } from "@goerp/core/navigation"',
|
|
375
|
+
'import { navigations } from "@/data/navigations"',
|
|
376
|
+
"export default async function L() {",
|
|
377
|
+
// Thiếu `"view"` = "có BẤT KỲ action nào" → menu rộng hơn trang mở được.
|
|
378
|
+
" const nav = filterNavigationTree(navigations, getAccessibleResources(session))",
|
|
379
|
+
" return <MainLayout navigation={nav} />",
|
|
380
|
+
"}",
|
|
381
|
+
].join("\n"),
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
expect(resultOf(results, NAV_RULE).violations).toEqual([
|
|
385
|
+
'app/[lang]/(main)/layout.tsx — lọc menu không theo action "view"',
|
|
386
|
+
]);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it(`${NAV_RULE} — truy sang service mà layout gọi, không dừng ở layout`, () => {
|
|
390
|
+
const results = run(
|
|
391
|
+
fixture({
|
|
392
|
+
"app/[lang]/(main)/layout.tsx": [
|
|
393
|
+
'import { getNavigationForUser } from "@/services/navigation-service"',
|
|
394
|
+
"export default async function L() {",
|
|
395
|
+
" const nav = await getNavigationForUser()",
|
|
396
|
+
" return <MainLayout navigation={nav} />",
|
|
397
|
+
"}",
|
|
398
|
+
].join("\n"),
|
|
399
|
+
"services/navigation-service.ts": [
|
|
400
|
+
'import { navigationsData } from "@/data/navigations"',
|
|
401
|
+
"export async function getNavigationForUser() { return navigationsData }",
|
|
402
|
+
].join("\n"),
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
expect(resultOf(results, NAV_RULE).violations).toEqual([
|
|
406
|
+
"services/navigation-service.ts (qua app/[lang]/(main)/layout.tsx) — cấp menu cho vỏ ứng dụng mà không lọc",
|
|
407
|
+
]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it(`${NAV_RULE} — file đọc menu cho việc KHÁC không bị bắt oan`, () => {
|
|
411
|
+
// Ba ca có thật trong vinhhoa/thingtodo/spartronics: sinh registry TỪ menu,
|
|
412
|
+
// seed RBAC từ menu, và provider tra tiêu đề tab. Không chỗ nào vẽ menu.
|
|
413
|
+
const results = run(
|
|
414
|
+
fixture({
|
|
415
|
+
"configs/permissions/menu-tree.ts":
|
|
416
|
+
'import { navigations } from "@/data/navigations"\nexport const MENU_TREE = navigations',
|
|
417
|
+
"app/api/resources/sync/route.ts":
|
|
418
|
+
'import { navigationsData } from "@/data/navigations"\nexport const POST = () => sync(navigationsData)',
|
|
419
|
+
"providers/index.tsx":
|
|
420
|
+
'import { navigations } from "@/data/navigations"\nexport const P = () => <TabNavigationProvider navigations={navigations} />',
|
|
421
|
+
"app/[lang]/layout.tsx":
|
|
422
|
+
'import { P } from "@/providers"\nexport default function Root() { return <P /> }',
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
expect(resultOf(results, NAV_RULE).violations).toEqual([]);
|
|
426
|
+
});
|
|
427
|
+
|
|
346
428
|
it("rbac/* tự bỏ qua khi app không truyền registry", () => {
|
|
347
429
|
const results = run(fixture({ "lib/a.ts": "export const a = 1" }));
|
|
348
430
|
expect(resultOf(results, "rbac/known-actions").status).toBe(
|
|
@@ -18,6 +18,41 @@ const declaredResources = (ctx: GuardrailContext) =>
|
|
|
18
18
|
(registryOf(ctx) ?? []).flatMap((f) => f.resources.map((r) => r.code)),
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
+
/** Cây menu thô. Mọi app đều để ở `data/navigations` — cũng là `options.navigations`. */
|
|
22
|
+
const READS_RAW_NAV = /from\s+["'][^"']*data\/navigations["']/;
|
|
23
|
+
|
|
24
|
+
/** Hai lối ra hợp lệ cho việc lọc menu. */
|
|
25
|
+
const FILTERS_NAV = /(buildNavigationForSession|filterNavigationTree)\s*\(/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Các file nguồn (rel `srcDir`) mà `code` import từ TRONG app — cả `@/x` lẫn
|
|
29
|
+
* `./x`. Trả về mọi ứng viên phần mở rộng; chỗ gọi dùng `readCodeRel` nên file
|
|
30
|
+
* không tồn tại chỉ ra chuỗi rỗng, không cần kiểm tra trước.
|
|
31
|
+
*/
|
|
32
|
+
function localImportsOf(code: string, fromDir: string): string[] {
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const m of code.matchAll(/from\s+["'](@\/|\.\.?\/)([^"']+)["']/g)) {
|
|
35
|
+
const base =
|
|
36
|
+
m[1] === "@/"
|
|
37
|
+
? m[2]
|
|
38
|
+
: // `./a/b` và `../a/b` — gộp về đường dẫn tương đối srcDir rồi rút gọn.
|
|
39
|
+
`${fromDir}/${m[1]}${m[2]}`
|
|
40
|
+
.split("/")
|
|
41
|
+
.reduce<string[]>((acc, seg) => {
|
|
42
|
+
if (seg === "." || seg === "") return acc;
|
|
43
|
+
if (seg === "..") {
|
|
44
|
+
acc.pop();
|
|
45
|
+
return acc;
|
|
46
|
+
}
|
|
47
|
+
acc.push(seg);
|
|
48
|
+
return acc;
|
|
49
|
+
}, [])
|
|
50
|
+
.join("/");
|
|
51
|
+
out.push(`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
21
56
|
const isApiFile = (rel: string) =>
|
|
22
57
|
(rel.startsWith("app/api/") && rel.endsWith("/route.ts")) ||
|
|
23
58
|
/^modules\/[a-z-]+\/api\//.test(rel);
|
|
@@ -202,6 +237,91 @@ export const rbacRules: GuardrailRule[] = [
|
|
|
202
237
|
},
|
|
203
238
|
},
|
|
204
239
|
|
|
240
|
+
{
|
|
241
|
+
id: "rbac/nav-filtered-by-permission",
|
|
242
|
+
title: "file nào đọc cây menu thô cũng phải lọc theo quyền",
|
|
243
|
+
why:
|
|
244
|
+
"Cây menu là bản đồ TOÀN BỘ hệ thống. Truyền thẳng nó xuống layout thì " +
|
|
245
|
+
"mọi người đăng nhập đều thấy đủ mục — vai `staff` không có một quyền " +
|
|
246
|
+
"nào trên `role`/`user` vẫn thấy Vai trò, Người dùng; bấm vào thì cổng " +
|
|
247
|
+
"gác đá về trang chủ, menu hứa một đằng trang trả lời một nẻo, và người " +
|
|
248
|
+
"dùng chỉ đọc được là 'hệ thống lỗi'. Đúng lỗi này sống trong " +
|
|
249
|
+
"tanloc-spartronics tới 2026-08-15 vì app đó khởi tạo mà không chép " +
|
|
250
|
+
"`services/navigation-service.ts` — `filterNavigationTree` có trong core " +
|
|
251
|
+
"nhưng KHÔNG một chỗ nào gọi. Ẩn ở client không cứu được: layout render " +
|
|
252
|
+
"ra HTML nên mục ẩn bằng CSS vẫn nằm nguyên trong payload gửi xuống.",
|
|
253
|
+
fix:
|
|
254
|
+
"Dùng `buildNavigationForSession(navigations, session)` của " +
|
|
255
|
+
"`@goerp/core/navigation` (fail-closed, mốc `view` sẵn), hoặc tự gọi " +
|
|
256
|
+
"`filterNavigationTree`. File chỉ đọc menu cho việc khác (breadcrumb, " +
|
|
257
|
+
"sitemap) thì khai vào `allowlists[\"rbac/nav-filtered-by-permission\"]`.",
|
|
258
|
+
run(ctx) {
|
|
259
|
+
const allowed = new Set(
|
|
260
|
+
ctx.options.allowlists["rbac/nav-filtered-by-permission"] ?? [],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const problems: string[] = [];
|
|
264
|
+
const seen = new Set<string>();
|
|
265
|
+
|
|
266
|
+
const check = (rel: string, code: string, via?: string) => {
|
|
267
|
+
if (!code || seen.has(rel) || allowed.has(rel)) return;
|
|
268
|
+
seen.add(rel);
|
|
269
|
+
const label = via ? `${rel} (qua ${via})` : rel;
|
|
270
|
+
if (!FILTERS_NAV.test(code)) {
|
|
271
|
+
problems.push(`${label} — cấp menu cho vỏ ứng dụng mà không lọc`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// Mốc lọc phải TRÙNG mốc cổng gác trang. `getAccessibleResources` bỏ
|
|
275
|
+
// trống action = "có BẤT KỲ action nào" → menu rộng hơn trang mở được.
|
|
276
|
+
// `buildNavigationForSession` đã chốt `"view"` bên trong nên không soi.
|
|
277
|
+
if (
|
|
278
|
+
/getAccessibleResources\s*\(/.test(code) &&
|
|
279
|
+
!/getAccessibleResources\s*\([^)]*["']view["']/.test(code)
|
|
280
|
+
) {
|
|
281
|
+
problems.push(`${label} — lọc menu không theo action "view"`);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
for (const file of ctx.files) {
|
|
286
|
+
const rel = ctx.rel(file);
|
|
287
|
+
const code = ctx.readCode(file);
|
|
288
|
+
// Chỉ soi chỗ menu đi VÀO GIAO DIỆN: prop `navigation` của vỏ ứng dụng
|
|
289
|
+
// (`MainLayout` của core), hoặc file layout. Quét mọi file đọc cây menu
|
|
290
|
+
// là báo oan hàng loạt — `configs/permissions/menu-tree.ts` sinh registry
|
|
291
|
+
// TỪ menu, `api/resources/sync` seed RBAC từ menu, trang audit tra nhãn
|
|
292
|
+
// resource: không chỗ nào trong số đó vẽ menu ra cả.
|
|
293
|
+
const feedsShell = /\bnavigation=\{/.test(code);
|
|
294
|
+
const isLayout = /(^|\/)layout\.tsx$/.test(rel);
|
|
295
|
+
if (!feedsShell && !isLayout) continue;
|
|
296
|
+
|
|
297
|
+
if (READS_RAW_NAV.test(code)) {
|
|
298
|
+
check(rel, code);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
// Layout thường không tự đọc cây menu mà gọi một service
|
|
302
|
+
// (`getNavigationForUser`). Truy sang file đó: chính nó mới là chỗ
|
|
303
|
+
// quên lọc được, và không ai soi nó cả.
|
|
304
|
+
//
|
|
305
|
+
// CHỈ truy từ layout đang thật sự cấp `navigation=` cho vỏ. Truy từ mọi
|
|
306
|
+
// layout thì layout GỐC kéo theo `providers/index.tsx` — file này đọc
|
|
307
|
+
// cây menu để `TabNavigationProvider` tra tiêu đề tab, không vẽ menu, và
|
|
308
|
+
// bắt nó ở đây là báo oan ở cả ba app đang chạy.
|
|
309
|
+
if (!feedsShell) continue;
|
|
310
|
+
const dir = rel.split("/").slice(0, -1).join("/");
|
|
311
|
+
for (const dep of localImportsOf(code, dir)) {
|
|
312
|
+
const depCode = ctx.readCodeRel(dep);
|
|
313
|
+
if (depCode && READS_RAW_NAV.test(depCode)) check(dep, depCode, rel);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return problems;
|
|
317
|
+
},
|
|
318
|
+
staleAllowlist(ctx) {
|
|
319
|
+
return (
|
|
320
|
+
ctx.options.allowlists["rbac/nav-filtered-by-permission"] ?? []
|
|
321
|
+
).filter((rel) => !ctx.existsInSrc(rel));
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
|
|
205
325
|
{
|
|
206
326
|
id: "rbac/gate-resource-declared",
|
|
207
327
|
title: "resource mà cổng gác đều đã khai trong registry",
|
package/src/navigation/index.ts
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// Core navigation utilities for GoERP platform
|
|
3
3
|
// Consolidated from packages/shared/navigation
|
|
4
4
|
|
|
5
|
+
import { getAccessibleResources } from "../auth";
|
|
6
|
+
|
|
5
7
|
import type {
|
|
6
8
|
NavigationType,
|
|
7
9
|
NavigationRootItem,
|
|
8
10
|
NavigationNestedItem,
|
|
11
|
+
Session,
|
|
9
12
|
} from "../types";
|
|
10
13
|
|
|
11
14
|
/**
|
|
@@ -89,3 +92,49 @@ export function filterNavigationTree(
|
|
|
89
92
|
|
|
90
93
|
return filteredNavigation;
|
|
91
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Menu của MỘT session — bản ráp sẵn, đúng-mặc-định.
|
|
98
|
+
*
|
|
99
|
+
* `filterNavigationTree` ở trên là nguyên liệu, và trong hai năm nó là thứ DUY
|
|
100
|
+
* NHẤT core cung cấp: mỗi app tự viết lấy phần ráp trong
|
|
101
|
+
* `src/services/navigation-service.ts`. Bốn app, bốn bản chép tay, và mỗi bản
|
|
102
|
+
* lệch một kiểu — tanloc-spartronics thì không có file đó luôn, nên menu không
|
|
103
|
+
* hề được lọc: mọi người đăng nhập đều thấy đủ mục, bấm vào mới bị
|
|
104
|
+
* `requirePageAccess` đá về trang chủ (2026-08-15). Hàm này tồn tại để câu trả
|
|
105
|
+
* lời đúng có đúng MỘT chỗ.
|
|
106
|
+
*
|
|
107
|
+
* Ba mặc định ở đây đều là bài học đã trả giá, đừng đảo:
|
|
108
|
+
*
|
|
109
|
+
* 1. **Chưa đăng nhập ⇒ menu RỖNG.** Fail-closed. Cây menu là bản đồ toàn bộ
|
|
110
|
+
* hệ thống; trả đầy đủ rồi tính sau là lộ cấu trúc màn hình cho người lạ.
|
|
111
|
+
* 2. **Mốc lọc mặc định là `"view"`**, TRÙNG mặc định của cổng gác trang
|
|
112
|
+
* (`requirePageAccess(lang, resource)`). `getAccessibleResources` bỏ trống
|
|
113
|
+
* action nghĩa là "có bất kỳ action nào" — lấy mốc đó thì menu rộng hơn
|
|
114
|
+
* trang, tức là lại hứa một đằng trả lời một nẻo, chỉ lệch ít hơn.
|
|
115
|
+
* 3. **KHÔNG bắt lỗi.** Hai bản chép tay cũ `catch → return []`, biến sự cố DB
|
|
116
|
+
* thành sidebar trắng trơn không lời giải thích — chế độ hỏng tệ nhất của
|
|
117
|
+
* app RBAC: người dùng tưởng bị thu hồi quyền và đi báo lỗi sai chỗ. Cứ để
|
|
118
|
+
* lỗi nổi lên error boundary.
|
|
119
|
+
*
|
|
120
|
+
* Core KHÔNG tự đọc session: app dùng better-auth, app dùng next-auth, và cách
|
|
121
|
+
* lấy session là thứ duy nhất khác nhau thật giữa chúng. App truyền session vào
|
|
122
|
+
* và tự bọc `cache()` của React nếu muốn gọi nhiều lần trong một request.
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* // app/[lang]/(main)/layout.tsx
|
|
126
|
+
* const navigation = buildNavigationForSession(navigations, await getSession())
|
|
127
|
+
* ```
|
|
128
|
+
*/
|
|
129
|
+
export function buildNavigationForSession(
|
|
130
|
+
navigation: NavigationType[],
|
|
131
|
+
session: Session | null,
|
|
132
|
+
options: { action?: string } = {},
|
|
133
|
+
): NavigationType[] {
|
|
134
|
+
if (!session?.user) return [];
|
|
135
|
+
|
|
136
|
+
return filterNavigationTree(
|
|
137
|
+
navigation,
|
|
138
|
+
getAccessibleResources(session, options.action ?? "view"),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createLandingPathResolver } from "../landing-path";
|
|
4
|
+
import type { NavigationType } from "../../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Ba thứ dễ hỏng ở đây, và hỏng thì người dùng KHÔNG vào được hệ thống:
|
|
8
|
+
* - `landingPath` trỏ tới trang vai trò không còn quyền → phải bỏ qua, nếu
|
|
9
|
+
* không sẽ là vòng chuyển hướng vô hạn.
|
|
10
|
+
* - App chưa migrate cột → phải im lặng bỏ qua bước 1, không được ném.
|
|
11
|
+
* - Người kiêm nhiều vai → theo vai MẠNH nhất (rank nhỏ nhất), tức theo đúng
|
|
12
|
+
* thứ tự DB trả về.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const NAV: NavigationType[] = [
|
|
16
|
+
{
|
|
17
|
+
title: "Tổng quan",
|
|
18
|
+
items: [
|
|
19
|
+
{ title: "Trang chủ", href: "/", iconName: "House", resource: "dashboard" },
|
|
20
|
+
{ title: "Hồ sơ", href: "/profile", iconName: "User" },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
title: "Bếp",
|
|
25
|
+
iconName: "Utensils",
|
|
26
|
+
items: [
|
|
27
|
+
{
|
|
28
|
+
title: "Đặt món",
|
|
29
|
+
href: "/ordering",
|
|
30
|
+
iconName: "Utensils",
|
|
31
|
+
resource: "ordering",
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
title: "Nhận món",
|
|
35
|
+
href: "/pickup",
|
|
36
|
+
iconName: "Hand",
|
|
37
|
+
resource: "pickup",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Phiên có đúng bộ quyền được liệt kê, không phải admin. */
|
|
44
|
+
function session(roles: string[], perms: Record<string, string[]>) {
|
|
45
|
+
return {
|
|
46
|
+
user: {
|
|
47
|
+
id: "u1",
|
|
48
|
+
roles,
|
|
49
|
+
permissionMap: perms,
|
|
50
|
+
},
|
|
51
|
+
} as any;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function fakePrisma(
|
|
55
|
+
rows: Array<{ landingPath: string | null }>,
|
|
56
|
+
opts?: { withColumn?: boolean; withRank?: boolean },
|
|
57
|
+
) {
|
|
58
|
+
const findMany = vi.fn().mockResolvedValue(rows);
|
|
59
|
+
const role: any = { findMany };
|
|
60
|
+
if (opts?.withColumn !== false) {
|
|
61
|
+
role.fields = { landingPath: {} };
|
|
62
|
+
if (opts?.withRank !== false) role.fields.rank = {};
|
|
63
|
+
}
|
|
64
|
+
return { prisma: { role } as any, findMany };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("createLandingPathResolver", () => {
|
|
68
|
+
it("dùng landingPath của vai trò khi quyền còn hợp lệ", async () => {
|
|
69
|
+
const { prisma } = fakePrisma([{ landingPath: "/pickup" }]);
|
|
70
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
resolve(session(["kitchen"], { pickup: ["view"] }), "vi"),
|
|
74
|
+
).resolves.toBe("/vi/pickup");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("bỏ qua landingPath đã mất quyền, rơi về mục menu đầu tiên mở được", async () => {
|
|
78
|
+
const { prisma } = fakePrisma([{ landingPath: "/pickup" }]);
|
|
79
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
resolve(session(["staff"], { ordering: ["view"] }), "vi"),
|
|
83
|
+
).resolves.toBe("/vi/profile");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("bỏ qua landingPath không có trên menu (trang đã bị gỡ)", async () => {
|
|
87
|
+
const { prisma } = fakePrisma([{ landingPath: "/da-xoa" }]);
|
|
88
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
resolve(session(["staff"], { ordering: ["view"] }), "vi"),
|
|
92
|
+
).resolves.toBe("/vi/profile");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("người kiêm nhiều vai đi theo dòng đầu tiên DB trả về (rank asc)", async () => {
|
|
96
|
+
const { prisma, findMany } = fakePrisma([
|
|
97
|
+
{ landingPath: "/" },
|
|
98
|
+
{ landingPath: "/pickup" },
|
|
99
|
+
]);
|
|
100
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
resolve(
|
|
104
|
+
session(["admin", "kitchen"], { dashboard: ["view"], pickup: ["view"] }),
|
|
105
|
+
"vi",
|
|
106
|
+
),
|
|
107
|
+
).resolves.toBe("/vi");
|
|
108
|
+
expect(findMany.mock.calls[0][0].orderBy).toEqual({ rank: "asc" });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("app không có cột `rank` (vinhhoa) thì không sắp xếp, vẫn chạy", async () => {
|
|
112
|
+
const { prisma, findMany } = fakePrisma([{ landingPath: "/pickup" }], {
|
|
113
|
+
withRank: false,
|
|
114
|
+
});
|
|
115
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
116
|
+
|
|
117
|
+
await expect(
|
|
118
|
+
resolve(session(["kitchen"], { pickup: ["view"] }), "vi"),
|
|
119
|
+
).resolves.toBe("/vi/pickup");
|
|
120
|
+
expect(findMany.mock.calls[0][0].orderBy).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("app chưa migrate cột thì không hỏi DB, vẫn trả mục menu đầu tiên", async () => {
|
|
124
|
+
const { prisma, findMany } = fakePrisma([], { withColumn: false });
|
|
125
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
resolve(session(["kitchen"], { pickup: ["view"] }), "vi"),
|
|
129
|
+
).resolves.toBe("/vi/profile");
|
|
130
|
+
expect(findMany).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("mục không khai resource là trang ai đăng nhập cũng mở được", async () => {
|
|
134
|
+
const { prisma } = fakePrisma([{ landingPath: null }]);
|
|
135
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
136
|
+
|
|
137
|
+
await expect(resolve(session(["nobody"], {}), "vi")).resolves.toBe(
|
|
138
|
+
"/vi/profile",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("chưa đăng nhập thì về trang đăng nhập", async () => {
|
|
143
|
+
const { prisma } = fakePrisma([]);
|
|
144
|
+
const resolve = createLandingPathResolver({ prisma, navigations: NAV });
|
|
145
|
+
|
|
146
|
+
await expect(resolve(null, "vi")).resolves.toBe("/vi/sign-in");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// `Role.landingPath` là cột BỔ SUNG: chỉ app nào đã migrate mới có. Hai app
|
|
3
|
+
// đang chạy thật dùng chung core này, nên handler phải tự dò và im lặng bỏ qua
|
|
4
|
+
// — ghi bừa một cột không tồn tại là 500 ở mọi lần lưu vai trò.
|
|
5
|
+
//
|
|
6
|
+
// Đây cũng là chỗ chặn đường dẫn ra ngoài: `landingPath` là nơi người dùng bị
|
|
7
|
+
// đưa tới ngay sau khi đăng nhập, để lọt "//host" là mở đường phishing.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
createRoleItemHandlers,
|
|
13
|
+
createRolesCollectionHandlers,
|
|
14
|
+
} from "../route-handlers";
|
|
15
|
+
|
|
16
|
+
function fakeDb(opts: { withLandingPath: boolean }) {
|
|
17
|
+
const existing = {
|
|
18
|
+
id: "r1",
|
|
19
|
+
code: "staff",
|
|
20
|
+
name: "Nhân viên",
|
|
21
|
+
description: "",
|
|
22
|
+
status: "active",
|
|
23
|
+
landingPath: null as string | null,
|
|
24
|
+
rolePermissions: [],
|
|
25
|
+
};
|
|
26
|
+
const created: any[] = [];
|
|
27
|
+
const updated: any[] = [];
|
|
28
|
+
|
|
29
|
+
const tx = {
|
|
30
|
+
role: {
|
|
31
|
+
create: async ({ data }: any) => {
|
|
32
|
+
created.push(data);
|
|
33
|
+
return { ...existing, ...data };
|
|
34
|
+
},
|
|
35
|
+
update: async ({ data }: any) => {
|
|
36
|
+
updated.push(data);
|
|
37
|
+
return { ...existing, ...data };
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
rolePermission: {
|
|
41
|
+
deleteMany: async () => undefined,
|
|
42
|
+
createMany: async () => undefined,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const role: any = { findUnique: async () => existing };
|
|
47
|
+
// Prisma sinh sẵn `model.fields` — chỉ có khi schema thật khai cột đó.
|
|
48
|
+
if (opts.withLandingPath) role.fields = { landingPath: { name: "landingPath" } };
|
|
49
|
+
|
|
50
|
+
const prisma: any = {
|
|
51
|
+
role,
|
|
52
|
+
systemConfig: { upsert: async () => undefined },
|
|
53
|
+
$transaction: async (fn: any) => fn(tx),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const deps = {
|
|
57
|
+
prisma,
|
|
58
|
+
getSession: async () => ({ user: { id: "u1" } }),
|
|
59
|
+
getCrudPermissions: async () => ({
|
|
60
|
+
read: true,
|
|
61
|
+
create: true,
|
|
62
|
+
update: true,
|
|
63
|
+
delete: true,
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return { deps, created, updated };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const post = (body: unknown) =>
|
|
71
|
+
new Request("http://app.test/api/roles", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
body: JSON.stringify(body),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const put = (body: unknown) =>
|
|
77
|
+
new Request("http://app.test/api/roles/r1", {
|
|
78
|
+
method: "PUT",
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ctx = { params: Promise.resolve({ id: "r1" }) };
|
|
83
|
+
|
|
84
|
+
describe("roles route handlers — landingPath", () => {
|
|
85
|
+
it("ghi landingPath khi app ĐÃ có cột", async () => {
|
|
86
|
+
const { deps, created } = fakeDb({ withLandingPath: true });
|
|
87
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
88
|
+
post({ code: "staff", name: "Nhân viên", landingPath: "/ordering" })
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(res.status).toBe(201);
|
|
92
|
+
expect(created[0].landingPath).toBe("/ordering");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("app CHƯA migrate: không đưa landingPath vào data (không làm 500)", async () => {
|
|
96
|
+
const { deps, created } = fakeDb({ withLandingPath: false });
|
|
97
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
98
|
+
post({ code: "staff", name: "Nhân viên", landingPath: "/ordering" })
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
expect(res.status).toBe(201);
|
|
102
|
+
expect(created[0]).not.toHaveProperty("landingPath");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("không gửi khóa landingPath thì KHÔNG đụng giá trị đang có", async () => {
|
|
106
|
+
const { deps, updated } = fakeDb({ withLandingPath: true });
|
|
107
|
+
await createRoleItemHandlers(deps as any).PUT(
|
|
108
|
+
put({ name: "Nhân viên" }),
|
|
109
|
+
ctx as any
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(updated[0]).not.toHaveProperty("landingPath");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("chuỗi rỗng = trả về 'tự suy ra' (null)", async () => {
|
|
116
|
+
const { deps, updated } = fakeDb({ withLandingPath: true });
|
|
117
|
+
await createRoleItemHandlers(deps as any).PUT(
|
|
118
|
+
put({ name: "Nhân viên", landingPath: "" }),
|
|
119
|
+
ctx as any
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(updated[0].landingPath).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it.each(["//evil.example/x", "https://evil.example", "javascript:alert(1)", "ordering"])(
|
|
126
|
+
"chặn đường dẫn ra ngoài app: %s",
|
|
127
|
+
async (value) => {
|
|
128
|
+
const { deps, updated } = fakeDb({ withLandingPath: true });
|
|
129
|
+
await createRoleItemHandlers(deps as any).PUT(
|
|
130
|
+
put({ name: "Nhân viên", landingPath: value }),
|
|
131
|
+
ctx as any
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(updated[0].landingPath).toBeNull();
|
|
135
|
+
}
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
it("GET trả landingPath để form sửa prefill được", async () => {
|
|
139
|
+
const { deps } = fakeDb({ withLandingPath: true });
|
|
140
|
+
const res = await createRoleItemHandlers(deps as any).GET(
|
|
141
|
+
new Request("http://app.test/api/roles/r1"),
|
|
142
|
+
ctx as any
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(await res.json()).toMatchObject({ landingPath: null });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
2
|
+
|
|
3
|
+
import type { NavigationType } from "../types";
|
|
4
|
+
import type { Session } from "../auth";
|
|
5
|
+
import { checkPermission } from "../auth";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* TRANG MỞ ĐẦU của một người — nơi đưa họ tới sau khi đăng nhập, và nơi đá về
|
|
9
|
+
* khi họ mở một trang không có quyền.
|
|
10
|
+
*
|
|
11
|
+
* Mặc định của mọi app là cắm cứng "/" cho tất cả. Hệ quả: người chỉ có một
|
|
12
|
+
* phần việc (đặt cơm, nhận món, chấm công) vẫn bị ném vào bảng số liệu tổng —
|
|
13
|
+
* và khi trang chủ có quyền riêng thì "/" còn là ngõ cụt thật sự với họ.
|
|
14
|
+
*
|
|
15
|
+
* Thứ tự quyết định, dừng ở cái đầu tiên hợp lệ:
|
|
16
|
+
*
|
|
17
|
+
* 1. `landingPath` của vai trò, ưu tiên vai trò MẠNH nhất (`rank` nhỏ nhất) —
|
|
18
|
+
* người kiêm nhiều vai vào trang của vai cao nhất.
|
|
19
|
+
* 2. Mục ĐẦU TIÊN trên menu mà họ mở được, theo đúng thứ tự sidebar.
|
|
20
|
+
* 3. "/" — chịu thua, để trang chủ tự nói là chưa có quyền.
|
|
21
|
+
*
|
|
22
|
+
* Bước 1 LUÔN kiểm tra lại quyền thật. Cấu hình nhập một lần rồi quyền đổi sau
|
|
23
|
+
* đó; một `landingPath` trỏ tới trang người ta không mở được sẽ thành vòng
|
|
24
|
+
* chuyển hướng vô hạn — người dùng chỉ thấy trình duyệt quay mãi.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Chỉ cần đúng một câu hỏi trên bảng `roles` — app truyền client nào cũng được. */
|
|
28
|
+
export interface LandingPrismaClient {
|
|
29
|
+
role: {
|
|
30
|
+
findMany: (args: any) => Promise<Array<{ landingPath?: string | null }>>;
|
|
31
|
+
// `fields` của Prisma là object sinh sẵn theo model, không có index
|
|
32
|
+
// signature — khai lỏng để client thật của app nào cũng khớp.
|
|
33
|
+
fields?: object;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LandingPathDeps {
|
|
38
|
+
prisma: LandingPrismaClient;
|
|
39
|
+
/** Cây menu GỐC của app (chưa lọc quyền) — thứ tự ở đây là thứ tự ưu tiên. */
|
|
40
|
+
navigations: readonly NavigationType[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Chuẩn hoá giá trị người dùng nhập/chọn trước khi ghi DB. Chỉ nhận đường dẫn
|
|
45
|
+
* NỘI BỘ; rỗng hoặc bậy = "tự suy ra" (null).
|
|
46
|
+
*
|
|
47
|
+
* "//host" là URL giao thức tương đối — chặn, nếu không cấu hình vai trò thành
|
|
48
|
+
* chỗ đá người dùng sang tên miền lạ ngay sau khi đăng nhập.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeLandingPath(value: unknown): string | null {
|
|
51
|
+
if (typeof value !== "string") return null;
|
|
52
|
+
const path = value.trim();
|
|
53
|
+
if (!path.startsWith("/") || path.startsWith("//")) return null;
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Leaf {
|
|
58
|
+
href: string;
|
|
59
|
+
resource?: string;
|
|
60
|
+
action?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Mọi mục có `href`, phẳng ra theo đúng thứ tự hiển thị trên sidebar. */
|
|
64
|
+
function menuLeaves(navigations: readonly NavigationType[]): Leaf[] {
|
|
65
|
+
const out: Leaf[] = [];
|
|
66
|
+
const walk = (items: readonly unknown[]) => {
|
|
67
|
+
for (const raw of items) {
|
|
68
|
+
const item = raw as {
|
|
69
|
+
href?: string;
|
|
70
|
+
resource?: string;
|
|
71
|
+
action?: string;
|
|
72
|
+
items?: readonly unknown[];
|
|
73
|
+
};
|
|
74
|
+
if (item.items?.length) {
|
|
75
|
+
walk(item.items);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (item.href) {
|
|
79
|
+
out.push({
|
|
80
|
+
href: item.href,
|
|
81
|
+
resource: item.resource,
|
|
82
|
+
action: item.action,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
for (const group of navigations) walk(group.items ?? []);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Mục không khai `resource` là trang công khai với người đã đăng nhập. */
|
|
92
|
+
function canOpen(session: Session, leaf: Leaf): boolean {
|
|
93
|
+
if (!leaf.resource) return true;
|
|
94
|
+
return checkPermission(session, leaf.resource, leaf.action ?? "view");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const withLocale = (locale: string, href: string) =>
|
|
98
|
+
href === "/" ? `/${locale}` : `/${locale}${href}`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* App nào CHƯA migrate cột `landing_path` thì bỏ qua bước 1, vẫn chạy bình
|
|
102
|
+
* thường bằng mục menu đầu tiên — hỏi cột không tồn tại là Prisma ném thẳng.
|
|
103
|
+
*/
|
|
104
|
+
const hasField = (prisma: LandingPrismaClient, field: string) =>
|
|
105
|
+
Boolean((prisma as any)?.role?.fields?.[field]);
|
|
106
|
+
|
|
107
|
+
const hasLandingPath = (prisma: LandingPrismaClient) =>
|
|
108
|
+
hasField(prisma, "landingPath");
|
|
109
|
+
|
|
110
|
+
export function createLandingPathResolver({
|
|
111
|
+
prisma,
|
|
112
|
+
navigations,
|
|
113
|
+
}: LandingPathDeps) {
|
|
114
|
+
return cache(
|
|
115
|
+
async (session: Session | null, locale: string): Promise<string> => {
|
|
116
|
+
if (!session?.user) return `/${locale}/sign-in`;
|
|
117
|
+
|
|
118
|
+
const leaves = menuLeaves(navigations);
|
|
119
|
+
const roleCodes = ((session.user as any).roles ?? []) as string[];
|
|
120
|
+
|
|
121
|
+
if (roleCodes.length > 0 && hasLandingPath(prisma)) {
|
|
122
|
+
const configured = await prisma.role.findMany({
|
|
123
|
+
where: { code: { in: roleCodes }, landingPath: { not: null } },
|
|
124
|
+
select: { landingPath: true },
|
|
125
|
+
// `rank` nhỏ = quyền lực cao. App nào chưa có cột `rank` (vinhhoa) thì
|
|
126
|
+
// bỏ orderBy — sắp theo cột không tồn tại là Prisma ném.
|
|
127
|
+
...(hasField(prisma, "rank") ? { orderBy: { rank: "asc" } } : {}),
|
|
128
|
+
});
|
|
129
|
+
for (const role of configured) {
|
|
130
|
+
const leaf = leaves.find((l) => l.href === role.landingPath);
|
|
131
|
+
if (leaf && canOpen(session, leaf))
|
|
132
|
+
return withLocale(locale, leaf.href);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const first = leaves.find((l) => canOpen(session, l));
|
|
137
|
+
return withLocale(locale, first?.href ?? "/");
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
ChevronDown,
|
|
43
43
|
ChevronRight,
|
|
44
44
|
Copy,
|
|
45
|
+
House,
|
|
45
46
|
Info,
|
|
46
47
|
LayoutTemplate,
|
|
47
48
|
Save,
|
|
@@ -76,6 +77,9 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
76
77
|
// Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
|
|
77
78
|
const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
78
79
|
|
|
80
|
+
/** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
|
|
81
|
+
const LANDING_AUTO = "__auto__"
|
|
82
|
+
|
|
79
83
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
80
84
|
export interface RoleTemplate {
|
|
81
85
|
code: string
|
|
@@ -136,6 +140,8 @@ export interface RoleFormPageProps {
|
|
|
136
140
|
description: string | undefined
|
|
137
141
|
status: string
|
|
138
142
|
permissions: string[]
|
|
143
|
+
/** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
|
|
144
|
+
landingPath?: string | null
|
|
139
145
|
}
|
|
140
146
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
141
147
|
roleTemplates?: RoleTemplate[]
|
|
@@ -189,6 +195,8 @@ export function RoleFormPage({
|
|
|
189
195
|
description: initialData?.description || "",
|
|
190
196
|
status: (initialData?.status || "active") as "active" | "inactive",
|
|
191
197
|
permissions: initialData?.permissions || ([] as string[]),
|
|
198
|
+
// "" = tự suy ra (mục đầu tiên trên menu mà vai trò này mở được).
|
|
199
|
+
landingPath: initialData?.landingPath || "",
|
|
192
200
|
})
|
|
193
201
|
|
|
194
202
|
const getLocalizedName = React.useCallback(
|
|
@@ -436,6 +444,30 @@ export function RoleFormPage({
|
|
|
436
444
|
})
|
|
437
445
|
}
|
|
438
446
|
|
|
447
|
+
// ── Trang mở đầu của vai trò ──────────────────────────────────────────
|
|
448
|
+
// Chỉ cho chọn trang mà vai trò này THẬT SỰ mở được: đặt trang chưa cấp
|
|
449
|
+
// quyền làm nơi hạ cánh là đá người ta vào tường ngay sau khi đăng nhập.
|
|
450
|
+
const landingOptions = React.useMemo(() => {
|
|
451
|
+
const byHref = new Map<string, { href: string; label: string }>()
|
|
452
|
+
tree.forEach((section) =>
|
|
453
|
+
section.items.forEach((i) => {
|
|
454
|
+
if (!i.href || i.note || !has(i.resource, "view")) return
|
|
455
|
+
// Hai mục cùng href (vd trang chi tiết gắn 2 chỗ) — giữ mục đầu.
|
|
456
|
+
if (!byHref.has(i.href))
|
|
457
|
+
byHref.set(i.href, {
|
|
458
|
+
href: i.href,
|
|
459
|
+
label: `${section.title} · ${i.title}`,
|
|
460
|
+
})
|
|
461
|
+
})
|
|
462
|
+
)
|
|
463
|
+
return Array.from(byHref.values())
|
|
464
|
+
}, [tree, has])
|
|
465
|
+
|
|
466
|
+
/** Đã cấu hình nhưng quyền bị gỡ sau đó — vẫn hiện để admin thấy mà sửa. */
|
|
467
|
+
const landingStale =
|
|
468
|
+
formData.landingPath !== "" &&
|
|
469
|
+
!landingOptions.some((o) => o.href === formData.landingPath)
|
|
470
|
+
|
|
439
471
|
const applyTemplate = (templateCode: string) => {
|
|
440
472
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
441
473
|
if (!tpl) return
|
|
@@ -854,6 +886,73 @@ export function RoleFormPage({
|
|
|
854
886
|
</div>
|
|
855
887
|
)}
|
|
856
888
|
|
|
889
|
+
{/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
|
|
890
|
+
<div className="flex items-center gap-1.5">
|
|
891
|
+
<House className="h-3.5 w-3.5 text-muted-foreground" />
|
|
892
|
+
<span className="text-xs text-muted-foreground">
|
|
893
|
+
Trang mở đầu:
|
|
894
|
+
</span>
|
|
895
|
+
<Select
|
|
896
|
+
value={formData.landingPath || LANDING_AUTO}
|
|
897
|
+
onValueChange={(v) =>
|
|
898
|
+
setFormData({
|
|
899
|
+
...formData,
|
|
900
|
+
landingPath: v === LANDING_AUTO ? "" : v,
|
|
901
|
+
})
|
|
902
|
+
}
|
|
903
|
+
>
|
|
904
|
+
<SelectTrigger
|
|
905
|
+
className={cn(
|
|
906
|
+
"h-8 w-52 rounded-md border border-border bg-card text-sm",
|
|
907
|
+
landingStale && "border-amber-500 text-amber-700 dark:text-amber-400"
|
|
908
|
+
)}
|
|
909
|
+
>
|
|
910
|
+
<SelectValue>
|
|
911
|
+
{!formData.landingPath
|
|
912
|
+
? "Tự động"
|
|
913
|
+
: landingStale
|
|
914
|
+
? `${formData.landingPath} · chưa cấp quyền`
|
|
915
|
+
: landingOptions.find(
|
|
916
|
+
(o) => o.href === formData.landingPath
|
|
917
|
+
)?.label}
|
|
918
|
+
</SelectValue>
|
|
919
|
+
</SelectTrigger>
|
|
920
|
+
<SelectContent className="rounded-lg">
|
|
921
|
+
<SelectItem value={LANDING_AUTO}>
|
|
922
|
+
<span className="flex flex-col">
|
|
923
|
+
<span>Tự động</span>
|
|
924
|
+
<span className="text-[11px] text-muted-foreground">
|
|
925
|
+
Menu đầu tiên vai trò này mở được
|
|
926
|
+
</span>
|
|
927
|
+
</span>
|
|
928
|
+
</SelectItem>
|
|
929
|
+
{/* Cấu hình cũ trỏ tới trang vừa bị gỡ quyền — giữ lại trong
|
|
930
|
+
danh sách, nếu không nó biến mất im lặng và admin tưởng
|
|
931
|
+
mình chưa từng đặt. */}
|
|
932
|
+
{landingStale && (
|
|
933
|
+
<SelectItem value={formData.landingPath}>
|
|
934
|
+
<span className="flex flex-col">
|
|
935
|
+
<span>{formData.landingPath}</span>
|
|
936
|
+
<span className="text-[11px] text-amber-600 dark:text-amber-400">
|
|
937
|
+
Chưa cấp quyền vào trang này
|
|
938
|
+
</span>
|
|
939
|
+
</span>
|
|
940
|
+
</SelectItem>
|
|
941
|
+
)}
|
|
942
|
+
{landingOptions.map((o) => (
|
|
943
|
+
<SelectItem key={o.href} value={o.href}>
|
|
944
|
+
<span className="flex flex-col">
|
|
945
|
+
<span>{o.label}</span>
|
|
946
|
+
<span className="text-[11px] text-muted-foreground">
|
|
947
|
+
{o.href}
|
|
948
|
+
</span>
|
|
949
|
+
</span>
|
|
950
|
+
</SelectItem>
|
|
951
|
+
))}
|
|
952
|
+
</SelectContent>
|
|
953
|
+
</Select>
|
|
954
|
+
</div>
|
|
955
|
+
|
|
857
956
|
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
858
957
|
<span className="font-semibold tabular-nums text-foreground">
|
|
859
958
|
{formData.permissions.length}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// });
|
|
15
15
|
|
|
16
16
|
import { getRolesData, type RoleServiceSchema } from "./role-service";
|
|
17
|
+
import { normalizeLandingPath } from "./landing-path";
|
|
17
18
|
import {
|
|
18
19
|
bumpPermissionsVersion,
|
|
19
20
|
getPermissionsVersion,
|
|
@@ -46,6 +47,18 @@ async function writePermissions(tx: any, roleCode: string, permissions: string[]
|
|
|
46
47
|
if (rows.length) await tx.rolePermission.createMany({ data: rows, skipDuplicates: true });
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
// ── Trang mở đầu theo vai trò (`Role.landingPath`) ─────────────────────────
|
|
51
|
+
// Cột BỔ SUNG: app nào chưa migrate thì handler phải im lặng bỏ qua, không ném
|
|
52
|
+
// — hai app đang chạy thật vẫn dùng core này. Dò bằng `prisma.role.fields`
|
|
53
|
+
// (Prisma sinh sẵn), rẻ và đúng với schema THẬT của app chứ không đoán.
|
|
54
|
+
const hasLandingPath = (prisma: any) => Boolean(prisma?.role?.fields?.landingPath);
|
|
55
|
+
|
|
56
|
+
/** Mảnh `data` để ghép vào create/update — rỗng khi app chưa có cột. */
|
|
57
|
+
function landingPathData(prisma: any, body: any) {
|
|
58
|
+
if (!hasLandingPath(prisma) || !("landingPath" in (body ?? {}))) return {};
|
|
59
|
+
return { landingPath: normalizeLandingPath(body.landingPath) };
|
|
60
|
+
}
|
|
61
|
+
|
|
49
62
|
// GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
|
|
50
63
|
export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
51
64
|
const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
|
|
@@ -87,7 +100,13 @@ export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
|
87
100
|
const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
|
|
88
101
|
const role = await prisma.$transaction(async (tx: any) => {
|
|
89
102
|
const created = await tx.role.create({
|
|
90
|
-
data: {
|
|
103
|
+
data: {
|
|
104
|
+
code,
|
|
105
|
+
name,
|
|
106
|
+
description: body.description ?? null,
|
|
107
|
+
status: body.status ?? "active",
|
|
108
|
+
...landingPathData(prisma, body),
|
|
109
|
+
},
|
|
91
110
|
});
|
|
92
111
|
await writePermissions(tx, created.code, permissions);
|
|
93
112
|
return created;
|
|
@@ -126,6 +145,7 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
126
145
|
name: role.name,
|
|
127
146
|
description: role.description ?? "",
|
|
128
147
|
status: role.status,
|
|
148
|
+
landingPath: role.landingPath ?? null,
|
|
129
149
|
permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
|
|
130
150
|
});
|
|
131
151
|
} catch (e) {
|
|
@@ -151,6 +171,7 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
151
171
|
name: (body.name ?? existing.name).trim(),
|
|
152
172
|
description: body.description ?? existing.description,
|
|
153
173
|
status: body.status ?? existing.status,
|
|
174
|
+
...landingPathData(prisma, body),
|
|
154
175
|
},
|
|
155
176
|
});
|
|
156
177
|
await writePermissions(tx, updated.code, permissions);
|
|
@@ -6,6 +6,12 @@ export const roleSchema = z.object({
|
|
|
6
6
|
description: z.string().optional(),
|
|
7
7
|
status: z.enum(["active", "inactive"]).default("active"),
|
|
8
8
|
permissions: z.array(z.string()).optional(),
|
|
9
|
+
/**
|
|
10
|
+
* Trang mở đầu của vai trò (`Role.landingPath`). Rỗng = "tự suy ra".
|
|
11
|
+
* Route phải lọc lại bằng `normalizeLandingPath` (@goerp/core/rbac/landing-path)
|
|
12
|
+
* trước khi ghi — schema chỉ nhận kiểu, không phán đường dẫn có an toàn không.
|
|
13
|
+
*/
|
|
14
|
+
landingPath: z.string().optional().nullable(),
|
|
9
15
|
});
|
|
10
16
|
|
|
11
17
|
export type RoleFormData = z.infer<typeof roleSchema>;
|
|
@@ -47,6 +47,18 @@ export interface SignInFormProps {
|
|
|
47
47
|
/** Ghi đè nhãn ô định danh (vd "Email hoặc mã nhân viên"). */
|
|
48
48
|
identifierLabel?: string;
|
|
49
49
|
identifierPlaceholder?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Endpoint trả `{ path }` — TRANG MỞ ĐẦU của chính người vừa đăng nhập.
|
|
52
|
+
*
|
|
53
|
+
* Chỉ hỏi được SAU khi đăng nhập xong (trước đó server chưa biết là ai), nên
|
|
54
|
+
* không thể gói vào `?redirectTo=`. Không khai prop này thì mọi thứ y như cũ:
|
|
55
|
+
* về `?redirectTo=` / NEXT_PUBLIC_HOME_PATHNAME / "/".
|
|
56
|
+
*
|
|
57
|
+
* Hỏng mạng hay trả bậy đều bỏ qua, vẫn về đường mặc định — chặn đăng nhập
|
|
58
|
+
* thành công rồi kẹt ở màn hình trắng là cái giá quá đắt cho một gợi ý điều
|
|
59
|
+
* hướng.
|
|
60
|
+
*/
|
|
61
|
+
landingEndpoint?: string;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
const IDENTIFIER_PRESETS = {
|
|
@@ -80,6 +92,7 @@ export function SignInForm({
|
|
|
80
92
|
identifier = "email",
|
|
81
93
|
identifierLabel,
|
|
82
94
|
identifierPlaceholder,
|
|
95
|
+
landingEndpoint,
|
|
83
96
|
}: SignInFormProps = {}) {
|
|
84
97
|
const preset = IDENTIFIER_PRESETS[identifier];
|
|
85
98
|
const schema = React.useMemo(
|
|
@@ -98,10 +111,9 @@ export function SignInForm({
|
|
|
98
111
|
const { clearAllCache } = useTabContentCache();
|
|
99
112
|
const { clearTabs } = useTabNavigation();
|
|
100
113
|
|
|
114
|
+
const explicitRedirect = searchParams.get("redirectTo");
|
|
101
115
|
const redirectPathname =
|
|
102
|
-
|
|
103
|
-
process.env.NEXT_PUBLIC_HOME_PATHNAME ||
|
|
104
|
-
"/";
|
|
116
|
+
explicitRedirect || process.env.NEXT_PUBLIC_HOME_PATHNAME || "/";
|
|
105
117
|
|
|
106
118
|
const form = useForm<SignInFormType>({
|
|
107
119
|
resolver: zodResolver(schema),
|
|
@@ -145,7 +157,27 @@ export function SignInForm({
|
|
|
145
157
|
// Best-effort only
|
|
146
158
|
}
|
|
147
159
|
|
|
148
|
-
|
|
160
|
+
// `?redirectTo=` là chỗ người dùng đang muốn tới trước khi bị đòi đăng
|
|
161
|
+
// nhập — luôn thắng trang mở đầu của vai trò.
|
|
162
|
+
let target = redirectPathname;
|
|
163
|
+
if (landingEndpoint && !explicitRedirect) {
|
|
164
|
+
try {
|
|
165
|
+
const res = await fetch(landingEndpoint);
|
|
166
|
+
const data = await res.json();
|
|
167
|
+
// Chỉ nhận đường dẫn nội bộ ("//host" là URL giao thức tương đối).
|
|
168
|
+
if (
|
|
169
|
+
typeof data?.path === "string" &&
|
|
170
|
+
data.path.startsWith("/") &&
|
|
171
|
+
!data.path.startsWith("//")
|
|
172
|
+
) {
|
|
173
|
+
target = data.path;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// Giữ đường mặc định — đăng nhập đã thành công rồi.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
router.push(target);
|
|
149
181
|
} catch (error) {
|
|
150
182
|
const rawMessage =
|
|
151
183
|
error instanceof Error ? error.message : "Đăng nhập thất bại";
|
|
@@ -1,13 +1,25 @@
|
|
|
1
|
+
import { buildNavigationForSession } from "@goerp/core/navigation"
|
|
2
|
+
|
|
1
3
|
import type { ReactNode } from "react"
|
|
2
4
|
|
|
3
5
|
import { dictionary } from "@/data/dictionary"
|
|
4
6
|
import { navigations } from "@/data/navigations"
|
|
7
|
+
import { getSession } from "@/lib/auth"
|
|
5
8
|
|
|
6
9
|
import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
|
|
7
10
|
|
|
8
|
-
export default function MainAreaLayout({
|
|
11
|
+
export default async function MainAreaLayout({
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
children: ReactNode
|
|
15
|
+
}) {
|
|
16
|
+
// Cây menu thô là bản đồ TOÀN BỘ hệ thống — truyền thẳng xuống layout thì ai
|
|
17
|
+
// đăng nhập cũng thấy đủ mục rồi bấm vào bị cổng gác đá về. Lọc theo quyền
|
|
18
|
+
// ngay ở server: mục không có quyền không nằm trong payload gửi xuống.
|
|
19
|
+
const navigation = buildNavigationForSession(navigations, await getSession())
|
|
20
|
+
|
|
9
21
|
return (
|
|
10
|
-
<MainLayoutWrapper dictionary={dictionary} navigation={
|
|
22
|
+
<MainLayoutWrapper dictionary={dictionary} navigation={navigation}>
|
|
11
23
|
{children}
|
|
12
24
|
</MainLayoutWrapper>
|
|
13
25
|
)
|