@goplusvn/core 0.1.78 → 0.1.81
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 +125 -0
- package/package.json +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +252 -0
- package/src/rbac/components/roles/role-card.tsx +60 -17
- package/src/rbac/pages/role-form-page.tsx +218 -4
- package/src/rbac/pages/role-list-page.tsx +5 -2
- package/src/rbac/role-service.ts +109 -1
- package/src/rbac/route-handlers.ts +192 -10
- package/src/rbac/types.ts +15 -0
- package/src/user/user-service.ts +57 -0
- package/src/workspace/__tests__/workspace-delegation.test.ts +102 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +73 -6
- package/src/workspace/__tests__/workspace-scope.test.ts +98 -0
- package/src/workspace/components/workspace-member-picker-dialog.tsx +492 -0
- package/src/workspace/components/workspace-members-panel.tsx +63 -161
- package/src/workspace/components/workspace-tree-panel.tsx +369 -0
- package/src/workspace/delegation.ts +148 -0
- package/src/workspace/index.ts +18 -3
- package/src/workspace/pages/workspace-list-page.tsx +106 -46
- package/src/workspace/route-handlers.ts +218 -4
- package/src/workspace/scope.ts +41 -3
- package/src/workspace/service.ts +22 -0
- package/src/workspace/types.ts +25 -0
- package/src/workspace/components/workspace-org-block.tsx +0 -293
|
@@ -45,7 +45,9 @@ import {
|
|
|
45
45
|
House,
|
|
46
46
|
Info,
|
|
47
47
|
LayoutTemplate,
|
|
48
|
+
Network,
|
|
48
49
|
Save,
|
|
50
|
+
ShieldCheck,
|
|
49
51
|
X,
|
|
50
52
|
} from "lucide-react"
|
|
51
53
|
|
|
@@ -80,6 +82,49 @@ const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
|
80
82
|
/** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
|
|
81
83
|
const LANDING_AUTO = "__auto__"
|
|
82
84
|
|
|
85
|
+
/** Cùng lý do: "" = theo mặc định của app (`subtree`). */
|
|
86
|
+
const DATA_SCOPE_DEFAULT = "__default__"
|
|
87
|
+
|
|
88
|
+
/** Cùng lý do: "" = vai trò DÙNG CHUNG (`workspaceId = null`). */
|
|
89
|
+
const WORKSPACE_SHARED = "__shared__"
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Thang 5 nấc của `@goerp/core/workspace` (Δ1). Chữ ở đây cố ý nói theo NGƯỜI
|
|
93
|
+
* DÙNG chứ không theo tên kỹ thuật: người tick quyền không biết "subtree" là gì.
|
|
94
|
+
*/
|
|
95
|
+
const DATA_SCOPE_OPTIONS: { value: string; label: string; hint: string }[] = [
|
|
96
|
+
{
|
|
97
|
+
value: DATA_SCOPE_DEFAULT,
|
|
98
|
+
label: "Theo mặc định",
|
|
99
|
+
hint: "Workspace được gán + cấp dưới",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
value: "all",
|
|
103
|
+
label: "Tất cả dữ liệu",
|
|
104
|
+
hint: "Không cô lập — thấy mọi workspace",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
value: "subtree",
|
|
108
|
+
label: "Workspace + cấp dưới",
|
|
109
|
+
hint: "Đơn vị được gán và các bộ phận con",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
value: "workspace",
|
|
113
|
+
label: "Chỉ workspace được gán",
|
|
114
|
+
hint: "Không thấy dữ liệu của bộ phận con",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
value: "own",
|
|
118
|
+
label: "Chỉ bản ghi của mình",
|
|
119
|
+
hint: "Cần app khai cột chủ sở hữu; thiếu thì không thấy gì",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
value: "none",
|
|
123
|
+
label: "Không thấy dữ liệu",
|
|
124
|
+
hint: "Vai trò chỉ thao tác, không đọc danh sách",
|
|
125
|
+
},
|
|
126
|
+
]
|
|
127
|
+
|
|
83
128
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
84
129
|
export interface RoleTemplate {
|
|
85
130
|
code: string
|
|
@@ -142,6 +187,10 @@ export interface RoleFormPageProps {
|
|
|
142
187
|
permissions: string[]
|
|
143
188
|
/** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
|
|
144
189
|
landingPath?: string | null
|
|
190
|
+
/** Nấc cô lập dữ liệu. Null = mặc định của app (`subtree`). */
|
|
191
|
+
dataScope?: string | null
|
|
192
|
+
/** Đơn vị sở hữu vai trò. Null = vai trò dùng chung. */
|
|
193
|
+
workspaceId?: string | null
|
|
145
194
|
}
|
|
146
195
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
147
196
|
roleTemplates?: RoleTemplate[]
|
|
@@ -151,6 +200,40 @@ export interface RoleFormPageProps {
|
|
|
151
200
|
menuTree?: MenuTreeSection[]
|
|
152
201
|
/** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
|
|
153
202
|
historyEndpoint?: string
|
|
203
|
+
/**
|
|
204
|
+
* TRẦN QUYỀN của người đang tạo/sửa vai trò, dạng `"action:resource"`.
|
|
205
|
+
*
|
|
206
|
+
* 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
|
|
207
|
+
* trận chỉ hiện thứ họ tự có — cùng luật D2 mà API đang chặn, để người dùng
|
|
208
|
+
* không tick đủ thứ rồi mới ăn 403 lúc bấm lưu.
|
|
209
|
+
*
|
|
210
|
+
* 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ẽ
|
|
211
|
+
* từ chối lưu — thà chặn còn hơn âm thầm tước quyền của vai trò.
|
|
212
|
+
*/
|
|
213
|
+
grantablePermissions?: string[]
|
|
214
|
+
/**
|
|
215
|
+
* Hiện ô CÔ LẬP DỮ LIỆU (`Role.dataScope`). Chỉ bật cho vai vận hành: quản
|
|
216
|
+
* 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
|
|
217
|
+
* 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
|
|
218
|
+
* 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ị.
|
|
219
|
+
*/
|
|
220
|
+
showDataScope?: boolean
|
|
221
|
+
/**
|
|
222
|
+
* Các ĐƠN VỊ được phép đặt làm chủ sở hữu vai trò (`Role.workspaceId`). Bỏ
|
|
223
|
+
* trống ⇒ không hiện ô, payload không mang trường này, engine tự đóng dấu theo
|
|
224
|
+
* nhánh người tạo — đúng hành vi trước khi có ô.
|
|
225
|
+
*
|
|
226
|
+
* Hai trường dễ lẫn, đừng gộp:
|
|
227
|
+
* - `workspaceId` = vai trò này CỦA ĐƠN VỊ NÀO, tức AI ĐƯỢC SỬA nó;
|
|
228
|
+
* - `dataScope` = người MANG vai trò này nhìn thấy dữ liệu tới đâu.
|
|
229
|
+
* Một vai trò dùng chung (`null`) vẫn có thể cô lập chặt, và ngược lại.
|
|
230
|
+
*
|
|
231
|
+
* `allowShared` chỉ bật cho vai vận hành: quản trị viên đơn vị mà tạo được vai
|
|
232
|
+
* trò dùng chung là họ vừa đẻ ra thứ mọi đơn vị khác cũng gán được. API chặn
|
|
233
|
+
* sẵn (D1), đây là lớp hiển thị cho khớp.
|
|
234
|
+
*/
|
|
235
|
+
workspaceOptions?: Array<{ id: string; name: string; depth?: number }>
|
|
236
|
+
allowSharedWorkspace?: boolean
|
|
154
237
|
}
|
|
155
238
|
|
|
156
239
|
export function RoleFormPage({
|
|
@@ -167,9 +250,18 @@ export function RoleFormPage({
|
|
|
167
250
|
resourcePages,
|
|
168
251
|
menuTree,
|
|
169
252
|
historyEndpoint,
|
|
253
|
+
grantablePermissions,
|
|
254
|
+
showDataScope,
|
|
255
|
+
workspaceOptions,
|
|
256
|
+
allowSharedWorkspace,
|
|
170
257
|
}: RoleFormPageProps) {
|
|
171
258
|
const router = useRouter()
|
|
172
259
|
|
|
260
|
+
const grantable = React.useMemo(
|
|
261
|
+
() => (grantablePermissions ? new Set(grantablePermissions) : null),
|
|
262
|
+
[grantablePermissions]
|
|
263
|
+
)
|
|
264
|
+
|
|
173
265
|
const mergedActionLabels = React.useMemo(
|
|
174
266
|
() => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
|
|
175
267
|
[actionLabels]
|
|
@@ -199,6 +291,19 @@ export function RoleFormPage({
|
|
|
199
291
|
landingPath: initialData?.landingPath || "",
|
|
200
292
|
})
|
|
201
293
|
|
|
294
|
+
// Tách khỏi `formData` vì payload gửi thẳng cả object: vai trò không được
|
|
295
|
+
// 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.
|
|
296
|
+
const [dataScope, setDataScope] = React.useState<string>(
|
|
297
|
+
initialData?.dataScope ?? ""
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
// Cùng lý do tách khỏi `formData`: không hiện ô thì payload phải KHÔNG có
|
|
301
|
+
// trường `workspaceId`, để cổng uỷ quyền tự đóng dấu theo nhánh người tạo.
|
|
302
|
+
const showWorkspace = Boolean(workspaceOptions?.length)
|
|
303
|
+
const [workspaceId, setWorkspaceId] = React.useState<string>(
|
|
304
|
+
initialData?.workspaceId ?? ""
|
|
305
|
+
)
|
|
306
|
+
|
|
202
307
|
const getLocalizedName = React.useCallback(
|
|
203
308
|
(key: string | null) => {
|
|
204
309
|
if (!key) return ""
|
|
@@ -258,6 +363,17 @@ export function RoleFormPage({
|
|
|
258
363
|
} catch {
|
|
259
364
|
allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
|
|
260
365
|
}
|
|
366
|
+
// TRẦN QUYỀN (D2): người tạo vai trò không cấp được thứ chính mình không
|
|
367
|
+
// có. Cắt ngay ở `allowed` nên mọi thứ dựng trên nó — hàng thao tác, tick
|
|
368
|
+
// trang, mẫu vai trò, phạm vi chi nhánh — tự động tuân theo; lọc riêng ở
|
|
369
|
+
// từng chỗ vẽ là kiểu bỏ sót một chỗ rồi không ai biết.
|
|
370
|
+
if (grantable) {
|
|
371
|
+
allowed = allowed.filter((a) => grantable.has(`${a}:${resource.code}`))
|
|
372
|
+
}
|
|
373
|
+
// 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
|
|
374
|
+
// tick trang bị khoá chỉ tổ gây hiểu lầm là "đang bật".
|
|
375
|
+
if (grantable && allowed.length === 0) return
|
|
376
|
+
|
|
261
377
|
map.set(resource.code, {
|
|
262
378
|
name: getLocalizedName(resource.name),
|
|
263
379
|
icon: resource.icon,
|
|
@@ -265,7 +381,7 @@ export function RoleFormPage({
|
|
|
265
381
|
})
|
|
266
382
|
})
|
|
267
383
|
return map
|
|
268
|
-
}, [resources, actions, getLocalizedName])
|
|
384
|
+
}, [resources, actions, getLocalizedName, grantable])
|
|
269
385
|
|
|
270
386
|
/** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
|
|
271
387
|
* = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
|
|
@@ -471,9 +587,17 @@ export function RoleFormPage({
|
|
|
471
587
|
const applyTemplate = (templateCode: string) => {
|
|
472
588
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
473
589
|
if (!tpl) return
|
|
474
|
-
|
|
590
|
+
// 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
|
|
591
|
+
// người đang tạo, không thì bấm một cái là vượt trần và API từ chối.
|
|
592
|
+
const applied = grantable
|
|
593
|
+
? tpl.permissions.filter((p) => grantable.has(p))
|
|
594
|
+
: [...tpl.permissions]
|
|
595
|
+
setFormData((prev) => ({ ...prev, permissions: applied }))
|
|
475
596
|
toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
|
|
476
|
-
description:
|
|
597
|
+
description:
|
|
598
|
+
applied.length === tpl.permissions.length
|
|
599
|
+
? `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`
|
|
600
|
+
: `${applied.length}/${tpl.permissions.length} quyền (phần còn lại ngoài phạm vi của bạn).`,
|
|
477
601
|
})
|
|
478
602
|
}
|
|
479
603
|
|
|
@@ -487,7 +611,11 @@ export function RoleFormPage({
|
|
|
487
611
|
const res = await fetch(url, {
|
|
488
612
|
method,
|
|
489
613
|
headers: { "Content-Type": "application/json" },
|
|
490
|
-
body: JSON.stringify(
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
...formData,
|
|
616
|
+
...(showDataScope ? { dataScope: dataScope || null } : {}),
|
|
617
|
+
...(showWorkspace ? { workspaceId: workspaceId || null } : {}),
|
|
618
|
+
}),
|
|
491
619
|
})
|
|
492
620
|
if (!res.ok) {
|
|
493
621
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
@@ -847,6 +975,56 @@ export function RoleFormPage({
|
|
|
847
975
|
</Select>
|
|
848
976
|
)}
|
|
849
977
|
|
|
978
|
+
{/* Đơn vị sở hữu — vai trò này CỦA AI, tức ai được sửa nó. Khác hẳn
|
|
979
|
+
ô "Cô lập dữ liệu" ngay bên cạnh (người MANG vai trò thấy gì). */}
|
|
980
|
+
{showWorkspace && (
|
|
981
|
+
<div className="flex items-center gap-1.5">
|
|
982
|
+
<Network className="h-3.5 w-3.5 text-muted-foreground" />
|
|
983
|
+
<span className="text-xs text-muted-foreground">
|
|
984
|
+
Đơn vị sở hữu:
|
|
985
|
+
</span>
|
|
986
|
+
<Select
|
|
987
|
+
value={workspaceId || WORKSPACE_SHARED}
|
|
988
|
+
onValueChange={(v) =>
|
|
989
|
+
setWorkspaceId(v === WORKSPACE_SHARED ? "" : v)
|
|
990
|
+
}
|
|
991
|
+
>
|
|
992
|
+
<SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
|
|
993
|
+
<SelectValue>
|
|
994
|
+
{workspaceId
|
|
995
|
+
? (workspaceOptions?.find((w) => w.id === workspaceId)
|
|
996
|
+
?.name ?? workspaceId)
|
|
997
|
+
: "Dùng chung (mọi đơn vị)"}
|
|
998
|
+
</SelectValue>
|
|
999
|
+
</SelectTrigger>
|
|
1000
|
+
<SelectContent className="rounded-lg">
|
|
1001
|
+
{allowSharedWorkspace !== false && (
|
|
1002
|
+
<SelectItem value={WORKSPACE_SHARED}>
|
|
1003
|
+
<span className="flex flex-col">
|
|
1004
|
+
<span>Dùng chung (mọi đơn vị)</span>
|
|
1005
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1006
|
+
Đơn vị nào cũng gán được, chỉ cấp vận hành sửa
|
|
1007
|
+
</span>
|
|
1008
|
+
</span>
|
|
1009
|
+
</SelectItem>
|
|
1010
|
+
)}
|
|
1011
|
+
{workspaceOptions?.map((w) => (
|
|
1012
|
+
<SelectItem key={w.id} value={w.id}>
|
|
1013
|
+
<span
|
|
1014
|
+
className="flex flex-col"
|
|
1015
|
+
// Thụt theo cấp để nhìn ra bộ phận con của đơn vị nào —
|
|
1016
|
+
// danh sách phẳng thì "Kho" của hai khách hàng trông y hệt.
|
|
1017
|
+
style={{ paddingLeft: `${((w.depth ?? 1) - 1) * 12}px` }}
|
|
1018
|
+
>
|
|
1019
|
+
<span>{w.name}</span>
|
|
1020
|
+
</span>
|
|
1021
|
+
</SelectItem>
|
|
1022
|
+
))}
|
|
1023
|
+
</SelectContent>
|
|
1024
|
+
</Select>
|
|
1025
|
+
</div>
|
|
1026
|
+
)}
|
|
1027
|
+
|
|
850
1028
|
{branchScopeResources.length > 0 && (
|
|
851
1029
|
<div className="flex items-center gap-1.5">
|
|
852
1030
|
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
@@ -886,6 +1064,42 @@ export function RoleFormPage({
|
|
|
886
1064
|
</div>
|
|
887
1065
|
)}
|
|
888
1066
|
|
|
1067
|
+
{/* Cô lập dữ liệu — vai trò này nhìn thấy dữ liệu của những ai */}
|
|
1068
|
+
{showDataScope && (
|
|
1069
|
+
<div className="flex items-center gap-1.5">
|
|
1070
|
+
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
|
1071
|
+
<span className="text-xs text-muted-foreground">
|
|
1072
|
+
Cô lập dữ liệu:
|
|
1073
|
+
</span>
|
|
1074
|
+
<Select
|
|
1075
|
+
value={dataScope || DATA_SCOPE_DEFAULT}
|
|
1076
|
+
onValueChange={(v) =>
|
|
1077
|
+
setDataScope(v === DATA_SCOPE_DEFAULT ? "" : v)
|
|
1078
|
+
}
|
|
1079
|
+
>
|
|
1080
|
+
<SelectTrigger className="h-8 w-56 rounded-md border border-border bg-card text-sm">
|
|
1081
|
+
<SelectValue>
|
|
1082
|
+
{DATA_SCOPE_OPTIONS.find(
|
|
1083
|
+
(o) => o.value === (dataScope || DATA_SCOPE_DEFAULT)
|
|
1084
|
+
)?.label ?? dataScope}
|
|
1085
|
+
</SelectValue>
|
|
1086
|
+
</SelectTrigger>
|
|
1087
|
+
<SelectContent className="rounded-lg">
|
|
1088
|
+
{DATA_SCOPE_OPTIONS.map((o) => (
|
|
1089
|
+
<SelectItem key={o.value} value={o.value}>
|
|
1090
|
+
<span className="flex flex-col">
|
|
1091
|
+
<span>{o.label}</span>
|
|
1092
|
+
<span className="text-[11px] text-muted-foreground">
|
|
1093
|
+
{o.hint}
|
|
1094
|
+
</span>
|
|
1095
|
+
</span>
|
|
1096
|
+
</SelectItem>
|
|
1097
|
+
))}
|
|
1098
|
+
</SelectContent>
|
|
1099
|
+
</Select>
|
|
1100
|
+
</div>
|
|
1101
|
+
)}
|
|
1102
|
+
|
|
889
1103
|
{/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
|
|
890
1104
|
<div className="flex items-center gap-1.5">
|
|
891
1105
|
<House className="h-3.5 w-3.5 text-muted-foreground" />
|
|
@@ -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 } },
|
|
@@ -143,6 +206,30 @@ export async function getRolesData(
|
|
|
143
206
|
}),
|
|
144
207
|
]);
|
|
145
208
|
|
|
209
|
+
// Tên đơn vị sở hữu: MỘT câu cho cả trang, không phải `include` lồng — quan hệ
|
|
210
|
+
// `workspace` có thể không được khai trên `Role` ở app chỉ thêm cột thô, mà
|
|
211
|
+
// `include` sai tên quan hệ là Prisma ném lỗi cả trang danh sách.
|
|
212
|
+
const workspaceNames = new Map<string, string>();
|
|
213
|
+
if (hasWorkspaceField) {
|
|
214
|
+
const ids = [
|
|
215
|
+
...new Set(items.map((r: any) => r.workspaceId).filter(Boolean)),
|
|
216
|
+
] as string[];
|
|
217
|
+
// `db.workspace` chỉ tồn tại ở app đã chạy migration workspace. Có cột mà
|
|
218
|
+
// chưa có bảng là chuyện có thật giữa hai lần deploy — im lặng bỏ tên còn
|
|
219
|
+
// hơn cho nổ trang.
|
|
220
|
+
if (ids.length > 0 && (db as any).workspace?.findMany) {
|
|
221
|
+
try {
|
|
222
|
+
const rows = await (db as any).workspace.findMany({
|
|
223
|
+
where: { id: { in: ids } },
|
|
224
|
+
select: { id: true, name: true },
|
|
225
|
+
});
|
|
226
|
+
for (const w of rows) workspaceNames.set(w.id, w.name);
|
|
227
|
+
} catch {
|
|
228
|
+
/* không có bảng workspaces — bỏ qua tên, danh sách vẫn chạy */
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
146
233
|
// Transform raw DB items to RoleData type
|
|
147
234
|
const transformedItems = items.map((role: any) => ({
|
|
148
235
|
id: role.id,
|
|
@@ -154,6 +241,8 @@ export async function getRolesData(
|
|
|
154
241
|
permissions: role.rolePermissions.map(
|
|
155
242
|
(rp: any) => `${rp.actionCode}:${rp.resourceCode}`,
|
|
156
243
|
),
|
|
244
|
+
// Đếm theo danh sách ĐÃ kẹp: người quản trị đơn vị thấy "3 người dùng" là 3
|
|
245
|
+
// người trong đơn vị mình, không phải tổng toàn hệ thống.
|
|
157
246
|
usersCount: role.userRoles.length,
|
|
158
247
|
users: role.userRoles.map((ur: any) => ({
|
|
159
248
|
id: ur.user.id,
|
|
@@ -166,6 +255,25 @@ export async function getRolesData(
|
|
|
166
255
|
updatedAt: hasTimestamps && role.updatedAt ? role.updatedAt.toISOString() : "",
|
|
167
256
|
createdBy: role.createdBy || undefined,
|
|
168
257
|
updatedBy: role.updatedBy || undefined,
|
|
258
|
+
...(hasWorkspaceField
|
|
259
|
+
? {
|
|
260
|
+
workspaceId: role.workspaceId ?? null,
|
|
261
|
+
workspaceName: role.workspaceId
|
|
262
|
+
? (workspaceNames.get(role.workspaceId) ?? null)
|
|
263
|
+
: null,
|
|
264
|
+
isSystem: Boolean(role.isSystem),
|
|
265
|
+
rank: typeof role.rank === "number" ? role.rank : undefined,
|
|
266
|
+
// Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
|
|
267
|
+
// xem được, không sửa được. Cùng luật với `canManageRole` bên
|
|
268
|
+
// `@goerp/core/workspace` — đây chỉ là bản chiếu xuống UI.
|
|
269
|
+
canManage: scope
|
|
270
|
+
? scope.canManageAll ||
|
|
271
|
+
(!role.isSystem &&
|
|
272
|
+
Boolean(role.workspaceId) &&
|
|
273
|
+
adminIds.includes(role.workspaceId))
|
|
274
|
+
: undefined,
|
|
275
|
+
}
|
|
276
|
+
: {}),
|
|
169
277
|
}));
|
|
170
278
|
|
|
171
279
|
return { total, page, pageSize, items: transformedItems };
|