@goplusvn/core 0.1.78 → 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 +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +183 -0
- package/src/rbac/components/roles/role-card.tsx +36 -17
- package/src/rbac/pages/role-form-page.tsx +135 -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 +180 -10
- package/src/rbac/types.ts +9 -0
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 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
|
+
|
|
5
|
+
Lỗ hổng đối xứng của đợt 0.1.76: người dùng đã bị kẹp theo workspace, còn VAI
|
|
6
|
+
TRÒ thì không. Chặn *gán* vai trò mạnh (D4) mà để ngỏ đường *tạo* vai trò mạnh
|
|
7
|
+
chỉ là hàng rào có cửa sau — admin đơn vị tạo "Trợ lý" kèm `role:update` rồi tự
|
|
8
|
+
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
|
|
9
|
+
`getDelegationActor` / `scope` chạy y như trước.
|
|
10
|
+
|
|
11
|
+
- `workspace/delegation.ts` — thêm `assertCanManageRole`, `assertCanDeleteRole`,
|
|
12
|
+
`canManageRole`, `defaultRoleWorkspaceIdFor`, type `RoleDraft`. Vai trò mới bị
|
|
13
|
+
đóng dấu `workspaceId` của người tạo, ép `rank > actor.rank`, chặn quyền nguy
|
|
14
|
+
hiểm (D4) và quyền vượt trần của chính người tạo (D2).
|
|
15
|
+
- `rbac/role-service.ts` — `getRolesData(db, params, schema, scope?)` nhận
|
|
16
|
+
`RoleQueryScope`: lọc theo `adminWorkspaceIds` (+ vai trò dùng chung, chỉ
|
|
17
|
+
đọc), kẹp cả mảng `users[]` nhúng trong mỗi vai trò bằng `userWhere` — trước
|
|
18
|
+
đó trang vai trò là cửa hậu xem toàn bộ danh bạ. Trả thêm `workspaceId` /
|
|
19
|
+
`isSystem` / `rank` / `canManage`.
|
|
20
|
+
- `rbac/route-handlers.ts` — dep tuỳ chọn `getDelegationActor`. GET lọc theo
|
|
21
|
+
phạm vi; GET một vai trò ngoài phạm vi trả **404** (403 là xác nhận nó tồn
|
|
22
|
+
tại); POST/PUT/DELETE đi qua cổng uỷ quyền, từ chối trả 403 kèm mã D1/D2/D4.
|
|
23
|
+
- `user/user-service.ts` — `getAssignableRoles(db, actor)`: ô chọn vai trò trong
|
|
24
|
+
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
|
|
25
|
+
xong bấm lưu mới ăn 403.
|
|
26
|
+
- `rbac/pages/role-form-page.tsx` — prop `grantablePermissions`: ma trận quyền
|
|
27
|
+
cắt theo trần quyền người tạo ngay tại `resourceInfo.allowed` (mọi thứ dựng
|
|
28
|
+
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
|
|
29
|
+
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
|
|
30
|
+
tước quyền.
|
|
31
|
+
- `rbac` UI — `Role.canManage` tắt nút Sửa/Xoá và ô nhiệt trong danh sách, kèm
|
|
32
|
+
huy hiệu "Dùng chung" / "Hệ thống" để người dùng hiểu vì sao nút biến mất.
|
|
33
|
+
|
|
34
|
+
### Cô lập DỮ LIỆU theo vai trò (`Role.dataScope` → nấc phạm vi)
|
|
35
|
+
|
|
36
|
+
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
|
|
37
|
+
nghiệp vụ** vẫn mở. Thang 5 nấc `none | own | workspace | subtree | all` (Δ1,
|
|
38
|
+
kiểu Dynamics 365) trước đây là code chết — nay được đấu vào phạm vi thật.
|
|
39
|
+
|
|
40
|
+
Quyết định thiết kế đáng nhớ: bốn nấc `none/workspace/subtree/all` được **gấp
|
|
41
|
+
thẳng vào `canViewAll` + `allowedIds` lúc dựng phạm vi**, nên mọi `scopedWhere`
|
|
42
|
+
/ `memberScopeWhere` / guard lớp 2 đang có tự tuân theo — **không call-site nào
|
|
43
|
+
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.
|
|
44
|
+
|
|
45
|
+
- `workspace/types.ts` — `WorkspaceScope` thêm `level?` + `userId?`;
|
|
46
|
+
`WorkspaceScopeConfig` thêm `getScopeLevel?(session)` và `ownerField?`.
|
|
47
|
+
- `workspace/scope.ts` — `getWorkspaceScope` áp nấc: `all` → bỏ cô lập kể cả khi
|
|
48
|
+
không có quyền view-all; `none` → sentinel; `workspace` → **không** bung con
|
|
49
|
+
cháu; `subtree`/bỏ trống → y hệt hôm nay. `scopedWhere(scope, { ownerField })`
|
|
50
|
+
và `memberScopeWhere` hiểu nấc `own`; **thiếu `ownerField` là fail-closed**
|
|
51
|
+
(khớp sentinel) chứ không âm thầm nới lên `subtree`.
|
|
52
|
+
- `rbac/route-handlers.ts` — đọc/ghi `Role.dataScope` (dò cột bằng
|
|
53
|
+
`role.fields`, app chưa có cột thì bỏ qua). **Chỉ vai vận hành**
|
|
54
|
+
(`actor.canManageAll`) đặt được nấc: admin đơn vị gửi `dataScope` lên thì
|
|
55
|
+
field bị bỏ im lặng, nếu không họ tự nới tầm nhìn của chính mình.
|
|
56
|
+
- `rbac/pages/role-form-page.tsx` — ô chọn "Cô lập dữ liệu" sau prop
|
|
57
|
+
`showDataScope` (app truyền `canViewAllWorkspaces(session)`).
|
|
58
|
+
|
|
3
59
|
## 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
60
|
|
|
5
61
|
Nguồn: `docs/WORKSPACE-MULTITENANT-PLAN.md` + `docs/WORKSPACE-RESEARCH-IAM.md`
|
package/package.json
CHANGED
|
@@ -145,3 +145,186 @@ 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
|
+
it("DELETE chặn vai trò hệ thống", async () => {
|
|
300
|
+
const { deps, deleted } = tenantDb({ isSystem: true });
|
|
301
|
+
const res = await createRoleItemHandlers(deps as any).DELETE(
|
|
302
|
+
new Request("http://app.test/api/roles/r1", { method: "DELETE" }),
|
|
303
|
+
ctx as any
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(res.status).toBe(403);
|
|
307
|
+
expect(deleted).toHaveLength(0);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
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 () => {
|
|
311
|
+
const { deps } = tenantDb({ workspaceId: "tanloc" });
|
|
312
|
+
const res = await createRoleItemHandlers(deps as any).GET(
|
|
313
|
+
new Request("http://app.test/api/roles/r1"),
|
|
314
|
+
ctx as any
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
expect(res.status).toBe(404);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("GET vai trò dùng chung: xem được, canManage = false", async () => {
|
|
321
|
+
const { deps } = tenantDb({ workspaceId: null });
|
|
322
|
+
const res = await createRoleItemHandlers(deps as any).GET(
|
|
323
|
+
new Request("http://app.test/api/roles/r1"),
|
|
324
|
+
ctx as any
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
expect(res.status).toBe(200);
|
|
328
|
+
expect(await res.json()).toMatchObject({ canManage: false });
|
|
329
|
+
});
|
|
330
|
+
});
|
|
@@ -49,6 +49,14 @@ 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;
|
|
52
60
|
|
|
53
61
|
const handleEdit = useCallback(() => {
|
|
54
62
|
router.push(`/${lang}/roles/${role.id}/edit`);
|
|
@@ -77,21 +85,32 @@ export const RoleCard = memo(function RoleCard({
|
|
|
77
85
|
</CardDescription>
|
|
78
86
|
</div>
|
|
79
87
|
</div>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
<div className="flex items-center gap-1.5">
|
|
89
|
+
{readOnlyLabel && (
|
|
90
|
+
<Badge
|
|
91
|
+
variant="outline"
|
|
92
|
+
className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
|
|
93
|
+
title="Vai trò do cấp trên quản lý — bạn xem được nhưng không sửa"
|
|
94
|
+
>
|
|
95
|
+
{readOnlyLabel}
|
|
96
|
+
</Badge>
|
|
97
|
+
)}
|
|
98
|
+
{role.status === "active" ? (
|
|
99
|
+
<Badge
|
|
100
|
+
variant="outline"
|
|
101
|
+
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"
|
|
102
|
+
>
|
|
103
|
+
Hoạt động
|
|
104
|
+
</Badge>
|
|
105
|
+
) : (
|
|
106
|
+
<Badge
|
|
107
|
+
variant="outline"
|
|
108
|
+
className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
|
|
109
|
+
>
|
|
110
|
+
Khóa
|
|
111
|
+
</Badge>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
95
114
|
</div>
|
|
96
115
|
</CardHeader>
|
|
97
116
|
<CardContent className="p-4 pt-0 space-y-3">
|
|
@@ -119,7 +138,7 @@ export const RoleCard = memo(function RoleCard({
|
|
|
119
138
|
</div>
|
|
120
139
|
|
|
121
140
|
<div className="flex gap-2 pt-2">
|
|
122
|
-
{canUpdate && (
|
|
141
|
+
{canUpdate && managed && (
|
|
123
142
|
<Button
|
|
124
143
|
variant="outline"
|
|
125
144
|
size="sm"
|
|
@@ -141,7 +160,7 @@ export const RoleCard = memo(function RoleCard({
|
|
|
141
160
|
Chép
|
|
142
161
|
</Button>
|
|
143
162
|
)}
|
|
144
|
-
{canDelete && (
|
|
163
|
+
{canDelete && managed && (
|
|
145
164
|
<Button
|
|
146
165
|
variant="outline"
|
|
147
166
|
size="sm"
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
Info,
|
|
47
47
|
LayoutTemplate,
|
|
48
48
|
Save,
|
|
49
|
+
ShieldCheck,
|
|
49
50
|
X,
|
|
50
51
|
} from "lucide-react"
|
|
51
52
|
|
|
@@ -80,6 +81,46 @@ const STANDARD_ORDER = ["create", "update", "delete", "export", "import"]
|
|
|
80
81
|
/** Radix Select không nhận value rỗng — dùng mã riêng cho "tự suy ra". */
|
|
81
82
|
const LANDING_AUTO = "__auto__"
|
|
82
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
|
+
|
|
83
124
|
/** Bộ quyền mẫu áp khi TẠO vai trò mới (consumer định nghĩa). */
|
|
84
125
|
export interface RoleTemplate {
|
|
85
126
|
code: string
|
|
@@ -142,6 +183,8 @@ export interface RoleFormPageProps {
|
|
|
142
183
|
permissions: string[]
|
|
143
184
|
/** Trang mở đầu của vai trò. Null/undefined = tự suy ra. */
|
|
144
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
|
|
145
188
|
}
|
|
146
189
|
actionLabels?: Record<string, ActionLabelConfig>
|
|
147
190
|
roleTemplates?: RoleTemplate[]
|
|
@@ -151,6 +194,24 @@ export interface RoleFormPageProps {
|
|
|
151
194
|
menuTree?: MenuTreeSection[]
|
|
152
195
|
/** Endpoint nhật ký đổi quyền (edit mode) — vd /api/roles/{id}/history. */
|
|
153
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
|
|
154
215
|
}
|
|
155
216
|
|
|
156
217
|
export function RoleFormPage({
|
|
@@ -167,9 +228,16 @@ export function RoleFormPage({
|
|
|
167
228
|
resourcePages,
|
|
168
229
|
menuTree,
|
|
169
230
|
historyEndpoint,
|
|
231
|
+
grantablePermissions,
|
|
232
|
+
showDataScope,
|
|
170
233
|
}: RoleFormPageProps) {
|
|
171
234
|
const router = useRouter()
|
|
172
235
|
|
|
236
|
+
const grantable = React.useMemo(
|
|
237
|
+
() => (grantablePermissions ? new Set(grantablePermissions) : null),
|
|
238
|
+
[grantablePermissions]
|
|
239
|
+
)
|
|
240
|
+
|
|
173
241
|
const mergedActionLabels = React.useMemo(
|
|
174
242
|
() => ({ ...DEFAULT_ACTION_LABELS, ...actionLabels }),
|
|
175
243
|
[actionLabels]
|
|
@@ -199,6 +267,12 @@ export function RoleFormPage({
|
|
|
199
267
|
landingPath: initialData?.landingPath || "",
|
|
200
268
|
})
|
|
201
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
|
+
|
|
202
276
|
const getLocalizedName = React.useCallback(
|
|
203
277
|
(key: string | null) => {
|
|
204
278
|
if (!key) return ""
|
|
@@ -258,6 +332,17 @@ export function RoleFormPage({
|
|
|
258
332
|
} catch {
|
|
259
333
|
allowed = actions.map((a) => a.code).filter((c) => fallback.includes(c))
|
|
260
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
|
+
|
|
261
346
|
map.set(resource.code, {
|
|
262
347
|
name: getLocalizedName(resource.name),
|
|
263
348
|
icon: resource.icon,
|
|
@@ -265,7 +350,7 @@ export function RoleFormPage({
|
|
|
265
350
|
})
|
|
266
351
|
})
|
|
267
352
|
return map
|
|
268
|
-
}, [resources, actions, getLocalizedName])
|
|
353
|
+
}, [resources, actions, getLocalizedName, grantable])
|
|
269
354
|
|
|
270
355
|
/** Action hiển thị dưới một trang (bỏ view = chính checkbox trang; bỏ lookup
|
|
271
356
|
* = combo tự động). CRUD chuẩn trước, nghiệp vụ sau. */
|
|
@@ -471,9 +556,17 @@ export function RoleFormPage({
|
|
|
471
556
|
const applyTemplate = (templateCode: string) => {
|
|
472
557
|
const tpl = roleTemplates?.find((t) => t.code === templateCode)
|
|
473
558
|
if (!tpl) return
|
|
474
|
-
|
|
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 }))
|
|
475
565
|
toast.success(`Đã áp bộ quyền mẫu "${tpl.label}"`, {
|
|
476
|
-
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).`,
|
|
477
570
|
})
|
|
478
571
|
}
|
|
479
572
|
|
|
@@ -487,7 +580,9 @@ export function RoleFormPage({
|
|
|
487
580
|
const res = await fetch(url, {
|
|
488
581
|
method,
|
|
489
582
|
headers: { "Content-Type": "application/json" },
|
|
490
|
-
body: JSON.stringify(
|
|
583
|
+
body: JSON.stringify(
|
|
584
|
+
showDataScope ? { ...formData, dataScope: dataScope || null } : formData
|
|
585
|
+
),
|
|
491
586
|
})
|
|
492
587
|
if (!res.ok) {
|
|
493
588
|
await throwFetchError(res, `Failed to ${mode} role`)
|
|
@@ -886,6 +981,42 @@ export function RoleFormPage({
|
|
|
886
981
|
</div>
|
|
887
982
|
)}
|
|
888
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
|
+
|
|
889
1020
|
{/* Trang mở đầu — nơi vai trò này hạ cánh sau khi đăng nhập */}
|
|
890
1021
|
<div className="flex items-center gap-1.5">
|
|
891
1022
|
<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
|
/>
|