@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,130 @@
|
|
|
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
|
+
|
|
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
|
|
73
|
+
|
|
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
|
|
75
|
+
TRÒ thì không. Chặn *gán* vai trò mạnh (D4) mà để ngỏ đường *tạo* vai trò mạnh
|
|
76
|
+
chỉ là hàng rào có cửa sau — admin đơn vị tạo "Trợ lý" kèm `role:update` rồi tự
|
|
77
|
+
gán cho tài khoản mình vừa lập. Toàn bộ đợt này **ADDITIVE**: app không truyền
|
|
78
|
+
`getDelegationActor` / `scope` chạy y như trước.
|
|
79
|
+
|
|
80
|
+
- `workspace/delegation.ts` — thêm `assertCanManageRole`, `assertCanDeleteRole`,
|
|
81
|
+
`canManageRole`, `defaultRoleWorkspaceIdFor`, type `RoleDraft`. Vai trò mới bị
|
|
82
|
+
đóng dấu `workspaceId` của người tạo, ép `rank > actor.rank`, chặn quyền nguy
|
|
83
|
+
hiểm (D4) và quyền vượt trần của chính người tạo (D2).
|
|
84
|
+
- `rbac/role-service.ts` — `getRolesData(db, params, schema, scope?)` nhận
|
|
85
|
+
`RoleQueryScope`: lọc theo `adminWorkspaceIds` (+ vai trò dùng chung, chỉ
|
|
86
|
+
đọc), kẹp cả mảng `users[]` nhúng trong mỗi vai trò bằng `userWhere` — trước
|
|
87
|
+
đó trang vai trò là cửa hậu xem toàn bộ danh bạ. Trả thêm `workspaceId` /
|
|
88
|
+
`isSystem` / `rank` / `canManage`.
|
|
89
|
+
- `rbac/route-handlers.ts` — dep tuỳ chọn `getDelegationActor`. GET lọc theo
|
|
90
|
+
phạm vi; GET một vai trò ngoài phạm vi trả **404** (403 là xác nhận nó tồn
|
|
91
|
+
tại); POST/PUT/DELETE đi qua cổng uỷ quyền, từ chối trả 403 kèm mã D1/D2/D4.
|
|
92
|
+
- `user/user-service.ts` — `getAssignableRoles(db, actor)`: ô chọn vai trò trong
|
|
93
|
+
hộp thoại người dùng chỉ hiện vai trò GÁN ĐƯỢC (D2∧D4), không còn kiểu chọn
|
|
94
|
+
xong bấm lưu mới ăn 403.
|
|
95
|
+
- `rbac/pages/role-form-page.tsx` — prop `grantablePermissions`: ma trận quyền
|
|
96
|
+
cắt theo trần quyền người tạo ngay tại `resourceInfo.allowed` (mọi thứ dựng
|
|
97
|
+
trên nó tự tuân theo), mẫu vai trò cũng bị kẹp. Quyền đã cấp nằm ngoài trần
|
|
98
|
+
thì KHÔNG hiện và KHÔNG bị xoá — API từ chối lưu, thà chặn còn hơn âm thầm
|
|
99
|
+
tước quyền.
|
|
100
|
+
- `rbac` UI — `Role.canManage` tắt nút Sửa/Xoá và ô nhiệt trong danh sách, kèm
|
|
101
|
+
huy hiệu "Dùng chung" / "Hệ thống" để người dùng hiểu vì sao nút biến mất.
|
|
102
|
+
|
|
103
|
+
### Cô lập DỮ LIỆU theo vai trò (`Role.dataScope` → nấc phạm vi)
|
|
104
|
+
|
|
105
|
+
Nửa còn lại: 0.1.76 kẹp *người dùng*, phần trên kẹp *vai trò*, nhưng **dữ liệu
|
|
106
|
+
nghiệp vụ** vẫn mở. Thang 5 nấc `none | own | workspace | subtree | all` (Δ1,
|
|
107
|
+
kiểu Dynamics 365) trước đây là code chết — nay được đấu vào phạm vi thật.
|
|
108
|
+
|
|
109
|
+
Quyết định thiết kế đáng nhớ: bốn nấc `none/workspace/subtree/all` được **gấp
|
|
110
|
+
thẳng vào `canViewAll` + `allowedIds` lúc dựng phạm vi**, nên mọi `scopedWhere`
|
|
111
|
+
/ `memberScopeWhere` / guard lớp 2 đang có tự tuân theo — **không call-site nào
|
|
112
|
+
phải sửa**. Chỉ nấc `own` cần biết cột chủ sở hữu nên phải khai ở call-site.
|
|
113
|
+
|
|
114
|
+
- `workspace/types.ts` — `WorkspaceScope` thêm `level?` + `userId?`;
|
|
115
|
+
`WorkspaceScopeConfig` thêm `getScopeLevel?(session)` và `ownerField?`.
|
|
116
|
+
- `workspace/scope.ts` — `getWorkspaceScope` áp nấc: `all` → bỏ cô lập kể cả khi
|
|
117
|
+
không có quyền view-all; `none` → sentinel; `workspace` → **không** bung con
|
|
118
|
+
cháu; `subtree`/bỏ trống → y hệt hôm nay. `scopedWhere(scope, { ownerField })`
|
|
119
|
+
và `memberScopeWhere` hiểu nấc `own`; **thiếu `ownerField` là fail-closed**
|
|
120
|
+
(khớp sentinel) chứ không âm thầm nới lên `subtree`.
|
|
121
|
+
- `rbac/route-handlers.ts` — đọc/ghi `Role.dataScope` (dò cột bằng
|
|
122
|
+
`role.fields`, app chưa có cột thì bỏ qua). **Chỉ vai vận hành**
|
|
123
|
+
(`actor.canManageAll`) đặt được nấc: admin đơn vị gửi `dataScope` lên thì
|
|
124
|
+
field bị bỏ im lặng, nếu không họ tự nới tầm nhìn của chính mình.
|
|
125
|
+
- `rbac/pages/role-form-page.tsx` — ô chọn "Cô lập dữ liệu" sau prop
|
|
126
|
+
`showDataScope` (app truyền `canViewAllWorkspaces(session)`).
|
|
127
|
+
|
|
3
128
|
## 0.1.76 — Multi-tenant + workspace: một cây tổ chức/chi nhánh/phòng ban, uỷ quyền cấp tài khoản trong phạm vi
|
|
4
129
|
|
|
5
130
|
Nguồn: `docs/WORKSPACE-MULTITENANT-PLAN.md` + `docs/WORKSPACE-RESEARCH-IAM.md`
|
package/package.json
CHANGED
|
@@ -145,3 +145,255 @@ describe("roles route handlers — landingPath", () => {
|
|
|
145
145
|
expect(await res.json()).toMatchObject({ landingPath: null });
|
|
146
146
|
});
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
// ── Uỷ quyền vai trò (app multi-tenant) ────────────────────────────────────
|
|
150
|
+
// Chặn *gán* vai trò mạnh mà để ngỏ đường *tạo* vai trò mạnh là hàng rào có
|
|
151
|
+
// cửa sau: quản trị viên đơn vị chỉ cần tạo "Trợ lý" kèm `role:update`.
|
|
152
|
+
|
|
153
|
+
function tenantDb(role: Partial<Record<string, any>> = {}) {
|
|
154
|
+
const existing = {
|
|
155
|
+
id: "r1",
|
|
156
|
+
code: "to_truong",
|
|
157
|
+
name: "Tổ trưởng",
|
|
158
|
+
description: "",
|
|
159
|
+
status: "active",
|
|
160
|
+
landingPath: null,
|
|
161
|
+
workspaceId: "spa",
|
|
162
|
+
rank: 100,
|
|
163
|
+
isSystem: false,
|
|
164
|
+
rolePermissions: [{ resourceCode: "meal-order", actionCode: "view" }],
|
|
165
|
+
_count: { userRoles: 0 },
|
|
166
|
+
...role,
|
|
167
|
+
};
|
|
168
|
+
const created: any[] = [];
|
|
169
|
+
const updated: any[] = [];
|
|
170
|
+
const deleted: string[] = [];
|
|
171
|
+
|
|
172
|
+
const tx = {
|
|
173
|
+
role: {
|
|
174
|
+
create: async ({ data }: any) => {
|
|
175
|
+
created.push(data);
|
|
176
|
+
return { ...existing, ...data };
|
|
177
|
+
},
|
|
178
|
+
update: async ({ data }: any) => {
|
|
179
|
+
updated.push(data);
|
|
180
|
+
return { ...existing, ...data };
|
|
181
|
+
},
|
|
182
|
+
delete: async ({ where }: any) => {
|
|
183
|
+
deleted.push(where.id);
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
rolePermission: {
|
|
187
|
+
deleteMany: async () => undefined,
|
|
188
|
+
createMany: async () => undefined,
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const prisma: any = {
|
|
193
|
+
role: {
|
|
194
|
+
findUnique: async () => existing,
|
|
195
|
+
fields: {
|
|
196
|
+
workspaceId: { name: "workspaceId" },
|
|
197
|
+
rank: { name: "rank" },
|
|
198
|
+
isSystem: { name: "isSystem" },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
systemConfig: { upsert: async () => undefined },
|
|
202
|
+
$transaction: async (fn: any) => fn(tx),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const actor = {
|
|
206
|
+
userId: "u-spa",
|
|
207
|
+
scope: {
|
|
208
|
+
canViewAll: false,
|
|
209
|
+
rootIds: ["spa"],
|
|
210
|
+
allowedIds: ["spa"],
|
|
211
|
+
adminIds: ["spa"],
|
|
212
|
+
defaultId: "spa",
|
|
213
|
+
},
|
|
214
|
+
canManageAll: false,
|
|
215
|
+
permissions: new Set(["meal-order:view", "meal-order:create"]),
|
|
216
|
+
rank: 50,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const deps = {
|
|
220
|
+
prisma,
|
|
221
|
+
getSession: async () => ({ user: { id: "u-spa" } }),
|
|
222
|
+
getCrudPermissions: async () => ({
|
|
223
|
+
read: true,
|
|
224
|
+
create: true,
|
|
225
|
+
update: true,
|
|
226
|
+
delete: true,
|
|
227
|
+
}),
|
|
228
|
+
getDelegationActor: async () => actor,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return { deps, actor, created, updated, deleted };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
describe("roles route handlers — uỷ quyền", () => {
|
|
235
|
+
it("POST đóng dấu đơn vị của người tạo, không để vai trò dùng chung", async () => {
|
|
236
|
+
const { deps, created } = tenantDb();
|
|
237
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
238
|
+
post({ code: "ca_truong", name: "Ca trưởng", rank: 90, permissions: ["view:meal-order"] })
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
expect(res.status).toBe(201);
|
|
242
|
+
expect(created[0].workspaceId).toBe("spa");
|
|
243
|
+
expect(created[0].rank).toBe(90);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("POST từ chối quyền quản trị hệ thống (403 kèm mã D4)", async () => {
|
|
247
|
+
const { deps, created } = tenantDb();
|
|
248
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
249
|
+
post({ code: "tro_ly", name: "Trợ lý", rank: 90, permissions: ["create:role"] })
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(res.status).toBe(403);
|
|
253
|
+
expect((await res.json()).code).toBe("D4_DANGEROUS_ROLE");
|
|
254
|
+
expect(created).toHaveLength(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("POST từ chối quyền vượt trần của chính người tạo", async () => {
|
|
258
|
+
const { deps } = tenantDb();
|
|
259
|
+
const res = await createRolesCollectionHandlers(deps as any).POST(
|
|
260
|
+
post({ code: "kt", name: "Kế toán", rank: 90, permissions: ["view:payroll"] })
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(res.status).toBe(403);
|
|
264
|
+
expect((await res.json()).code).toBe("D2_EXCEEDS_CEILING");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("PUT chặn sửa vai trò DÙNG CHUNG (workspaceId null)", async () => {
|
|
268
|
+
const { deps, updated } = tenantDb({ workspaceId: null });
|
|
269
|
+
const res = await createRoleItemHandlers(deps as any).PUT(
|
|
270
|
+
put({ name: "Đổi tên", permissions: ["view:meal-order"] }),
|
|
271
|
+
ctx as any
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(res.status).toBe(403);
|
|
275
|
+
expect(updated).toHaveLength(0);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("PUT chặn sửa vai trò của đơn vị khác", async () => {
|
|
279
|
+
const { deps } = tenantDb({ workspaceId: "tanloc" });
|
|
280
|
+
const res = await createRoleItemHandlers(deps as any).PUT(
|
|
281
|
+
put({ name: "Đổi tên", permissions: [] }),
|
|
282
|
+
ctx as any
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(res.status).toBe(403);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("PUT sửa vai trò trong nhánh mình thì qua", async () => {
|
|
289
|
+
const { deps, updated } = tenantDb();
|
|
290
|
+
const res = await createRoleItemHandlers(deps as any).PUT(
|
|
291
|
+
put({ name: "Tổ trưởng bếp", permissions: ["view:meal-order"] }),
|
|
292
|
+
ctx as any
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(res.status).toBe(200);
|
|
296
|
+
expect(updated[0].name).toBe("Tổ trưởng bếp");
|
|
297
|
+
});
|
|
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
|
+
|
|
368
|
+
it("DELETE chặn vai trò hệ thống", async () => {
|
|
369
|
+
const { deps, deleted } = tenantDb({ isSystem: true });
|
|
370
|
+
const res = await createRoleItemHandlers(deps as any).DELETE(
|
|
371
|
+
new Request("http://app.test/api/roles/r1", { method: "DELETE" }),
|
|
372
|
+
ctx as any
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
expect(res.status).toBe(403);
|
|
376
|
+
expect(deleted).toHaveLength(0);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("GET vai trò ngoài phạm vi trả 404 chứ không phải 403 (đừng xác nhận nó tồn tại)", async () => {
|
|
380
|
+
const { deps } = tenantDb({ workspaceId: "tanloc" });
|
|
381
|
+
const res = await createRoleItemHandlers(deps as any).GET(
|
|
382
|
+
new Request("http://app.test/api/roles/r1"),
|
|
383
|
+
ctx as any
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
expect(res.status).toBe(404);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("GET vai trò dùng chung: xem được, canManage = false", async () => {
|
|
390
|
+
const { deps } = tenantDb({ workspaceId: null });
|
|
391
|
+
const res = await createRoleItemHandlers(deps as any).GET(
|
|
392
|
+
new Request("http://app.test/api/roles/r1"),
|
|
393
|
+
ctx as any
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
expect(res.status).toBe(200);
|
|
397
|
+
expect(await res.json()).toMatchObject({ canManage: false });
|
|
398
|
+
});
|
|
399
|
+
});
|
|
@@ -49,6 +49,25 @@ export const RoleCard = memo(function RoleCard({
|
|
|
49
49
|
const router = useRouter();
|
|
50
50
|
const permissionsCount = role.permissions.length;
|
|
51
51
|
const roleColor = roleColors[role.code] || "bg-indigo-500";
|
|
52
|
+
// Vai trò dùng chung / vai trò hệ thống / vai trò đơn vị khác: XEM được (để
|
|
53
|
+
// biết mình đang gán gì) nhưng không sửa. `undefined` = app không xét.
|
|
54
|
+
const managed = role.canManage !== false;
|
|
55
|
+
const readOnlyLabel = role.isSystem
|
|
56
|
+
? "Hệ thống"
|
|
57
|
+
: role.canManage === false && !role.workspaceId
|
|
58
|
+
? "Dùng chung"
|
|
59
|
+
: null;
|
|
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;
|
|
52
71
|
|
|
53
72
|
const handleEdit = useCallback(() => {
|
|
54
73
|
router.push(`/${lang}/roles/${role.id}/edit`);
|
|
@@ -77,21 +96,45 @@ export const RoleCard = memo(function RoleCard({
|
|
|
77
96
|
</CardDescription>
|
|
78
97
|
</div>
|
|
79
98
|
</div>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
<div className="flex items-center gap-1.5">
|
|
100
|
+
{readOnlyLabel && (
|
|
101
|
+
<Badge
|
|
102
|
+
variant="outline"
|
|
103
|
+
className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
|
|
104
|
+
title="Vai trò do cấp trên quản lý — bạn xem được nhưng không sửa"
|
|
105
|
+
>
|
|
106
|
+
{readOnlyLabel}
|
|
107
|
+
</Badge>
|
|
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
|
+
)}
|
|
122
|
+
{role.status === "active" ? (
|
|
123
|
+
<Badge
|
|
124
|
+
variant="outline"
|
|
125
|
+
className="bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20 text-[10px] px-2 py-0 h-5 font-semibold"
|
|
126
|
+
>
|
|
127
|
+
Hoạt động
|
|
128
|
+
</Badge>
|
|
129
|
+
) : (
|
|
130
|
+
<Badge
|
|
131
|
+
variant="outline"
|
|
132
|
+
className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
|
|
133
|
+
>
|
|
134
|
+
Khóa
|
|
135
|
+
</Badge>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
95
138
|
</div>
|
|
96
139
|
</CardHeader>
|
|
97
140
|
<CardContent className="p-4 pt-0 space-y-3">
|
|
@@ -119,7 +162,7 @@ export const RoleCard = memo(function RoleCard({
|
|
|
119
162
|
</div>
|
|
120
163
|
|
|
121
164
|
<div className="flex gap-2 pt-2">
|
|
122
|
-
{canUpdate && (
|
|
165
|
+
{canUpdate && managed && (
|
|
123
166
|
<Button
|
|
124
167
|
variant="outline"
|
|
125
168
|
size="sm"
|
|
@@ -141,7 +184,7 @@ export const RoleCard = memo(function RoleCard({
|
|
|
141
184
|
Chép
|
|
142
185
|
</Button>
|
|
143
186
|
)}
|
|
144
|
-
{canDelete && (
|
|
187
|
+
{canDelete && managed && (
|
|
145
188
|
<Button
|
|
146
189
|
variant="outline"
|
|
147
190
|
size="sm"
|