@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 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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.78",
4
+ "version": "0.1.79",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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
- {role.status === "active" ? (
81
- <Badge
82
- variant="outline"
83
- 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"
84
- >
85
- Hoạt động
86
- </Badge>
87
- ) : (
88
- <Badge
89
- variant="outline"
90
- className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
91
- >
92
- Khóa
93
- </Badge>
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
- setFormData((prev) => ({ ...prev, permissions: [...tpl.permissions] }))
559
+ // Mẫu 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: `${tpl.permissions.length} quyền — chỉnh tiếp rồi bấm Lưu.`,
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(formData),
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={!canUpdate}
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
- canUpdate && "cursor-pointer hover:ring-2 hover:ring-primary/40"
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
  />