@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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Khung TRÁI của trang Không gian làm việc: cơ cấu tổ chức, dạng cây gấp mở
|
|
4
|
+
// được — mỗi nút MỘT DÒNG, mọi dòng cùng một khuôn.
|
|
5
|
+
//
|
|
6
|
+
// Vì sao không còn "khối tổ chức" (mỗi gốc một thẻ có nền màu, tiêu đề to, câu
|
|
7
|
+
// giải thích ba dòng): khuôn đó vẽ nút gốc bằng một ngôn ngữ, nút con bằng một
|
|
8
|
+
// ngôn ngữ khác, nên cây đọc ra thành "một cái thẻ, và mấy dòng dính dưới nó"
|
|
9
|
+
// chứ không phải một cây. Thêm nữa mỗi gốc chiếm gần nửa màn hình cho phần
|
|
10
|
+
// trang trí, trong khi thứ người ta quét mắt tìm chỉ là tên bộ phận.
|
|
11
|
+
//
|
|
12
|
+
// Khuôn mới mượn từ trình duyệt tệp, thứ ai cũng đã đọc quen:
|
|
13
|
+
// ▾ Toàn hệ thống Nút gốc 15 👤
|
|
14
|
+
// ▾ Nhà ăn Tấn Lộc Vận hành 3 👤
|
|
15
|
+
// Bếp 2 👤
|
|
16
|
+
// Spartronics Khách hàng 8 👤
|
|
17
|
+
// Mũi tên gấp mở nói ra quan hệ cha–con rõ hơn mọi mức thụt lề, và gấp lại được
|
|
18
|
+
// nghĩa là cây 60 bộ phận vẫn xem được mà không phải cuộn.
|
|
19
|
+
//
|
|
20
|
+
// Mỗi dòng mang luôn MENU THAO TÁC của chính nó (thêm cấp dưới, sửa, chuyển
|
|
21
|
+
// nhánh, xoá). Trước đây thao tác chỉ nằm ở khung phải sau một nút ba chấm
|
|
22
|
+
// không nhãn — người dùng tìm chức năng xoá không ra. Menu hiện khi rê chuột
|
|
23
|
+
// hoặc khi dòng đang được chọn, nên lúc bình thường cây vẫn sạch.
|
|
24
|
+
|
|
25
|
+
import * as React from "react";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
ChevronRight,
|
|
29
|
+
MoreHorizontal,
|
|
30
|
+
Pencil,
|
|
31
|
+
Plus,
|
|
32
|
+
RotateCcw,
|
|
33
|
+
Shield,
|
|
34
|
+
Trash2,
|
|
35
|
+
Users,
|
|
36
|
+
} from "lucide-react";
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
Button,
|
|
40
|
+
DropdownMenu,
|
|
41
|
+
DropdownMenuContent,
|
|
42
|
+
DropdownMenuItem,
|
|
43
|
+
DropdownMenuSeparator,
|
|
44
|
+
DropdownMenuTrigger,
|
|
45
|
+
} from "../../ui";
|
|
46
|
+
import { cn } from "../../utils";
|
|
47
|
+
import type { WorkspaceTreeNode } from "./workspace-tree-view";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Danh tính của một nút — thứ core KHÔNG tự suy ra được.
|
|
51
|
+
*
|
|
52
|
+
* "Đơn vị vận hành" với "khách hàng" trong dữ liệu là hai dòng `workspace` y hệt
|
|
53
|
+
* nhau; cái phân biệt chúng là quyền `view-all-workspaces` nằm ở tầng vai trò
|
|
54
|
+
* của từng app. Nên app phải tự nói ra, core chỉ vẽ.
|
|
55
|
+
*/
|
|
56
|
+
export interface WorkspaceOrgAnnotation {
|
|
57
|
+
/** Huy hiệu cạnh tên, ví dụ "Vận hành" / "Khách hàng". */
|
|
58
|
+
badge?: string;
|
|
59
|
+
/** Một câu giải thích phạm vi — hiện ở KHUNG PHẢI, nơi có chỗ để đọc. */
|
|
60
|
+
hint?: string;
|
|
61
|
+
/** `brand` = tổ chức của chính mình; `neutral` = tổ chức bên ngoài. */
|
|
62
|
+
tone?: "brand" | "neutral";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WorkspaceRollup {
|
|
66
|
+
/** Tổng thành viên của cả nhánh, kể cả nút gốc. */
|
|
67
|
+
members: number;
|
|
68
|
+
admins: number;
|
|
69
|
+
/** Số nút con cháu, KHÔNG tính chính nó. */
|
|
70
|
+
units: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Cộng dồn cả nhánh.
|
|
75
|
+
*
|
|
76
|
+
* Dùng cho dòng đã GẤP LẠI: gấp một nhánh có 4 bộ phận mà con số tụt từ 12
|
|
77
|
+
* xuống 2 thì người dùng tưởng vừa làm mất người. Dòng mở thì hiện số gán trực
|
|
78
|
+
* tiếp, dòng gấp thì hiện tổng cả nhánh — và nói ra bằng dấu ngoặc.
|
|
79
|
+
*/
|
|
80
|
+
export function rollupCounts(node: WorkspaceTreeNode): WorkspaceRollup {
|
|
81
|
+
return node.children.reduce<WorkspaceRollup>(
|
|
82
|
+
(acc, child) => {
|
|
83
|
+
const sub = rollupCounts(child);
|
|
84
|
+
return {
|
|
85
|
+
members: acc.members + sub.members,
|
|
86
|
+
admins: acc.admins + sub.admins,
|
|
87
|
+
units: acc.units + sub.units + 1,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
members: node.memberCount ?? 0,
|
|
92
|
+
admins: node.adminCount ?? 0,
|
|
93
|
+
units: 0,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Giữ nhánh nào có ÍT NHẤT một hậu duệ khớp — cắt cha thì mất ngữ cảnh. */
|
|
99
|
+
export function filterWorkspaceTree(
|
|
100
|
+
nodes: WorkspaceTreeNode[],
|
|
101
|
+
term: string,
|
|
102
|
+
): WorkspaceTreeNode[] {
|
|
103
|
+
const needle = term.trim().toLowerCase();
|
|
104
|
+
if (!needle) return nodes;
|
|
105
|
+
const walk = (node: WorkspaceTreeNode): WorkspaceTreeNode | null => {
|
|
106
|
+
const children = node.children
|
|
107
|
+
.map(walk)
|
|
108
|
+
.filter((n): n is WorkspaceTreeNode => n !== null);
|
|
109
|
+
const hit =
|
|
110
|
+
node.name.toLowerCase().includes(needle) ||
|
|
111
|
+
node.code.toLowerCase().includes(needle);
|
|
112
|
+
if (!hit && children.length === 0) return null;
|
|
113
|
+
return { ...node, children };
|
|
114
|
+
};
|
|
115
|
+
return nodes.map(walk).filter((n): n is WorkspaceTreeNode => n !== null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Mọi id trong cây — để mở sẵn toàn bộ khi đang lọc. */
|
|
119
|
+
function allIds(nodes: WorkspaceTreeNode[]): string[] {
|
|
120
|
+
return nodes.flatMap((node) => [node.id, ...allIds(node.children)]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface WorkspaceTreeActions {
|
|
124
|
+
/** `null` = nút này hết cấp, ẩn mục "Thêm…". Chuỗi = nhãn cấp con. */
|
|
125
|
+
childKindLabel?: (node: WorkspaceTreeNode) => string | null;
|
|
126
|
+
/** Người dùng có ghi được ĐÚNG nút này không. */
|
|
127
|
+
canManage?: (node: WorkspaceTreeNode) => boolean;
|
|
128
|
+
onAddChild?: (node: WorkspaceTreeNode) => void;
|
|
129
|
+
onEdit?: (node: WorkspaceTreeNode) => void;
|
|
130
|
+
onMove?: (node: WorkspaceTreeNode) => void;
|
|
131
|
+
onDelete?: (node: WorkspaceTreeNode) => void;
|
|
132
|
+
onRestore?: (node: WorkspaceTreeNode) => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface WorkspaceTreePanelProps extends WorkspaceTreeActions {
|
|
136
|
+
nodes: WorkspaceTreeNode[];
|
|
137
|
+
/** Danh tính từng nút, tra theo `code` (không theo id: id đổi theo môi trường). */
|
|
138
|
+
annotations?: Record<string, WorkspaceOrgAnnotation>;
|
|
139
|
+
activeId?: string | null;
|
|
140
|
+
onSelect?: (node: WorkspaceTreeNode) => void;
|
|
141
|
+
/** Đang lọc: mở sẵn mọi nhánh, vì kết quả khớp thường nằm ở cấp sâu. */
|
|
142
|
+
expandAll?: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Thụt lề mỗi cấp. Cột trái hẹp — lùi to là tên bộ phận hết chỗ. */
|
|
146
|
+
const INDENT = 1.125;
|
|
147
|
+
|
|
148
|
+
export function WorkspaceTreePanel({
|
|
149
|
+
nodes,
|
|
150
|
+
annotations,
|
|
151
|
+
activeId,
|
|
152
|
+
onSelect,
|
|
153
|
+
expandAll = false,
|
|
154
|
+
childKindLabel,
|
|
155
|
+
canManage,
|
|
156
|
+
onAddChild,
|
|
157
|
+
onEdit,
|
|
158
|
+
onMove,
|
|
159
|
+
onDelete,
|
|
160
|
+
onRestore,
|
|
161
|
+
}: WorkspaceTreePanelProps) {
|
|
162
|
+
// Mặc định MỞ HẾT: cây tổ chức của một công ty là vài chục dòng, và người mở
|
|
163
|
+
// trang này đang đi tìm một bộ phận cụ thể chứ không ngắm cơ cấu. Gấp lại là
|
|
164
|
+
// việc người dùng chủ động làm khi cây đã dài.
|
|
165
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
|
166
|
+
|
|
167
|
+
const forced = React.useMemo(
|
|
168
|
+
() => (expandAll ? new Set(allIds(nodes)) : null),
|
|
169
|
+
[expandAll, nodes],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const isOpen = (id: string) => (forced ? true : !collapsed.has(id));
|
|
173
|
+
|
|
174
|
+
const toggle = (id: string) =>
|
|
175
|
+
setCollapsed((prev) => {
|
|
176
|
+
const next = new Set(prev);
|
|
177
|
+
if (next.has(id)) next.delete(id);
|
|
178
|
+
else next.add(id);
|
|
179
|
+
return next;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const renderRow = (
|
|
183
|
+
node: WorkspaceTreeNode,
|
|
184
|
+
depth: number,
|
|
185
|
+
): React.ReactNode => {
|
|
186
|
+
const active = activeId === node.id;
|
|
187
|
+
const hasChildren = node.children.length > 0;
|
|
188
|
+
const open = isOpen(node.id);
|
|
189
|
+
const annotation = annotations?.[node.code];
|
|
190
|
+
const total = rollupCounts(node);
|
|
191
|
+
// Dòng gấp lại nói TỔNG cả nhánh, dòng mở nói số gán trực tiếp — con số kia
|
|
192
|
+
// đã nằm ngay dưới ở các dòng con rồi, lặp lại thành cộng hai lần.
|
|
193
|
+
const showRollup = hasChildren && !open;
|
|
194
|
+
const members = showRollup ? total.members : (node.memberCount ?? 0);
|
|
195
|
+
const admins = showRollup ? total.admins : (node.adminCount ?? 0);
|
|
196
|
+
const manage = canManage?.(node) ?? false;
|
|
197
|
+
const addLabel = childKindLabel?.(node) ?? null;
|
|
198
|
+
const inactive = node.isActive === false;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<React.Fragment key={node.id}>
|
|
202
|
+
<div
|
|
203
|
+
className={cn(
|
|
204
|
+
"group relative flex items-center gap-1 pr-1.5 text-sm transition-colors",
|
|
205
|
+
// Vạch chọn bên trái thay cho nền màu đậm: nền `primary/20` phủ cả
|
|
206
|
+
// dòng làm cột trái loang một mảng tím, mà đây là danh sách điều
|
|
207
|
+
// hướng — nó chỉ cần chỉ ra một dòng, không cần hét.
|
|
208
|
+
active
|
|
209
|
+
? "bg-accent font-medium before:absolute before:inset-y-0 before:left-0 before:w-0.5 before:bg-primary"
|
|
210
|
+
: "hover:bg-muted/60",
|
|
211
|
+
inactive && "opacity-55",
|
|
212
|
+
)}
|
|
213
|
+
style={{ paddingLeft: `${0.375 + depth * INDENT}rem` }}
|
|
214
|
+
>
|
|
215
|
+
{/* Mũi tên là nút RIÊNG, không gộp vào nút chọn: gấp một nhánh để
|
|
216
|
+
nhìn cho gọn không có nghĩa là muốn mở hồ sơ của nó. */}
|
|
217
|
+
{hasChildren ? (
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={() => toggle(node.id)}
|
|
221
|
+
disabled={Boolean(forced)}
|
|
222
|
+
aria-label={open ? "Thu gọn" : "Mở rộng"}
|
|
223
|
+
aria-expanded={open}
|
|
224
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-background hover:text-foreground disabled:opacity-40"
|
|
225
|
+
>
|
|
226
|
+
<ChevronRight
|
|
227
|
+
className={cn(
|
|
228
|
+
"h-3.5 w-3.5 transition-transform",
|
|
229
|
+
open && "rotate-90",
|
|
230
|
+
)}
|
|
231
|
+
/>
|
|
232
|
+
</button>
|
|
233
|
+
) : (
|
|
234
|
+
<span aria-hidden className="h-5 w-5 shrink-0" />
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
onClick={() => onSelect?.(node)}
|
|
240
|
+
aria-current={active ? "true" : undefined}
|
|
241
|
+
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
|
242
|
+
>
|
|
243
|
+
<span className="min-w-0 flex-1 truncate">
|
|
244
|
+
{node.name}
|
|
245
|
+
{inactive ? (
|
|
246
|
+
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
|
247
|
+
(đã ngừng)
|
|
248
|
+
</span>
|
|
249
|
+
) : null}
|
|
250
|
+
</span>
|
|
251
|
+
|
|
252
|
+
{annotation?.badge ? (
|
|
253
|
+
<span
|
|
254
|
+
className={cn(
|
|
255
|
+
"shrink-0 rounded border px-1.5 py-px text-[10px] font-medium",
|
|
256
|
+
annotation.tone === "brand"
|
|
257
|
+
? "border-primary/30 bg-primary/10 text-foreground"
|
|
258
|
+
: "border-border bg-muted text-muted-foreground",
|
|
259
|
+
)}
|
|
260
|
+
>
|
|
261
|
+
{annotation.badge}
|
|
262
|
+
</span>
|
|
263
|
+
) : null}
|
|
264
|
+
|
|
265
|
+
{/* Có icon người đi kèm chứ không chỉ số trần: dòng nhiều cột mà
|
|
266
|
+
không có tiêu đề cột thì con số một mình không tự nói ra nó
|
|
267
|
+
đang đếm cái gì. */}
|
|
268
|
+
<span
|
|
269
|
+
className={cn(
|
|
270
|
+
"flex shrink-0 items-center gap-1 text-xs tabular-nums",
|
|
271
|
+
active ? "text-foreground" : "text-muted-foreground",
|
|
272
|
+
)}
|
|
273
|
+
title={
|
|
274
|
+
showRollup
|
|
275
|
+
? `${total.members} người cả nhánh`
|
|
276
|
+
: `${members} người gán trực tiếp`
|
|
277
|
+
}
|
|
278
|
+
>
|
|
279
|
+
<Users className="h-3.5 w-3.5" />
|
|
280
|
+
{showRollup ? `(${members})` : members}
|
|
281
|
+
{admins > 0 ? (
|
|
282
|
+
<Shield
|
|
283
|
+
className="h-3.5 w-3.5 text-primary"
|
|
284
|
+
aria-label={`${admins} quản trị`}
|
|
285
|
+
/>
|
|
286
|
+
) : null}
|
|
287
|
+
</span>
|
|
288
|
+
</button>
|
|
289
|
+
|
|
290
|
+
{/* Menu chỉ hiện khi rê chuột / dòng đang chọn / đang focus bàn phím:
|
|
291
|
+
hiện thường trực thì cả cột đầy dấu ba chấm, mà cột này để đọc cơ
|
|
292
|
+
cấu là chính. `focus-within` giữ đường đi bằng bàn phím. */}
|
|
293
|
+
{manage ? (
|
|
294
|
+
<DropdownMenu>
|
|
295
|
+
<DropdownMenuTrigger asChild>
|
|
296
|
+
<Button
|
|
297
|
+
variant="ghost"
|
|
298
|
+
size="sm"
|
|
299
|
+
title="Thao tác"
|
|
300
|
+
className={cn(
|
|
301
|
+
"h-6 w-6 shrink-0 p-0 text-muted-foreground opacity-0 focus-visible:opacity-100 group-hover:opacity-100 data-[state=open]:opacity-100",
|
|
302
|
+
active && "opacity-100",
|
|
303
|
+
)}
|
|
304
|
+
>
|
|
305
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
306
|
+
</Button>
|
|
307
|
+
</DropdownMenuTrigger>
|
|
308
|
+
<DropdownMenuContent align="end" className="w-48">
|
|
309
|
+
{addLabel && !inactive ? (
|
|
310
|
+
<DropdownMenuItem onClick={() => onAddChild?.(node)}>
|
|
311
|
+
<Plus className="h-3.5 w-3.5" />
|
|
312
|
+
<span className="ml-2">Thêm {addLabel.toLowerCase()}</span>
|
|
313
|
+
</DropdownMenuItem>
|
|
314
|
+
) : null}
|
|
315
|
+
<DropdownMenuItem onClick={() => onEdit?.(node)}>
|
|
316
|
+
<Pencil className="h-3.5 w-3.5" />
|
|
317
|
+
<span className="ml-2">Sửa tên / mã</span>
|
|
318
|
+
</DropdownMenuItem>
|
|
319
|
+
<DropdownMenuItem onClick={() => onMove?.(node)}>
|
|
320
|
+
<ChevronRight className="h-3.5 w-3.5" />
|
|
321
|
+
<span className="ml-2">Chuyển nhánh</span>
|
|
322
|
+
</DropdownMenuItem>
|
|
323
|
+
<DropdownMenuSeparator />
|
|
324
|
+
{inactive ? (
|
|
325
|
+
<DropdownMenuItem onClick={() => onRestore?.(node)}>
|
|
326
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
327
|
+
<span className="ml-2">Khôi phục</span>
|
|
328
|
+
</DropdownMenuItem>
|
|
329
|
+
) : (
|
|
330
|
+
// Chữ "Xoá" phải có mặt: người đi tìm chức năng xoá gõ/quét
|
|
331
|
+
// mắt tìm đúng chữ đó, "Ngừng hoạt động" họ đọc thành một
|
|
332
|
+
// trạng thái nào khác và kết luận là trang không có xoá.
|
|
333
|
+
<DropdownMenuItem
|
|
334
|
+
className="text-destructive focus:text-destructive"
|
|
335
|
+
onClick={() => onDelete?.(node)}
|
|
336
|
+
>
|
|
337
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
338
|
+
<span className="ml-2">Xoá workspace</span>
|
|
339
|
+
</DropdownMenuItem>
|
|
340
|
+
)}
|
|
341
|
+
</DropdownMenuContent>
|
|
342
|
+
</DropdownMenu>
|
|
343
|
+
) : (
|
|
344
|
+
<span aria-hidden className="h-6 w-6 shrink-0" />
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{hasChildren && open ? (
|
|
349
|
+
// Vạch dọc mọc ra từ tim mũi tên của dòng cha: thụt lề một mình vẫn
|
|
350
|
+
// đọc được thành các dòng lệch nhau, nhất là khi tên dài rồi bị cắt.
|
|
351
|
+
<div className="relative">
|
|
352
|
+
<span
|
|
353
|
+
aria-hidden
|
|
354
|
+
className="absolute inset-y-0 w-px bg-border"
|
|
355
|
+
style={{ left: `${0.375 + depth * INDENT + 0.625}rem` }}
|
|
356
|
+
/>
|
|
357
|
+
{node.children.map((child) => renderRow(child, depth + 1))}
|
|
358
|
+
</div>
|
|
359
|
+
) : null}
|
|
360
|
+
</React.Fragment>
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return (
|
|
365
|
+
<div className="overflow-hidden rounded-xl border border-border bg-card py-1">
|
|
366
|
+
{nodes.map((node) => renderRow(node, 0))}
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
@@ -278,6 +278,154 @@ export function assignableRoles<T extends DelegationRole>(
|
|
|
278
278
|
});
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Vai trò đang được TẠO hoặc SỬA — đầu vào của đường quản trị vai trò.
|
|
283
|
+
*
|
|
284
|
+
* Khác `DelegationRole` (thứ đã có trong DB, dùng để hỏi "gán đi được không"):
|
|
285
|
+
* đây là bản nháp người dùng vừa bấm lưu, chưa qua cổng nào.
|
|
286
|
+
*/
|
|
287
|
+
export interface RoleDraft {
|
|
288
|
+
code: string;
|
|
289
|
+
/** Bỏ trống = để engine đóng dấu theo nhánh của người tạo. */
|
|
290
|
+
workspaceId?: string | null;
|
|
291
|
+
rank?: number | null;
|
|
292
|
+
isSystem?: boolean;
|
|
293
|
+
permissions: readonly string[];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Vai trò DÙNG CHUNG (`workspaceId === null`) là của cấp vận hành: admin nhánh
|
|
298
|
+
* NHÌN thấy để biết mình đang gán gì, nhưng không sửa được — một nhánh sửa thì
|
|
299
|
+
* mọi đơn vị khác lãnh đủ.
|
|
300
|
+
*/
|
|
301
|
+
export function canManageRole(
|
|
302
|
+
actor: DelegationActor,
|
|
303
|
+
role: Pick<DelegationRole, "workspaceId" | "isSystem">,
|
|
304
|
+
): boolean {
|
|
305
|
+
if (actor.canManageAll) return true;
|
|
306
|
+
if (role.isSystem) return false;
|
|
307
|
+
if (!role.workspaceId) return false;
|
|
308
|
+
return isWorkspaceAdmin(actor.scope, role.workspaceId);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Nhánh mà vai trò mới sẽ mang dấu. Người quản trị nhiều nhánh thì lấy nhánh
|
|
313
|
+
* mặc định nếu nó nằm trong quyền quản trị, không thì nhánh đầu tiên.
|
|
314
|
+
*/
|
|
315
|
+
export function defaultRoleWorkspaceIdFor(
|
|
316
|
+
actor: DelegationActor,
|
|
317
|
+
): string | null {
|
|
318
|
+
if (actor.canManageAll) return null;
|
|
319
|
+
const { defaultId, adminIds } = actor.scope;
|
|
320
|
+
if (defaultId && adminIds.includes(defaultId)) return defaultId;
|
|
321
|
+
return adminIds[0] ?? null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Cổng đầy đủ cho đường TẠO / SỬA một vai trò (D1 + D2 + D4).
|
|
326
|
+
*
|
|
327
|
+
* Đây là lỗ hổng đối xứng với `assertCanCreateUser`: chặn *gán* vai trò mạnh mà
|
|
328
|
+
* để ngỏ đường *tạo* vai trò mạnh thì admin nhánh chỉ cần đi vòng một bước —
|
|
329
|
+
* tạo vai trò "Trợ lý" có `role:update` rồi tự gán cho tài khoản mình vừa lập.
|
|
330
|
+
*
|
|
331
|
+
* `existing` là vai trò trong DB (null khi tạo mới). Trả về `workspaceId` +
|
|
332
|
+
* `rank` đã chuẩn hoá để route ghi thẳng, khỏi tự suy lại rồi suy sai.
|
|
333
|
+
*/
|
|
334
|
+
export function assertCanManageRole(
|
|
335
|
+
actor: DelegationActor,
|
|
336
|
+
existing: DelegationRole | null,
|
|
337
|
+
draft: RoleDraft,
|
|
338
|
+
): { workspaceId: string | null; rank: number } {
|
|
339
|
+
const requestedRank = draft.rank ?? existing?.rank ?? 100;
|
|
340
|
+
|
|
341
|
+
if (actor.canManageAll) {
|
|
342
|
+
return {
|
|
343
|
+
workspaceId:
|
|
344
|
+
draft.workspaceId !== undefined
|
|
345
|
+
? draft.workspaceId
|
|
346
|
+
: (existing?.workspaceId ?? null),
|
|
347
|
+
rank: requestedRank,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (actor.scope.adminIds.length === 0) {
|
|
352
|
+
throw new DelegationError(
|
|
353
|
+
"NO_DELEGATION",
|
|
354
|
+
"Bạn không được uỷ quyền quản trị workspace nào.",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (existing && !canManageRole(actor, existing)) {
|
|
359
|
+
throw new DelegationError(
|
|
360
|
+
existing.isSystem ? "D4_DANGEROUS_ROLE" : "D1_OUT_OF_SCOPE",
|
|
361
|
+
existing.isSystem
|
|
362
|
+
? `Vai trò "${existing.code}" là vai trò hệ thống — chỉ quản trị toàn hệ thống sửa được.`
|
|
363
|
+
: `Vai trò "${existing.code}" ${existing.workspaceId ? "thuộc đơn vị ngoài phạm vi của bạn" : "là vai trò dùng chung của cấp trên"} — bạn xem được nhưng không sửa được.`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (draft.isSystem) {
|
|
368
|
+
throw new DelegationError(
|
|
369
|
+
"D4_DANGEROUS_ROLE",
|
|
370
|
+
"Không thể đánh dấu vai trò là vai trò hệ thống.",
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Đóng dấu nhánh: vai trò không mang dấu là vai trò DÙNG CHUNG, tức là admin
|
|
375
|
+
// nhánh vừa tạo ra thứ mà mọi đơn vị khác cũng gán được.
|
|
376
|
+
const workspaceId =
|
|
377
|
+
draft.workspaceId ??
|
|
378
|
+
existing?.workspaceId ??
|
|
379
|
+
defaultRoleWorkspaceIdFor(actor);
|
|
380
|
+
if (!workspaceId || !isWorkspaceAdmin(actor.scope, workspaceId)) {
|
|
381
|
+
throw new DelegationError(
|
|
382
|
+
"D1_OUT_OF_SCOPE",
|
|
383
|
+
"Vai trò phải thuộc một đơn vị trong phạm vi quản trị của bạn.",
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// D2 lúc TẠO: vai trò yếu hơn chính người tạo. Không có luật này thì họ tạo
|
|
388
|
+
// vai trò rank 0 rồi nhờ người khác gán ngược lại cho mình.
|
|
389
|
+
if (requestedRank <= actor.rank) {
|
|
390
|
+
throw new DelegationError(
|
|
391
|
+
"D2_EXCEEDS_CEILING",
|
|
392
|
+
`Vai trò phải có cấp thấp hơn bạn (rank > ${actor.rank}).`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const dangerous = draft.permissions.filter(isDangerousPermission);
|
|
397
|
+
if (dangerous.length > 0) {
|
|
398
|
+
throw new DelegationError(
|
|
399
|
+
"D4_DANGEROUS_ROLE",
|
|
400
|
+
`Không thể đưa quyền quản trị hệ thống vào vai trò: ${dangerous.join(", ")}.`,
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const exceeded = draft.permissions.filter((p) => !actor.permissions.has(p));
|
|
405
|
+
if (exceeded.length > 0) {
|
|
406
|
+
throw new DelegationError(
|
|
407
|
+
"D2_EXCEEDS_CEILING",
|
|
408
|
+
`Vai trò chứa quyền bạn không có: ${exceeded.slice(0, 5).join(", ")}${exceeded.length > 5 ? "…" : ""}.`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return { workspaceId, rank: requestedRank };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Cổng cho đường XOÁ vai trò — cùng luật với sửa, không có phần bộ quyền. */
|
|
416
|
+
export function assertCanDeleteRole(
|
|
417
|
+
actor: DelegationActor,
|
|
418
|
+
role: DelegationRole,
|
|
419
|
+
): void {
|
|
420
|
+
if (actor.canManageAll) return;
|
|
421
|
+
if (!canManageRole(actor, role)) {
|
|
422
|
+
throw new DelegationError(
|
|
423
|
+
role.isSystem ? "D4_DANGEROUS_ROLE" : "D1_OUT_OF_SCOPE",
|
|
424
|
+
`Vai trò "${role.code}" nằm ngoài phạm vi quản trị của bạn — không xoá được.`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
281
429
|
/**
|
|
282
430
|
* D2 bản CHẠY — quyền hiệu lực = GIAO của quyền vai trò và trần quyền.
|
|
283
431
|
*
|
package/src/workspace/index.ts
CHANGED
|
@@ -97,6 +97,10 @@ export {
|
|
|
97
97
|
assertNotProtected,
|
|
98
98
|
assertRoleAssignable,
|
|
99
99
|
assignableRoles,
|
|
100
|
+
canManageRole,
|
|
101
|
+
defaultRoleWorkspaceIdFor,
|
|
102
|
+
assertCanManageRole,
|
|
103
|
+
assertCanDeleteRole,
|
|
100
104
|
applyPermissionCeiling,
|
|
101
105
|
assertBoundaryIntact,
|
|
102
106
|
assertCanUpdateUser,
|
|
@@ -107,6 +111,7 @@ export {
|
|
|
107
111
|
type DelegationTarget,
|
|
108
112
|
type DelegationRole,
|
|
109
113
|
type DelegationChanges,
|
|
114
|
+
type RoleDraft,
|
|
110
115
|
} from "./delegation";
|
|
111
116
|
|
|
112
117
|
export {
|
|
@@ -125,13 +130,23 @@ export {
|
|
|
125
130
|
} from "./components/workspace-tree-view";
|
|
126
131
|
|
|
127
132
|
export {
|
|
128
|
-
|
|
133
|
+
WorkspaceTreePanel,
|
|
129
134
|
filterWorkspaceTree,
|
|
130
135
|
rollupCounts,
|
|
131
136
|
type WorkspaceOrgAnnotation,
|
|
132
|
-
type
|
|
137
|
+
type WorkspaceTreeActions,
|
|
138
|
+
type WorkspaceTreePanelProps,
|
|
133
139
|
type WorkspaceRollup,
|
|
134
|
-
} from "./components/workspace-
|
|
140
|
+
} from "./components/workspace-tree-panel";
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
WorkspaceMemberPickerDialog,
|
|
144
|
+
WorkspaceIdentityLine,
|
|
145
|
+
type CandidateFilter,
|
|
146
|
+
type WorkspaceCandidate,
|
|
147
|
+
type WorkspaceUserIdentity,
|
|
148
|
+
type WorkspaceMemberPickerDialogProps,
|
|
149
|
+
} from "./components/workspace-member-picker-dialog";
|
|
135
150
|
|
|
136
151
|
export {
|
|
137
152
|
WorkspaceMembersPanel,
|