@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 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
@@ -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.81",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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
- {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
- )}
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"