@goplusvn/core 0.1.77 → 0.1.79
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 +56 -0
- 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 +330 -0
- package/src/rbac/components/roles/role-card.tsx +36 -17
- package/src/rbac/landing-path.ts +140 -0
- package/src/rbac/pages/role-form-page.tsx +234 -4
- package/src/rbac/pages/role-list-page.tsx +5 -2
- package/src/rbac/role-service.ts +82 -1
- package/src/rbac/route-handlers.ts +202 -11
- package/src/rbac/types.ts +9 -0
- package/src/schemas/role.schema.ts +6 -0
- package/src/ui/auth/sign-in-form.tsx +36 -4
- package/src/user/user-service.ts +57 -0
- package/src/workspace/__tests__/workspace-delegation.test.ts +102 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +98 -0
- package/src/workspace/delegation.ts +148 -0
- package/src/workspace/index.ts +5 -0
- package/src/workspace/scope.ts +41 -3
- package/src/workspace/types.ts +25 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +14 -2
|
@@ -42,9 +42,11 @@ import {
|
|
|
42
42
|
ChevronDown,
|
|
43
43
|
ChevronRight,
|
|
44
44
|
Copy,
|
|
45
|
+
House,
|
|
45
46
|
Info,
|
|
46
47
|
LayoutTemplate,
|
|
47
48
|
Save,
|
|
49
|
+
ShieldCheck,
|
|
48
50
|
X,
|
|
49
51
|
} from "lucide-react"
|
|
50
52
|
|
|
@@ -76,6 +78,49 @@ const DEFAULT_ACTION_LABELS: Record<string, ActionLabelConfig> = {
|
|
|
76
78
|
// Thứ tự hiển thị action trong một trang: CRUD chuẩn trước, nghiệp vụ sau.
|
|
77
79
|
const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
78
80
|
|
|
81
|
+
/** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
|
|
82
|
+
const LANDING_AUTO = "__auto__"
|
|
83
|
+
|
|
84
|
+
/** Cùng lý do: "" = theo mặc định của app (`subtree`). */
|
|
85
|
+
const DATA_SCOPE_DEFAULT = "__default__"
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Thang 5 nấc của `@goerp/core/workspace` (Δ1). Chữ ở đây cố ý nói theo NGƯỜI
|
|
89
|
+
* DÙNG chứ không theo tên kỹ thuật: người tick quyền không biết "subtree" là gì.
|
|
90
|
+
*/
|
|
91
|
+
const DATA_SCOPE_OPTIONS: { value: string; label: string; hint: string }[] = [
|
|
92
|
+
{
|
|
93
|
+
value: DATA_SCOPE_DEFAULT,
|
|
94
|
+
label: "Theo mặc định",
|
|
95
|
+
hint: "Workspace được gán + cấp dưới",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
value: "all",
|
|
99
|
+
label: "Tất cả dữ liệu",
|
|
100
|
+
hint: "Không cô lập — thấy mọi workspace",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
value: "subtree",
|
|
104
|
+
label: "Workspace + cấp dưới",
|
|
105
|
+
hint: "Đơn vị được gán và các bộ phận con",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
value: "workspace",
|
|
109
|
+
label: "Chỉ workspace được gán",
|
|
110
|
+
hint: "Không thấy dữ liệu của bộ phận con",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
value: "own",
|
|
114
|
+
label: "Chỉ bản ghi của mình",
|
|
115
|
+
hint: "Cần app khai cột chủ sở hữu; thiếu thì không thấy gì",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
value: "none",
|
|
119
|
+
label: "Không thấy dữ liệu",
|
|
120
|
+
hint: "Vai trò chỉ thao tác, không đọc danh sách",
|
|
121
|
+
},
|
|
122
|
+
]
|
|
123
|
+
|
|
79
124
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
80
125
|
export interface RoleTemplate {
|
|
81
126
|
code: string
|
|
@@ -136,6 +181,10 @@ export interface RoleFormPageProps {
|
|
|
136
181
|
description: string | undefined
|
|
137
182
|
status: string
|
|
138
183
|
permissions: string[]
|
|
184
|
+
/** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
|
|
185
|
+
landingPath?: string | null
|
|
186
|
+
/** Nấc cô lập dữ liệu. Null = mặc định của app (`subtree`). */
|
|
187
|
+
dataScope?: string | null
|
|
139
188
|
}
|
|
140
189
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
141
190
|
roleTemplates?: RoleTemplate[]
|
|
@@ -145,6 +194,24 @@ export interface RoleFormPageProps {
|
|
|
145
194
|
menuTree?: MenuTreeSection[]
|
|
146
195
|
/** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
|
|
147
196
|
historyEndpoint?: string
|
|
197
|
+
/**
|
|
198
|
+
* TRẦN QUYỀN của người đang tạo/sửa vai trò, dạng `"action:resource"`.
|
|
199
|
+
*
|
|
200
|
+
* Bỏ trống = không giới hạn (app một tổ chức, hành vi cũ). Truyền vào thì ma
|
|
201
|
+
* trận chỉ hiện thứ họ tự có — cùng luật D2 mà API đang chặn, để người dùng
|
|
202
|
+
* không tick đủ thứ rồi mới ăn 403 lúc bấm lưu.
|
|
203
|
+
*
|
|
204
|
+
* Quyền đã cấp sẵn nằm ngoài trần thì KHÔNG hiện và cũng KHÔNG bị xoá; API sẽ
|
|
205
|
+
* từ chối lưu — thà chặn còn hơn âm thầm tước quyền của vai trò.
|
|
206
|
+
*/
|
|
207
|
+
grantablePermissions?: string[]
|
|
208
|
+
/**
|
|
209
|
+
* Hiện ô CÔ LẬP DỮ LIỆU (`Role.dataScope`). Chỉ bật cho vai vận hành: quản
|
|
210
|
+
* trị viên đơn vị mà tự đặt được nấc này thì họ chỉ cần tạo một vai trò "Tất
|
|
211
|
+
* cả dữ liệu" rồi tự gán, và việc cô lập thành ra tuỳ chọn. API cũng bỏ qua
|
|
212
|
+
* trường này nếu người gửi không phải vai vận hành — đây chỉ là lớp hiển thị.
|
|
213
|
+
*/
|
|
214
|
+
showDataScope?: boolean
|
|
148
215
|
}
|
|
149
216
|
|
|
150
217
|
export function RoleFormPage({
|
|
@@ -161,9 +228,16 @@ export function RoleFormPage({
|
|
|
161
228
|
resourcePages,
|
|
162
229
|
menuTree,
|
|
163
230
|
historyEndpoint,
|
|
231
|
+
grantablePermissions,
|
|
232
|
+
showDataScope,
|
|
164
233
|
}: RoleFormPageProps) {
|
|
165
234
|
const router = useRouter()
|
|
166
235
|
|
|
236
|
+
const grantable = React.useMemo(
|
|
237
|
+
() => (grantablePermissions ? new Set(grantablePermissions) : null),
|
|
238
|
+
[grantablePermissions]
|
|
239
|
+
)
|
|
240
|
+
|
|
167
241
|
const mergedActionLabels = React.useMemo(
|
|
168
242
|
() => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
|
|
169
243
|
[actionLabels]
|
|
@@ -189,8 +263,16 @@ export function RoleFormPage({
|
|
|
189
263
|
description: initialData?.description || "",
|
|
190
264
|
status: (initialData?.status || "active") as "active" | "inactive",
|
|
191
265
|
permissions: initialData?.permissions || ([] as string[]),
|
|
266
|
+
// "" = tự suy ra (mục đầu tiên trên menu mà vai trò này mở được).
|
|
267
|
+
landingPath: initialData?.landingPath || "",
|
|
192
268
|
})
|
|
193
269
|
|
|
270
|
+
// Tách khỏi `formData` vì payload gửi thẳng cả object: vai trò không được
|
|
271
|
+
// phép đặt nấc này thì trường phải KHÔNG có mặt, chứ không phải gửi rỗng.
|
|
272
|
+
const [dataScope, setDataScope] = React.useState<string>(
|
|
273
|
+
initialData?.dataScope ?? ""
|
|
274
|
+
)
|
|
275
|
+
|
|
194
276
|
const getLocalizedName = React.useCallback(
|
|
195
277
|
(key: string | null) => {
|
|
196
278
|
if (!key) return ""
|
|
@@ -250,6 +332,17 @@ export function RoleFormPage({
|
|
|
250
332
|
} catch {
|
|
251
333
|
allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
|
|
252
334
|
}
|
|
335
|
+
// TRẦN QUYỀN (D2): người tạo vai trò không cấp được thứ chính mình không
|
|
336
|
+
// có. Cắt ngay ở `allowed` nên mọi thứ dựng trên nó — hàng thao tác, tick
|
|
337
|
+
// trang, mẫu vai trò, phạm vi chi nhánh — tự động tuân theo; lọc riêng ở
|
|
338
|
+
// từng chỗ vẽ là kiểu bỏ sót một chỗ rồi không ai biết.
|
|
339
|
+
if (grantable) {
|
|
340
|
+
allowed = allowed.filter((a) => grantable.has(`${a}:${resource.code}`))
|
|
341
|
+
}
|
|
342
|
+
// Trang không cấp được gì thì biến khỏi ma trận luôn: một dòng có mỗi cái
|
|
343
|
+
// tick trang bị khoá chỉ tổ gây hiểu lầm là "đang bật".
|
|
344
|
+
if (grantable && allowed.length === 0) return
|
|
345
|
+
|
|
253
346
|
map.set(resource.code, {
|
|
254
347
|
name: getLocalizedName(resource.name),
|
|
255
348
|
icon: resource.icon,
|
|
@@ -257,7 +350,7 @@ export function RoleFormPage({
|
|
|
257
350
|
})
|
|
258
351
|
})
|
|
259
352
|
return map
|
|
260
|
-
}, [resources, actions, getLocalizedName])
|
|
353
|
+
}, [resources, actions, getLocalizedName, grantable])
|
|
261
354
|
|
|
262
355
|
/** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
|
|
263
356
|
* = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
|
|
@@ -436,12 +529,44 @@ export function RoleFormPage({
|
|
|
436
529
|
})
|
|
437
530
|
}
|
|
438
531
|
|
|
532
|
+
// ── Trang mở đầu của vai trò ──────────────────────────────────────────
|
|
533
|
+
// Chỉ cho chọn trang mà vai trò này THẬT SỰ mở được: đặt trang chưa cấp
|
|
534
|
+
// quyền làm nơi hạ cánh là đá người ta vào tường ngay sau khi đăng nhập.
|
|
535
|
+
const landingOptions = React.useMemo(() => {
|
|
536
|
+
const byHref = new Map<string, { href: string; label: string }>()
|
|
537
|
+
tree.forEach((section) =>
|
|
538
|
+
section.items.forEach((i) => {
|
|
539
|
+
if (!i.href || i.note || !has(i.resource, "view")) return
|
|
540
|
+
// Hai mục cùng href (vd trang chi tiết gắn 2 chỗ) — giữ mục đầu.
|
|
541
|
+
if (!byHref.has(i.href))
|
|
542
|
+
byHref.set(i.href, {
|
|
543
|
+
href: i.href,
|
|
544
|
+
label: `${section.title} · ${i.title}`,
|
|
545
|
+
})
|
|
546
|
+
})
|
|
547
|
+
)
|
|
548
|
+
return Array.from(byHref.values())
|
|
549
|
+
}, [tree, has])
|
|
550
|
+
|
|
551
|
+
/** Đã cấu hình nhưng quyền bị gỡ sau đó — vẫn hiện để admin thấy mà sửa. */
|
|
552
|
+
const landingStale =
|
|
553
|
+
formData.landingPath !== "" &&
|
|
554
|
+
!landingOptions.some((o) => o.href === formData.landingPath)
|
|
555
|
+
|
|
439
556
|
const applyTemplate = (templateCode: string) => {
|
|
440
557
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
441
558
|
if (!tpl) return
|
|
442
|
-
|
|
559
|
+
// Mẫu là bộ quyền dựng sẵn cho TOÀN hệ thống — kẹp lại theo trần quyền của
|
|
560
|
+
// người đang tạo, không thì bấm một cái là vượt trần và API từ chối.
|
|
561
|
+
const applied = grantable
|
|
562
|
+
? tpl.permissions.filter((p) => grantable.has(p))
|
|
563
|
+
: [...tpl.permissions]
|
|
564
|
+
setFormData((prev) => ({ ...prev, permissions: applied }))
|
|
443
565
|
toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
|
|
444
|
-
description:
|
|
566
|
+
description:
|
|
567
|
+
applied.length === tpl.permissions.length
|
|
568
|
+
? `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`
|
|
569
|
+
: `${applied.length}/${tpl.permissions.length} quyền (phần còn lại ngoài phạm vi của bạn).`,
|
|
445
570
|
})
|
|
446
571
|
}
|
|
447
572
|
|
|
@@ -455,7 +580,9 @@ export function RoleFormPage({
|
|
|
455
580
|
const res = await fetch(url, {
|
|
456
581
|
method,
|
|
457
582
|
headers: { "Content-Type": "application/json" },
|
|
458
|
-
body: JSON.stringify(
|
|
583
|
+
body: JSON.stringify(
|
|
584
|
+
showDataScope ? { ...formData, dataScope: dataScope || null } : formData
|
|
585
|
+
),
|
|
459
586
|
})
|
|
460
587
|
if (!res.ok) {
|
|
461
588
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
@@ -854,6 +981,109 @@ export function RoleFormPage({
|
|
|
854
981
|
</div>
|
|
855
982
|
)}
|
|
856
983
|
|
|
984
|
+
{/* Cô lập dữ liệu — vai trò này nhìn thấy dữ liệu của những ai */}
|
|
985
|
+
{showDataScope && (
|
|
986
|
+
<div className="flex items-center gap-1.5">
|
|
987
|
+
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
|
988
|
+
<span className="text-xs text-muted-foreground">
|
|
989
|
+
Cô lập dữ liệu:
|
|
990
|
+
</span>
|
|
991
|
+
<Select
|
|
992
|
+
value={dataScope || DATA_SCOPE_DEFAULT}
|
|
993
|
+
onValueChange={(v) =>
|
|
994
|
+
setDataScope(v === DATA_SCOPE_DEFAULT ? "" : v)
|
|
995
|
+
}
|
|
996
|
+
>
|
|
997
|
+
<SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
|
|
998
|
+
<SelectValue>
|
|
999
|
+
{DATA_SCOPE_OPTIONS.find(
|
|
1000
|
+
(o) => o.value === (dataScope || DATA_SCOPE_DEFAULT)
|
|
1001
|
+
)?.label ?? dataScope}
|
|
1002
|
+
</SelectValue>
|
|
1003
|
+
</SelectTrigger>
|
|
1004
|
+
<SelectContent className="rounded-lg">
|
|
1005
|
+
{DATA_SCOPE_OPTIONS.map((o) => (
|
|
1006
|
+
<SelectItem key={o.value} value={o.value}>
|
|
1007
|
+
<span className="flex flex-col">
|
|
1008
|
+
<span>{o.label}</span>
|
|
1009
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1010
|
+
{o.hint}
|
|
1011
|
+
</span>
|
|
1012
|
+
</span>
|
|
1013
|
+
</SelectItem>
|
|
1014
|
+
))}
|
|
1015
|
+
</SelectContent>
|
|
1016
|
+
</Select>
|
|
1017
|
+
</div>
|
|
1018
|
+
)}
|
|
1019
|
+
|
|
1020
|
+
{/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
|
|
1021
|
+
<div className="flex items-center gap-1.5">
|
|
1022
|
+
<House className="h-3.5 w-3.5 text-muted-foreground" />
|
|
1023
|
+
<span className="text-xs text-muted-foreground">
|
|
1024
|
+
Trang mở đầu:
|
|
1025
|
+
</span>
|
|
1026
|
+
<Select
|
|
1027
|
+
value={formData.landingPath || LANDING_AUTO}
|
|
1028
|
+
onValueChange={(v) =>
|
|
1029
|
+
setFormData({
|
|
1030
|
+
...formData,
|
|
1031
|
+
landingPath: v === LANDING_AUTO ? "" : v,
|
|
1032
|
+
})
|
|
1033
|
+
}
|
|
1034
|
+
>
|
|
1035
|
+
<SelectTrigger
|
|
1036
|
+
className={cn(
|
|
1037
|
+
"h-8 w-52 rounded-md border border-border bg-card text-sm",
|
|
1038
|
+
landingStale && "border-amber-500 text-amber-700 dark:text-amber-400"
|
|
1039
|
+
)}
|
|
1040
|
+
>
|
|
1041
|
+
<SelectValue>
|
|
1042
|
+
{!formData.landingPath
|
|
1043
|
+
? "Tự động"
|
|
1044
|
+
: landingStale
|
|
1045
|
+
? `${formData.landingPath} · chưa cấp quyền`
|
|
1046
|
+
: landingOptions.find(
|
|
1047
|
+
(o) => o.href === formData.landingPath
|
|
1048
|
+
)?.label}
|
|
1049
|
+
</SelectValue>
|
|
1050
|
+
</SelectTrigger>
|
|
1051
|
+
<SelectContent className="rounded-lg">
|
|
1052
|
+
<SelectItem value={LANDING_AUTO}>
|
|
1053
|
+
<span className="flex flex-col">
|
|
1054
|
+
<span>Tự động</span>
|
|
1055
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1056
|
+
Menu đầu tiên vai trò này mở được
|
|
1057
|
+
</span>
|
|
1058
|
+
</span>
|
|
1059
|
+
</SelectItem>
|
|
1060
|
+
{/* Cấu hình cũ trỏ tới trang vừa bị gỡ quyền — giữ lại trong
|
|
1061
|
+
danh sách, nếu không nó biến mất im lặng và admin tưởng
|
|
1062
|
+
mình chưa từng đặt. */}
|
|
1063
|
+
{landingStale && (
|
|
1064
|
+
<SelectItem value={formData.landingPath}>
|
|
1065
|
+
<span className="flex flex-col">
|
|
1066
|
+
<span>{formData.landingPath}</span>
|
|
1067
|
+
<span className="text-[11px] text-amber-600 dark:text-amber-400">
|
|
1068
|
+
Chưa cấp quyền vào trang này
|
|
1069
|
+
</span>
|
|
1070
|
+
</span>
|
|
1071
|
+
</SelectItem>
|
|
1072
|
+
)}
|
|
1073
|
+
{landingOptions.map((o) => (
|
|
1074
|
+
<SelectItem key={o.href} value={o.href}>
|
|
1075
|
+
<span className="flex flex-col">
|
|
1076
|
+
<span>{o.label}</span>
|
|
1077
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1078
|
+
{o.href}
|
|
1079
|
+
</span>
|
|
1080
|
+
</span>
|
|
1081
|
+
</SelectItem>
|
|
1082
|
+
))}
|
|
1083
|
+
</SelectContent>
|
|
1084
|
+
</Select>
|
|
1085
|
+
</div>
|
|
1086
|
+
|
|
857
1087
|
<p className="ml-auto whitespace-nowrap text-xs text-muted-foreground">
|
|
858
1088
|
<span className="font-semibold tabular-nums text-foreground">
|
|
859
1089
|
{formData.permissions.length}
|
|
@@ -174,11 +174,14 @@ function RolePermissionHeatmap({
|
|
|
174
174
|
{groups.map((g) => {
|
|
175
175
|
const { granted, total } = cellFor(role, g)
|
|
176
176
|
const ratio = total > 0 ? granted / total : 0
|
|
177
|
+
// Ô nhiệt là lối tắt sang trang sửa — vai trò không sửa được
|
|
178
|
+
// thì đừng mời bấm rồi để trang sửa từ chối.
|
|
179
|
+
const editable = canUpdate && role.canManage !== false
|
|
177
180
|
return (
|
|
178
181
|
<td key={g} className="px-1 py-1 text-center">
|
|
179
182
|
<button
|
|
180
183
|
type="button"
|
|
181
|
-
disabled={!
|
|
184
|
+
disabled={!editable}
|
|
182
185
|
onClick={() =>
|
|
183
186
|
router.push(`/${lang}/roles/${role.id}/edit`)
|
|
184
187
|
}
|
|
@@ -186,7 +189,7 @@ function RolePermissionHeatmap({
|
|
|
186
189
|
className={cn(
|
|
187
190
|
"mx-auto block h-5 w-10 rounded",
|
|
188
191
|
shade(ratio),
|
|
189
|
-
|
|
192
|
+
editable && "cursor-pointer hover:ring-2 hover:ring-primary/40"
|
|
190
193
|
)}
|
|
191
194
|
aria-label={`${role.name} — ${g}: ${granted}/${total} quyền`}
|
|
192
195
|
/>
|
package/src/rbac/role-service.ts
CHANGED
|
@@ -35,6 +35,32 @@ export type RoleServiceSchema = {
|
|
|
35
35
|
roleTimestamps?: boolean;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* PHẠM VI vai trò — chỉ dành cho app đã bật multi-tenant (cột `Role.workspaceId`).
|
|
40
|
+
*
|
|
41
|
+
* Không truyền thì hàm chạy y như trước: mọi vai trò, mọi người dùng nhúng bên
|
|
42
|
+
* trong. App một-đơn-vị (vinhhoa) cứ để trống.
|
|
43
|
+
*
|
|
44
|
+
* Vai trò `workspaceId = null` là vai trò DÙNG CHUNG do cấp vận hành dựng
|
|
45
|
+
* ("Nhân viên", "Bếp"): quản trị viên đơn vị vẫn THẤY để biết mình đang gán gì,
|
|
46
|
+
* nhưng `canManage` trả `false` nên nút sửa/xoá tắt.
|
|
47
|
+
*/
|
|
48
|
+
export type RoleQueryScope = {
|
|
49
|
+
/** Nhóm vận hành — thấy tất cả, sửa tất cả. */
|
|
50
|
+
canManageAll?: boolean;
|
|
51
|
+
/** Các workspace người này quản trị (`scope.adminIds`). */
|
|
52
|
+
adminWorkspaceIds?: readonly string[];
|
|
53
|
+
/** Có kèm vai trò dùng chung (`workspaceId = null`) không. Mặc định có. */
|
|
54
|
+
includeShared?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Điều kiện kẹp danh sách NGƯỜI DÙNG nhúng trong mỗi vai trò — dạng where của
|
|
57
|
+
* bảng User (thường là `memberScopeWhere(scope)`). Thiếu nó thì trang vai trò
|
|
58
|
+
* là cửa hậu xem toàn bộ danh bạ: mỗi thẻ vai trò in kèm avatar + email của
|
|
59
|
+
* mọi người mang vai trò đó, kể cả đơn vị khác.
|
|
60
|
+
*/
|
|
61
|
+
userWhere?: any;
|
|
62
|
+
};
|
|
63
|
+
|
|
38
64
|
export type RoleData = {
|
|
39
65
|
id: string;
|
|
40
66
|
name: string;
|
|
@@ -54,6 +80,12 @@ export type RoleData = {
|
|
|
54
80
|
updatedAt: string;
|
|
55
81
|
createdBy?: string;
|
|
56
82
|
updatedBy?: string;
|
|
83
|
+
/** Chỉ có ở app multi-tenant. `null` = vai trò dùng chung. */
|
|
84
|
+
workspaceId?: string | null;
|
|
85
|
+
isSystem?: boolean;
|
|
86
|
+
rank?: number;
|
|
87
|
+
/** Người đang xem có sửa/xoá được vai trò này không. `undefined` = không xét. */
|
|
88
|
+
canManage?: boolean;
|
|
57
89
|
};
|
|
58
90
|
|
|
59
91
|
// Interface for the DB client injected into the service
|
|
@@ -68,6 +100,12 @@ export interface RolePrismaClient {
|
|
|
68
100
|
take: number;
|
|
69
101
|
include: any;
|
|
70
102
|
}) => Promise<any[]>;
|
|
103
|
+
/**
|
|
104
|
+
* Prisma sinh sẵn — dùng để dò cột tuỳ chọn (workspaceId/rank/isSystem).
|
|
105
|
+
* Kiểu `object` chứ không `Record<string, unknown>`: `RoleFieldRefs` của
|
|
106
|
+
* Prisma không có index signature nên app truyền thẳng `db` sẽ đỏ.
|
|
107
|
+
*/
|
|
108
|
+
fields?: object;
|
|
71
109
|
};
|
|
72
110
|
}
|
|
73
111
|
|
|
@@ -75,6 +113,11 @@ export interface RolePrismaClient {
|
|
|
75
113
|
// Service Logic
|
|
76
114
|
// ============================================================================
|
|
77
115
|
|
|
116
|
+
/** App chưa có cột thì bỏ qua điều kiện tương ứng — hỏi cột không tồn tại là Prisma ném. */
|
|
117
|
+
const hasRoleField = (db: RolePrismaClient, field: string) =>
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
Boolean((db as any)?.role?.fields?.[field]);
|
|
120
|
+
|
|
78
121
|
/**
|
|
79
122
|
* Get roles data with pagination and filtering
|
|
80
123
|
* Generic core implementation using injected DB client
|
|
@@ -86,6 +129,7 @@ export async function getRolesData(
|
|
|
86
129
|
db: RolePrismaClient,
|
|
87
130
|
params: RoleFilters = {},
|
|
88
131
|
schema: RoleServiceSchema = {},
|
|
132
|
+
scope?: RoleQueryScope,
|
|
89
133
|
): Promise<{
|
|
90
134
|
total: number;
|
|
91
135
|
page: number;
|
|
@@ -117,6 +161,22 @@ export async function getRolesData(
|
|
|
117
161
|
whereConditions.push({ status });
|
|
118
162
|
}
|
|
119
163
|
|
|
164
|
+
// Kẹp phạm vi — chỉ khi app THẬT SỰ có cột `workspaceId` trên Role. Dò bằng
|
|
165
|
+
// `role.fields` (Prisma sinh sẵn) thay vì đoán theo tên app.
|
|
166
|
+
const hasWorkspaceField = hasRoleField(db, "workspaceId");
|
|
167
|
+
const adminIds = scope?.adminWorkspaceIds ?? [];
|
|
168
|
+
const scoped = Boolean(scope) && !scope?.canManageAll && hasWorkspaceField;
|
|
169
|
+
|
|
170
|
+
if (scoped) {
|
|
171
|
+
const branches: any[] = [];
|
|
172
|
+
if (adminIds.length > 0) branches.push({ workspaceId: { in: [...adminIds] } });
|
|
173
|
+
if (scope?.includeShared !== false) branches.push({ workspaceId: null });
|
|
174
|
+
// Không quản trị đơn vị nào và không lấy vai trò dùng chung ⇒ không thấy gì.
|
|
175
|
+
// Viết tường minh chứ không để `OR: []` — dựa vào cách Prisma diễn giải mảng
|
|
176
|
+
// rỗng là dựa vào chi tiết cài đặt.
|
|
177
|
+
whereConditions.push(branches.length > 0 ? { OR: branches } : { id: { in: [] } });
|
|
178
|
+
}
|
|
179
|
+
|
|
120
180
|
const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
|
|
121
181
|
|
|
122
182
|
// Build the user select from the (possibly overridden) field names.
|
|
@@ -132,7 +192,10 @@ export async function getRolesData(
|
|
|
132
192
|
skip: (page - 1) * pageSize,
|
|
133
193
|
take: pageSize,
|
|
134
194
|
include: {
|
|
135
|
-
userRoles: {
|
|
195
|
+
userRoles: {
|
|
196
|
+
...(scope?.userWhere ? { where: { user: scope.userWhere } } : {}),
|
|
197
|
+
include: { user: { select: userSelect } },
|
|
198
|
+
},
|
|
136
199
|
rolePermissions: {
|
|
137
200
|
include: {
|
|
138
201
|
resource: { select: { code: true, name: true, icon: true } },
|
|
@@ -154,6 +217,8 @@ export async function getRolesData(
|
|
|
154
217
|
permissions: role.rolePermissions.map(
|
|
155
218
|
(rp: any) => `${rp.actionCode}:${rp.resourceCode}`,
|
|
156
219
|
),
|
|
220
|
+
// Đếm theo danh sách ĐÃ kẹp: người quản trị đơn vị thấy "3 người dùng" là 3
|
|
221
|
+
// người trong đơn vị mình, không phải tổng toàn hệ thống.
|
|
157
222
|
usersCount: role.userRoles.length,
|
|
158
223
|
users: role.userRoles.map((ur: any) => ({
|
|
159
224
|
id: ur.user.id,
|
|
@@ -166,6 +231,22 @@ export async function getRolesData(
|
|
|
166
231
|
updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
|
|
167
232
|
createdBy: role.createdBy || undefined,
|
|
168
233
|
updatedBy: role.updatedBy || undefined,
|
|
234
|
+
...(hasWorkspaceField
|
|
235
|
+
? {
|
|
236
|
+
workspaceId: role.workspaceId ?? null,
|
|
237
|
+
isSystem: Boolean(role.isSystem),
|
|
238
|
+
rank: typeof role.rank === "number" ? role.rank : undefined,
|
|
239
|
+
// Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
|
|
240
|
+
// xem được, không sửa được. Cùng luật với `canManageRole` bên
|
|
241
|
+
// `@goerp/core/workspace` — đây chỉ là bản chiếu xuống UI.
|
|
242
|
+
canManage: scope
|
|
243
|
+
? scope.canManageAll ||
|
|
244
|
+
(!role.isSystem &&
|
|
245
|
+
Boolean(role.workspaceId) &&
|
|
246
|
+
adminIds.includes(role.workspaceId))
|
|
247
|
+
: undefined,
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
169
250
|
}));
|
|
170
251
|
|
|
171
252
|
return { total, page, pageSize, items: transformedItems };
|