@goplusvn/core 0.1.79 → 0.1.82
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 +69 -0
- package/package.json +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +69 -0
- package/src/rbac/components/roles/role-card.tsx +24 -0
- package/src/rbac/pages/role-form-page.tsx +86 -3
- package/src/rbac/role-service.ts +27 -0
- package/src/rbac/route-handlers.ts +13 -1
- package/src/rbac/types.ts +6 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +73 -6
- 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 +6 -8
- package/src/workspace/index.ts +13 -3
- package/src/workspace/pages/workspace-list-page.tsx +106 -46
- package/src/workspace/route-handlers.ts +218 -4
- package/src/workspace/service.ts +22 -0
- package/src/workspace/components/workspace-org-block.tsx +0 -293
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.81 — Cây workspace kiểu explorer, xoá/khôi phục lộ ra, thêm thành viên bằng dialog
|
|
4
|
+
|
|
5
|
+
Vẫn **ADDITIVE**: prop mới đều tuỳ chọn, app không truyền thì màn hình vẫn chạy.
|
|
6
|
+
|
|
7
|
+
- `workspace/components/workspace-tree-panel.tsx` (MỚI, thay
|
|
8
|
+
`workspace-org-block.tsx` đã xoá) — cây thu gọn được kiểu explorer: một kiểu
|
|
9
|
+
dòng duy nhất cho mọi cấp, chevron riêng, vạch dẫn con, nút gọn thì cộng dồn
|
|
10
|
+
số người của cả nhánh. Chọn dòng bằng `bg-accent` + vạch `primary` bên trái
|
|
11
|
+
thay vì nền đậm. Mỗi dòng có menu `⋯`: thêm cấp con, sửa tên/mã, chuyển
|
|
12
|
+
nhánh, và **“Xoá workspace”** — trước đây thao tác xoá nấp sau chữ “Ngừng
|
|
13
|
+
hoạt động” nên nhìn vào không ai thấy màn hình có chức năng xoá.
|
|
14
|
+
Tên cũ (`WorkspaceOrgAnnotation`, `filterWorkspaceTree`, `rollupCounts`,
|
|
15
|
+
`WorkspaceRollup`) vẫn export được từ module này.
|
|
16
|
+
- `workspace/service.ts` — `reactivateSubtree()` đối xứng với
|
|
17
|
+
`deactivateSubtree()`; `PATCH { isActive: true }` bật lại CẢ NHÁNH. Xoá là
|
|
18
|
+
cửa hai chiều: không có đường về thì nút đã xoá nằm lại vĩnh viễn.
|
|
19
|
+
- `workspace/components/workspace-member-picker-dialog.tsx` (MỚI) — “Thêm thành
|
|
20
|
+
viên” chuyển từ khối chèn trong trang sang **dialog** theo đúng khuôn dialog
|
|
21
|
+
chọn của vinhhoa (`h-[85vh]`, header/toolbar dính, vùng cuộn `bg-muted/30`,
|
|
22
|
+
footer hành động). Tìm theo họ tên · mã nhân viên · email; lọc theo chi nhánh,
|
|
23
|
+
vai trò (app truyền) và **“đang ở bộ phận nào”** (core tự phục vụ bằng chính
|
|
24
|
+
bảng `userWorkspace`). Tick nhiều dòng, gán một lượt; dòng nào hỏng thì giữ
|
|
25
|
+
dialog và chọn lại đúng những người đó.
|
|
26
|
+
- `workspace/route-handlers.ts` — dep tuỳ chọn `candidateSearch(query)` (nhánh
|
|
27
|
+
tìm THÊM của app, ví dụ mã nhân viên) và tham số `inWorkspaceId`;
|
|
28
|
+
`memberIdentity.describe` trả thêm `code` để hiện mã nhân viên cạnh tên.
|
|
29
|
+
|
|
30
|
+
## 0.1.80 — Gán người vào workspace: hàng loạt theo bộ lọc, và nhìn ra người đó là ai
|
|
31
|
+
|
|
32
|
+
Đợt 0.1.76–0.1.79 dựng xong hàng rào; đợt này là phần THAO TÁC — chỗ người ta
|
|
33
|
+
thực sự ngồi làm. Toàn bộ **ADDITIVE**: mọi prop và dep mới đều tuỳ chọn, app
|
|
34
|
+
không truyền thì màn hình chạy y như 0.1.79.
|
|
35
|
+
|
|
36
|
+
- `workspace/components/workspace-members-panel.tsx`
|
|
37
|
+
- **Gán hàng loạt** (bật bằng prop `bulkFilters`): lọc → tick nhiều dòng →
|
|
38
|
+
chuyển một lần. Hai nút, hai nghĩa: **“Chuyển”** (`mode: "move"`) GỠ mọi
|
|
39
|
+
không gian khác của người được chọn, **“Thêm”** giữ nguyên nơi cũ. Đây là
|
|
40
|
+
điểm dễ hiểu nhầm nhất của cả tính năng: phạm vi đọc là HỢP các không gian
|
|
41
|
+
một người thuộc về, nên chỉ THÊM thì người vừa “gán vào bộ phận Kho” vẫn
|
|
42
|
+
còn membership gốc và vẫn thấy toàn hệ thống — bấm xong tưởng đã cô lập mà
|
|
43
|
+
thực ra không đổi gì. Vì vậy “Chuyển” là mặc định, không phải “Thêm”.
|
|
44
|
+
- **Danh tính mỗi dòng**: chi nhánh · vai trò · các không gian đang thuộc về,
|
|
45
|
+
cho cả thành viên lẫn ứng viên. Tên với email không trả lời được câu hỏi
|
|
46
|
+
thật của người đang gán (“ông này là ai, đang nằm ở đâu, có phải quản trị
|
|
47
|
+
không”), mà gán nhầm là cắt mất tầm nhìn của một người thật.
|
|
48
|
+
- `workspace/route-handlers.ts`
|
|
49
|
+
- Dep tuỳ chọn `candidateFilter(params)` — app dịch tham số URL thành mảnh
|
|
50
|
+
`where` của Prisma. Mảnh này **chồng thêm** vào `memberScopeWhere(...,
|
|
51
|
+
adminOnly)`, không thay thế: không dùng nó để với ra ngoài phạm vi quản trị
|
|
52
|
+
được.
|
|
53
|
+
- Dep tuỳ chọn `memberIdentity { select, describe }` — “chi nhánh”/“vai trò”
|
|
54
|
+
là quan hệ của app, nhét thẳng `userBranches`/`userRoles` vào truy vấn thì
|
|
55
|
+
app nào không có đúng hai quan hệ đó sẽ vỡ lúc chạy. Các không gian đang
|
|
56
|
+
thuộc về thì core tự đọc (đã trừ nút đang mở).
|
|
57
|
+
- POST nhận `{ userIds[], mode }`; từng dòng hỏng trả lý do RIÊNG (ngoài phạm
|
|
58
|
+
vi, tài khoản được bảo vệ…) thay vì gộp thành một câu “có lỗi”.
|
|
59
|
+
- `workspace/components/workspace-org-block.tsx` — cây con **nằm trong** nút
|
|
60
|
+
gốc: thụt lề mọc ra từ tim ô icon của khối (1.75rem) kèm vạch dọc nối các nút
|
|
61
|
+
cùng cấp. Trước đó cấp đầu tiên chỉ lùi 0.75rem — ít hơn cả lề của tiêu đề
|
|
62
|
+
khối — nên đơn vị con nhìn ra thành ngang hàng với tổ chức, đúng thứ màn hình
|
|
63
|
+
này không được phép nói. Prop `annotations` (tra theo `code`) gắn được huy
|
|
64
|
+
hiệu cho cả nút CON, cần khi cây có một nút gốc phủ toàn hệ thống và “vận
|
|
65
|
+
hành”/“khách hàng” tụt xuống làm con.
|
|
66
|
+
- `rbac` — `Role.workspaceName` giải sẵn ở server (một câu SQL cho cả trang,
|
|
67
|
+
không `include` lồng: quan hệ `workspace` có thể chưa khai trên `Role`).
|
|
68
|
+
`PUT /api/roles/[id]` cho **chuyển chủ sở hữu** vai trò, nhưng chỉ ghi khi
|
|
69
|
+
client CÓ gửi `workspaceId` — ghi vô điều kiện thì vai trò dùng chung do vai
|
|
70
|
+
vận hành sửa sẽ lặng lẽ rơi vào một đơn vị.
|
|
71
|
+
|
|
3
72
|
## 0.1.79 — Vai trò cũng có phạm vi: quản trị viên đơn vị chỉ thấy & sửa vai trò của mình
|
|
4
73
|
|
|
5
74
|
Lỗ hổng đối xứng của đợt 0.1.76: người dùng đã bị kẹp theo workspace, còn VAI
|
package/package.json
CHANGED
|
@@ -296,6 +296,75 @@ describe("roles route handlers — uỷ quyền", () => {
|
|
|
296
296
|
expect(updated[0].name).toBe("Tổ trưởng bếp");
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
+
// ── Chủ sở hữu vai trò (`Role.workspaceId`) — ô "Đơn vị sở hữu" trên form ──
|
|
300
|
+
// Khác `dataScope`: cái này trả lời "vai trò của ai, AI SỬA ĐƯỢC", không phải
|
|
301
|
+
// "người mang vai trò thấy dữ liệu tới đâu".
|
|
302
|
+
|
|
303
|
+
it("POST nhận đơn vị do vai vận hành chọn thay vì tự đóng dấu", async () => {
|
|
304
|
+
const { deps, actor, created } = tenantDb();
|
|
305
|
+
actor.canManageAll = true;
|
|
306
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
307
|
+
post({
|
|
308
|
+
code: "qc_lead",
|
|
309
|
+
name: "Trưởng QC",
|
|
310
|
+
workspaceId: "spa-qc",
|
|
311
|
+
permissions: ["view:meal-order"],
|
|
312
|
+
})
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(res.status).toBe(201);
|
|
316
|
+
expect(created[0].workspaceId).toBe("spa-qc");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("POST vai vận hành chọn 'dùng chung' thì workspaceId là null", async () => {
|
|
320
|
+
const { deps, actor, created } = tenantDb();
|
|
321
|
+
actor.canManageAll = true;
|
|
322
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
323
|
+
post({ code: "chung", name: "Dùng chung", workspaceId: null, permissions: [] })
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(res.status).toBe(201);
|
|
327
|
+
expect(created[0].workspaceId).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("PUT KHÔNG gửi workspaceId thì không đóng dấu lại (vai trò dùng chung ở yên)", async () => {
|
|
331
|
+
// Đây là hồi quy thật: ghi `gate.workspaceId` vô điều kiện thì mỗi lần vai
|
|
332
|
+
// vận hành sửa một vai trò dùng chung là nó lặng lẽ rơi vào một đơn vị.
|
|
333
|
+
const { deps, actor, updated } = tenantDb({ workspaceId: null });
|
|
334
|
+
actor.canManageAll = true;
|
|
335
|
+
const res = await createRoleItemHandlers(deps as any).PUT(
|
|
336
|
+
put({ name: "Đổi tên", permissions: [] }),
|
|
337
|
+
ctx as any
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
expect(res.status).toBe(200);
|
|
341
|
+
expect(updated[0]).not.toHaveProperty("workspaceId");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("PUT vai vận hành chuyển vai trò sang đơn vị khác", async () => {
|
|
345
|
+
const { deps, actor, updated } = tenantDb();
|
|
346
|
+
actor.canManageAll = true;
|
|
347
|
+
const res = await createRoleItemHandlers(deps as any).PUT(
|
|
348
|
+
put({ name: "Tổ trưởng", workspaceId: "tanloc", permissions: [] }),
|
|
349
|
+
ctx as any
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(res.status).toBe(200);
|
|
353
|
+
expect(updated[0].workspaceId).toBe("tanloc");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("PUT quản trị viên đơn vị KHÔNG chuyển được vai trò ra ngoài nhánh mình", async () => {
|
|
357
|
+
const { deps, updated } = tenantDb();
|
|
358
|
+
const res = await createRoleItemHandlers(deps as any).PUT(
|
|
359
|
+
put({ name: "Tổ trưởng", workspaceId: "tanloc", permissions: ["view:meal-order"] }),
|
|
360
|
+
ctx as any
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
expect(res.status).toBe(403);
|
|
364
|
+
expect((await res.json()).code).toBe("D1_OUT_OF_SCOPE");
|
|
365
|
+
expect(updated).toHaveLength(0);
|
|
366
|
+
});
|
|
367
|
+
|
|
299
368
|
it("DELETE chặn vai trò hệ thống", async () => {
|
|
300
369
|
const { deps, deleted } = tenantDb({ isSystem: true });
|
|
301
370
|
const res = await createRoleItemHandlers(deps as any).DELETE(
|
|
@@ -58,6 +58,17 @@ export const RoleCard = memo(function RoleCard({
|
|
|
58
58
|
? "Dùng chung"
|
|
59
59
|
: null;
|
|
60
60
|
|
|
61
|
+
// ĐƠN VỊ SỞ HỮU — chỉ ở app multi-tenant. Phân biệt `undefined` (app một tổ
|
|
62
|
+
// chức, không có khái niệm này) với `null` (có, và là vai trò dùng chung):
|
|
63
|
+
// gộp hai cái là app cũ tự nhiên mọc thêm huy hiệu "Dùng chung" vô nghĩa.
|
|
64
|
+
const multiTenant = "workspaceId" in role;
|
|
65
|
+
// Người không sửa được đã có nhãn riêng ở trên rồi, đừng dán hai huy hiệu
|
|
66
|
+
// cùng nghĩa cạnh nhau.
|
|
67
|
+
const ownerLabel =
|
|
68
|
+
multiTenant && !readOnlyLabel
|
|
69
|
+
? (role.workspaceName ?? (role.workspaceId ? "Đơn vị khác" : "Dùng chung"))
|
|
70
|
+
: null;
|
|
71
|
+
|
|
61
72
|
const handleEdit = useCallback(() => {
|
|
62
73
|
router.push(`/${lang}/roles/${role.id}/edit`);
|
|
63
74
|
}, [router, lang, role.id]);
|
|
@@ -95,6 +106,19 @@ export const RoleCard = memo(function RoleCard({
|
|
|
95
106
|
{readOnlyLabel}
|
|
96
107
|
</Badge>
|
|
97
108
|
)}
|
|
109
|
+
{ownerLabel && (
|
|
110
|
+
<Badge
|
|
111
|
+
variant="outline"
|
|
112
|
+
className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold max-w-[9rem] truncate"
|
|
113
|
+
title={
|
|
114
|
+
role.workspaceId
|
|
115
|
+
? "Đơn vị sở hữu vai trò — chỉ quản trị đơn vị này sửa được"
|
|
116
|
+
: "Vai trò dùng chung — mọi đơn vị đều gán được, chỉ cấp vận hành sửa"
|
|
117
|
+
}
|
|
118
|
+
>
|
|
119
|
+
{ownerLabel}
|
|
120
|
+
</Badge>
|
|
121
|
+
)}
|
|
98
122
|
{role.status === "active" ? (
|
|
99
123
|
<Badge
|
|
100
124
|
variant="outline"
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
House,
|
|
46
46
|
Info,
|
|
47
47
|
LayoutTemplate,
|
|
48
|
+
Network,
|
|
48
49
|
Save,
|
|
49
50
|
ShieldCheck,
|
|
50
51
|
X,
|
|
@@ -84,6 +85,9 @@ const LANDING_AUTO = "__auto__"
|
|
|
84
85
|
/** Cùng lý do: "" = theo mặc định của app (`subtree`). */
|
|
85
86
|
const DATA_SCOPE_DEFAULT = "__default__"
|
|
86
87
|
|
|
88
|
+
/** Cùng lý do: "" = vai trò DÙNG CHUNG (`workspaceId = null`). */
|
|
89
|
+
const WORKSPACE_SHARED = "__shared__"
|
|
90
|
+
|
|
87
91
|
/**
|
|
88
92
|
* Thang 5 nấc của `@goerp/core/workspace` (Δ1). Chữ ở đây cố ý nói theo NGƯỜI
|
|
89
93
|
* DÙNG chứ không theo tên kỹ thuật: người tick quyền không biết "subtree" là gì.
|
|
@@ -185,6 +189,8 @@ export interface RoleFormPageProps {
|
|
|
185
189
|
landingPath?: string | null
|
|
186
190
|
/** Nấc cô lập dữ liệu. Null = mặc định của app (`subtree`). */
|
|
187
191
|
dataScope?: string | null
|
|
192
|
+
/** Đơn vị sở hữu vai trò. Null = vai trò dùng chung. */
|
|
193
|
+
workspaceId?: string | null
|
|
188
194
|
}
|
|
189
195
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
190
196
|
roleTemplates?: RoleTemplate[]
|
|
@@ -212,6 +218,22 @@ export interface RoleFormPageProps {
|
|
|
212
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ị.
|
|
213
219
|
*/
|
|
214
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
|
|
215
237
|
}
|
|
216
238
|
|
|
217
239
|
export function RoleFormPage({
|
|
@@ -230,6 +252,8 @@ export function RoleFormPage({
|
|
|
230
252
|
historyEndpoint,
|
|
231
253
|
grantablePermissions,
|
|
232
254
|
showDataScope,
|
|
255
|
+
workspaceOptions,
|
|
256
|
+
allowSharedWorkspace,
|
|
233
257
|
}: RoleFormPageProps) {
|
|
234
258
|
const router = useRouter()
|
|
235
259
|
|
|
@@ -273,6 +297,13 @@ export function RoleFormPage({
|
|
|
273
297
|
initialData?.dataScope ?? ""
|
|
274
298
|
)
|
|
275
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
|
+
|
|
276
307
|
const getLocalizedName = React.useCallback(
|
|
277
308
|
(key: string | null) => {
|
|
278
309
|
if (!key) return ""
|
|
@@ -580,9 +611,11 @@ export function RoleFormPage({
|
|
|
580
611
|
const res = await fetch(url, {
|
|
581
612
|
method,
|
|
582
613
|
headers: { "Content-Type": "application/json" },
|
|
583
|
-
body: JSON.stringify(
|
|
584
|
-
|
|
585
|
-
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
...formData,
|
|
616
|
+
...(showDataScope ? { dataScope: dataScope || null } : {}),
|
|
617
|
+
...(showWorkspace ? { workspaceId: workspaceId || null } : {}),
|
|
618
|
+
}),
|
|
586
619
|
})
|
|
587
620
|
if (!res.ok) {
|
|
588
621
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
@@ -942,6 +975,56 @@ export function RoleFormPage({
|
|
|
942
975
|
</Select>
|
|
943
976
|
)}
|
|
944
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
|
+
|
|
945
1028
|
{branchScopeResources.length > 0 && (
|
|
946
1029
|
<div className="flex items-center gap-1.5">
|
|
947
1030
|
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
|
package/src/rbac/role-service.ts
CHANGED
|
@@ -206,6 +206,30 @@ export async function getRolesData(
|
|
|
206
206
|
}),
|
|
207
207
|
]);
|
|
208
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
|
+
|
|
209
233
|
// Transform raw DB items to RoleData type
|
|
210
234
|
const transformedItems = items.map((role: any) => ({
|
|
211
235
|
id: role.id,
|
|
@@ -234,6 +258,9 @@ export async function getRolesData(
|
|
|
234
258
|
...(hasWorkspaceField
|
|
235
259
|
? {
|
|
236
260
|
workspaceId: role.workspaceId ?? null,
|
|
261
|
+
workspaceName: role.workspaceId
|
|
262
|
+
? (workspaceNames.get(role.workspaceId) ?? null)
|
|
263
|
+
: null,
|
|
237
264
|
isSystem: Boolean(role.isSystem),
|
|
238
265
|
rank: typeof role.rank === "number" ? role.rank : undefined,
|
|
239
266
|
// Vai trò dùng chung / vai trò hệ thống / vai trò của đơn vị khác:
|
|
@@ -308,11 +308,23 @@ export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
|
308
308
|
try {
|
|
309
309
|
const gate = assertCanManageRole(actor, loaded.delegation, {
|
|
310
310
|
code: existing.code,
|
|
311
|
-
|
|
311
|
+
// Client KHÔNG gửi trường ⇒ giữ nguyên chủ sở hữu. Gửi ⇒ cổng uỷ
|
|
312
|
+
// quyền quyết: vai vận hành đổi được tuỳ ý, quản trị đơn vị chỉ
|
|
313
|
+
// chuyển được trong nhánh mình (D1 ném nếu ra ngoài).
|
|
314
|
+
workspaceId:
|
|
315
|
+
body.workspaceId !== undefined
|
|
316
|
+
? body.workspaceId
|
|
317
|
+
: loaded.delegation.workspaceId,
|
|
312
318
|
rank: typeof body.rank === "number" ? body.rank : loaded.delegation.rank,
|
|
313
319
|
permissions: toResourceAction(permissions),
|
|
314
320
|
});
|
|
315
321
|
if (hasRoleField(prisma, "rank")) stamp.rank = gate.rank;
|
|
322
|
+
// Chỉ ghi khi client CÓ ý đổi: `gate.workspaceId` luôn có giá trị, ghi
|
|
323
|
+
// vô điều kiện là mọi lần sửa vai trò đều đóng dấu lại — vai trò dùng
|
|
324
|
+
// chung do vai vận hành sửa sẽ lặng lẽ rơi vào một đơn vị.
|
|
325
|
+
if (body.workspaceId !== undefined && hasRoleField(prisma, "workspaceId")) {
|
|
326
|
+
stamp.workspaceId = gate.workspaceId;
|
|
327
|
+
}
|
|
316
328
|
} catch (e) {
|
|
317
329
|
const denied = delegationDenied(e);
|
|
318
330
|
if (denied) return denied;
|
package/src/rbac/types.ts
CHANGED
|
@@ -22,6 +22,12 @@ export type Role = {
|
|
|
22
22
|
updatedBy?: string | null | undefined;
|
|
23
23
|
/** App multi-tenant: đơn vị sở hữu vai trò; `null` = vai trò dùng chung. */
|
|
24
24
|
workspaceId?: string | null;
|
|
25
|
+
/**
|
|
26
|
+
* Tên đơn vị sở hữu — server giải sẵn để thẻ vai trò khỏi phải tra cây. `null`
|
|
27
|
+
* khi vai trò dùng chung; trường VẮNG MẶT khi app không bật multi-tenant (khác
|
|
28
|
+
* hẳn `null`, đừng gộp hai trường hợp).
|
|
29
|
+
*/
|
|
30
|
+
workspaceName?: string | null;
|
|
25
31
|
isSystem?: boolean;
|
|
26
32
|
rank?: number;
|
|
27
33
|
/**
|
|
@@ -118,12 +118,16 @@ function fakeDb(users: FakeUser[]) {
|
|
|
118
118
|
const before = members.length;
|
|
119
119
|
for (let i = members.length - 1; i >= 0; i -= 1) {
|
|
120
120
|
const m = members[i];
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
if (m.userId !== args.where.userId) continue;
|
|
122
|
+
// Hai dạng `where` mà handler thực sự gửi xuống: gỡ ĐÚNG một nút
|
|
123
|
+
// (đường xoá một người) và gỡ MỌI nút khác (đường chuyển hàng loạt).
|
|
124
|
+
const matches =
|
|
125
|
+
args.where.workspaceId !== undefined
|
|
126
|
+
? m.workspaceId === args.where.workspaceId
|
|
127
|
+
: args.where.NOT?.workspaceId !== undefined
|
|
128
|
+
? m.workspaceId !== args.where.NOT.workspaceId
|
|
129
|
+
: true;
|
|
130
|
+
if (matches) members.splice(i, 1);
|
|
127
131
|
}
|
|
128
132
|
return { count: before - members.length };
|
|
129
133
|
},
|
|
@@ -405,3 +409,66 @@ describe("tìm ứng viên để thêm", () => {
|
|
|
405
409
|
expect(res.status).toBe(403);
|
|
406
410
|
});
|
|
407
411
|
});
|
|
412
|
+
|
|
413
|
+
describe("gán HÀNG LOẠT", () => {
|
|
414
|
+
let db: ReturnType<typeof fakeDb>;
|
|
415
|
+
beforeEach(() => {
|
|
416
|
+
db = fakeDb(baseUsers());
|
|
417
|
+
configure(db);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const wsOf = (userId: string) =>
|
|
421
|
+
db.members.filter((m) => m.userId === userId).map((m) => m.workspaceId);
|
|
422
|
+
|
|
423
|
+
it('mode "move" GỠ mọi không gian cũ — nếu không thì cô lập là giả', async () => {
|
|
424
|
+
const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
|
|
425
|
+
const res = await POST(
|
|
426
|
+
postReq({ userIds: ["u-qc", "u-tanloc"], mode: "move" }),
|
|
427
|
+
ctx("spa-qc"),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
expect(res.status).toBe(200);
|
|
431
|
+
await expect(res.json()).resolves.toMatchObject({ changed: 2, failed: [] });
|
|
432
|
+
// Còn giữ membership cũ là người đó vẫn thấy dữ liệu nhánh cũ (phạm vi là
|
|
433
|
+
// HỢP các không gian), tức bấm gán xong mà không đổi gì.
|
|
434
|
+
expect(wsOf("u-qc")).toEqual(["spa-qc"]);
|
|
435
|
+
expect(wsOf("u-tanloc")).toEqual(["spa-qc"]);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('mode "add" giữ nguyên nơi cũ', async () => {
|
|
439
|
+
const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
|
|
440
|
+
const res = await POST(
|
|
441
|
+
postReq({ userIds: ["u-qc"], mode: "add" }),
|
|
442
|
+
ctx("spa-qc"),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
expect(res.status).toBe(200);
|
|
446
|
+
expect(wsOf("u-qc").sort()).toEqual(["spa", "spa-qc"]);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("người ngoài phạm vi bị loại RIÊNG, không kéo đổ cả mẻ", async () => {
|
|
450
|
+
const { POST } = createWorkspaceMemberHandlers(
|
|
451
|
+
deps(db, customerAdmin) as any,
|
|
452
|
+
);
|
|
453
|
+
const res = await POST(
|
|
454
|
+
// `u-tanloc` thuộc nhánh Tấn Lộc — admin khách hàng không sở hữu (D7).
|
|
455
|
+
postReq({ userIds: ["u-qc", "u-tanloc"] }),
|
|
456
|
+
ctx("spa-qc"),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
expect(res.status).toBe(200);
|
|
460
|
+
const payload = await res.json();
|
|
461
|
+
expect(payload.changed).toBe(1);
|
|
462
|
+
expect(payload.failed).toHaveLength(1);
|
|
463
|
+
expect(payload.failed[0].userId).toBe("u-tanloc");
|
|
464
|
+
// Nạn nhân KHÔNG bị đẩy khỏi nhánh cũ — đó chính là đường chiếm người mà D7 chặn.
|
|
465
|
+
expect(wsOf("u-tanloc")).toEqual(["tanloc"]);
|
|
466
|
+
expect(wsOf("u-qc")).toEqual(["spa-qc"]);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("danh sách rỗng → 400 chứ không im lặng báo thành công", async () => {
|
|
470
|
+
const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
|
|
471
|
+
const res = await POST(postReq({ userIds: [] }), ctx("spa-qc"));
|
|
472
|
+
expect(res.status).toBe(400);
|
|
473
|
+
});
|
|
474
|
+
});
|