@goplusvn/core 0.1.76 → 0.1.78

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.
Files changed (30) hide show
  1. package/package.json +2 -1
  2. package/src/guardrails/__tests__/guardrails.test.ts +82 -0
  3. package/src/guardrails/rules/rbac.ts +120 -0
  4. package/src/navigation/index.ts +49 -0
  5. package/src/rbac/__tests__/landing-path.test.ts +148 -0
  6. package/src/rbac/__tests__/route-handlers.test.ts +147 -0
  7. package/src/rbac/landing-path.ts +140 -0
  8. package/src/rbac/pages/role-form-page.tsx +99 -0
  9. package/src/rbac/route-handlers.ts +22 -1
  10. package/src/schemas/role.schema.ts +6 -0
  11. package/src/ui/auth/sign-in-form.tsx +36 -4
  12. package/src/user/components/unified-profile-dialog.tsx +160 -0
  13. package/src/user/pages/users-client-page.tsx +12 -0
  14. package/src/workspace/__tests__/workspace-delegation.test.ts +1 -1
  15. package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
  16. package/src/workspace/__tests__/workspace-route-handlers.test.ts +35 -0
  17. package/src/workspace/__tests__/workspace-service.test.ts +1 -1
  18. package/src/workspace/components/scope-level-select.tsx +4 -4
  19. package/src/workspace/components/workspace-members-panel.tsx +454 -0
  20. package/src/workspace/components/workspace-org-block.tsx +293 -0
  21. package/src/workspace/components/workspace-switcher.tsx +2 -2
  22. package/src/workspace/components/workspace-tree-view.tsx +66 -25
  23. package/src/workspace/delegation.ts +7 -7
  24. package/src/workspace/index.ts +16 -0
  25. package/src/workspace/pages/workspace-list-page.tsx +425 -53
  26. package/src/workspace/route-handlers.ts +278 -2
  27. package/src/workspace/service.ts +4 -4
  28. package/src/workspace/tree.ts +1 -1
  29. package/src/workspace/types.ts +1 -1
  30. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +14 -2
@@ -0,0 +1,293 @@
1
+ "use client";
2
+
3
+ // Khung TRÁI của trang workspace: mỗi tổ chức là một khối, mỗi bộ phận là một
4
+ // dòng chọn được. Chọn dòng nào thì khung PHẢI mở hồ sơ của dòng đó.
5
+ //
6
+ // Vì sao không phải cây thư mục: cây chỉ diễn tả được quan hệ "cha–con". Ở đây
7
+ // hai nút gốc không cùng loại — một bên là đơn vị VẬN HÀNH (thấy mọi tổ chức),
8
+ // bên kia là KHÁCH HÀNG (bị kẹp trong nhánh của mình). Vẽ cả hai bằng đúng một
9
+ // kiểu dòng thụt lề thì người xem không đọc ra sự khác nhau đó, mà đây lại là
10
+ // điều quan trọng nhất trên màn hình này.
11
+ //
12
+ // Vì sao khối này chỉ ĐIỀU HƯỚNG, không mang nút thao tác: cột trái hẹp, nhồi
13
+ // thêm mã, cấp, nút "+", menu ba chấm vào từng dòng thì tên bộ phận — thứ duy
14
+ // nhất người ta quét mắt tìm — bị ép còn vài chữ. Mã, cấp, nút bấm nằm ở khung
15
+ // phải, nơi đang nói về đúng một nút và có đủ chỗ.
16
+
17
+ import * as React from "react";
18
+
19
+ import { Building2, Shield, Users } from "lucide-react";
20
+
21
+ import { cn } from "../../utils";
22
+ import type { WorkspaceTreeNode } from "./workspace-tree-view";
23
+
24
+ /**
25
+ * Danh tính của một tổ chức gốc — thứ core KHÔNG tự suy ra được.
26
+ *
27
+ * "Đơn vị vận hành" với "khách hàng" trong dữ liệu là hai dòng `workspace` y hệt
28
+ * nhau; cái phân biệt chúng là quyền `view-all-workspaces` nằm ở tầng vai trò
29
+ * của từng app. Nên app phải tự nói ra, core chỉ vẽ.
30
+ */
31
+ export interface WorkspaceOrgAnnotation {
32
+ /** Huy hiệu cạnh tên, ví dụ "Vận hành" / "Khách hàng". */
33
+ badge?: string;
34
+ /** Một câu giải thích phạm vi, hiện dưới dòng số liệu. */
35
+ hint?: string;
36
+ /** `brand` = tổ chức của chính mình; `neutral` = tổ chức bên ngoài. */
37
+ tone?: "brand" | "neutral";
38
+ }
39
+
40
+ export interface WorkspaceOrgBlockProps {
41
+ root: WorkspaceTreeNode;
42
+ annotation?: WorkspaceOrgAnnotation;
43
+ /** Nút đang mở ở khung phải. */
44
+ activeId?: string | null;
45
+ onSelect?: (node: WorkspaceTreeNode) => void;
46
+ /**
47
+ * Nhãn cấp dưới của một nút, hoặc `null` nếu nút đó hết cấp — chỉ dùng để
48
+ * viết đúng chữ ("3 bộ phận" chứ không phải "3 cấp dưới"). Luật cấp nằm ở
49
+ * `kinds` của app nên trang cha quyết, khối chỉ hỏi.
50
+ */
51
+ childKindLabel?: (node: WorkspaceTreeNode) => string | null;
52
+ }
53
+
54
+ export interface WorkspaceRollup {
55
+ /** Tổng thành viên của cả nhánh, kể cả nút gốc. */
56
+ members: number;
57
+ admins: number;
58
+ /** Số nút con cháu, KHÔNG tính chính nó. */
59
+ units: number;
60
+ }
61
+
62
+ /**
63
+ * Cộng dồn cả nhánh.
64
+ *
65
+ * Con số ở tiêu đề khối phải là TỔNG, không phải số gán trực tiếp vào nút gốc:
66
+ * "Spartronics 2 người" trong khi ba bộ phận bên dưới có thêm 4 người nữa là
67
+ * câu trả lời sai cho câu hỏi người ta thực sự hỏi ("công ty này bao nhiêu
68
+ * người dùng?"). Số trực tiếp vẫn còn, nằm ở từng dòng.
69
+ */
70
+ export function rollupCounts(node: WorkspaceTreeNode): WorkspaceRollup {
71
+ return node.children.reduce<WorkspaceRollup>(
72
+ (acc, child) => {
73
+ const sub = rollupCounts(child);
74
+ return {
75
+ members: acc.members + sub.members,
76
+ admins: acc.admins + sub.admins,
77
+ units: acc.units + sub.units + 1,
78
+ };
79
+ },
80
+ {
81
+ members: node.memberCount ?? 0,
82
+ admins: node.adminCount ?? 0,
83
+ units: 0,
84
+ },
85
+ );
86
+ }
87
+
88
+ /** 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. */
89
+ export function filterWorkspaceTree(
90
+ nodes: WorkspaceTreeNode[],
91
+ term: string,
92
+ ): WorkspaceTreeNode[] {
93
+ const needle = term.trim().toLowerCase();
94
+ if (!needle) return nodes;
95
+ const walk = (node: WorkspaceTreeNode): WorkspaceTreeNode | null => {
96
+ const children = node.children
97
+ .map(walk)
98
+ .filter((n): n is WorkspaceTreeNode => n !== null);
99
+ const hit =
100
+ node.name.toLowerCase().includes(needle) ||
101
+ node.code.toLowerCase().includes(needle);
102
+ if (!hit && children.length === 0) return null;
103
+ return { ...node, children };
104
+ };
105
+ return nodes.map(walk).filter((n): n is WorkspaceTreeNode => n !== null);
106
+ }
107
+
108
+ /**
109
+ * Ô "2 người" ở cuối mỗi dòng.
110
+ *
111
+ * Có icon người đi kèm chứ không chỉ số trần: đây là dòng nhiều cột mà không có
112
+ * tiêu đề cột, con số một mình thì không tự nói ra nó đang đếm cái gì.
113
+ */
114
+ function MemberStat({
115
+ members,
116
+ admins,
117
+ active,
118
+ }: {
119
+ members: number;
120
+ admins: number;
121
+ active?: boolean;
122
+ }) {
123
+ return (
124
+ <span
125
+ className={cn(
126
+ "flex shrink-0 items-center gap-1.5 text-xs",
127
+ active ? "text-foreground" : "text-muted-foreground",
128
+ )}
129
+ >
130
+ <Users className="h-3.5 w-3.5" />
131
+ <span className="tabular-nums">{members}</span>
132
+ {admins > 0 ? (
133
+ <Shield
134
+ className="h-3.5 w-3.5 text-primary"
135
+ aria-label={`${admins} quản trị`}
136
+ />
137
+ ) : null}
138
+ </span>
139
+ );
140
+ }
141
+
142
+ export function WorkspaceOrgBlock({
143
+ root,
144
+ annotation,
145
+ activeId,
146
+ onSelect,
147
+ childKindLabel,
148
+ }: WorkspaceOrgBlockProps) {
149
+ const total = React.useMemo(() => rollupCounts(root), [root]);
150
+ const brand = annotation?.tone === "brand";
151
+ const rootChildLabel = childKindLabel?.(root) ?? null;
152
+ const rootActive = activeId === root.id;
153
+
154
+ const renderRow = (
155
+ node: WorkspaceTreeNode,
156
+ depth: number,
157
+ ): React.ReactNode => {
158
+ const active = activeId === node.id;
159
+
160
+ return (
161
+ <React.Fragment key={node.id}>
162
+ <button
163
+ type="button"
164
+ onClick={() => onSelect?.(node)}
165
+ aria-current={active ? "true" : undefined}
166
+ className={cn(
167
+ "flex w-full items-center gap-2 border-t border-border/60 py-2 pr-3 text-left text-sm transition-colors",
168
+ // Nền đậm hơn + chữ đậm, KHÔNG tô chữ bằng `text-primary`: ở chế độ
169
+ // tối màu primary ngả xanh lợt, chữ chìm hẳn. `/20` chứ không `/10`:
170
+ // trên nền tối, 10% primary gần như không phân biệt được với dòng
171
+ // thường.
172
+ active ? "bg-primary/20 font-medium" : "hover:bg-muted/50",
173
+ node.isActive === false && "opacity-55",
174
+ )}
175
+ style={{ paddingLeft: `${0.75 + depth * 1.25}rem` }}
176
+ >
177
+ <span className="min-w-0 flex-1 truncate">
178
+ {node.name}
179
+ {node.isActive === false ? (
180
+ <span className="ml-1.5 text-xs font-normal text-muted-foreground">
181
+ (ngừng)
182
+ </span>
183
+ ) : null}
184
+ </span>
185
+ <MemberStat
186
+ members={node.memberCount ?? 0}
187
+ admins={node.adminCount ?? 0}
188
+ active={active}
189
+ />
190
+ </button>
191
+
192
+ {node.children.map((child) => renderRow(child, depth + 1))}
193
+ </React.Fragment>
194
+ );
195
+ };
196
+
197
+ return (
198
+ <section
199
+ className={cn(
200
+ "overflow-hidden rounded-xl border bg-card",
201
+ brand ? "border-primary/25" : "border-border",
202
+ root.isActive === false && "opacity-60",
203
+ )}
204
+ >
205
+ {/* Cả đầu khối là một nút: tổ chức gốc cũng gán thành viên trực tiếp
206
+ được, nên nó phải chọn được hệt như bộ phận bên dưới. */}
207
+ <button
208
+ type="button"
209
+ onClick={() => onSelect?.(root)}
210
+ aria-current={rootActive ? "true" : undefined}
211
+ className={cn(
212
+ "flex w-full items-start gap-3 px-3 py-3 text-left transition-colors",
213
+ rootActive
214
+ ? "bg-primary/20"
215
+ : brand
216
+ ? "bg-primary/10 hover:bg-primary/20"
217
+ : "bg-muted/30 hover:bg-muted/60",
218
+ )}
219
+ >
220
+ <span
221
+ aria-hidden
222
+ className={cn(
223
+ "mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
224
+ brand
225
+ ? "bg-primary/15 text-primary"
226
+ : "bg-background text-muted-foreground ring-1 ring-border",
227
+ )}
228
+ >
229
+ <Building2 className="h-4 w-4" />
230
+ </span>
231
+
232
+ <span className="min-w-0 flex-1">
233
+ <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
234
+ <span className="truncate text-[15px] font-semibold">
235
+ {root.name}
236
+ </span>
237
+ {annotation?.badge ? (
238
+ <span
239
+ className={cn(
240
+ "inline-flex shrink-0 items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
241
+ // Chữ `foreground` chứ không `primary`: nhãn này phải đọc
242
+ // được ở cả hai chế độ, sắc thái brand để nền và viền lo.
243
+ brand
244
+ ? "border-primary/40 bg-primary/20 text-foreground"
245
+ : "border-border bg-background text-muted-foreground",
246
+ )}
247
+ >
248
+ {annotation.badge}
249
+ </span>
250
+ ) : null}
251
+ {root.isActive === false ? (
252
+ <span className="text-xs text-muted-foreground">
253
+ (đã ngừng hoạt động)
254
+ </span>
255
+ ) : null}
256
+ </span>
257
+
258
+ {/* Số ở đây là TỔNG cả nhánh, số trên từng dòng là số trực tiếp. Nói
259
+ rõ "cả nhánh" khi có cấp dưới, không thì hai con số khác nhau trên
260
+ cùng màn hình mà không ai biết vì sao. */}
261
+ <span className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
262
+ <span className="tabular-nums">
263
+ {total.members} người{total.units > 0 ? " cả nhánh" : ""}
264
+ </span>
265
+ <span aria-hidden>·</span>
266
+ <span className="tabular-nums">{total.admins} quản trị</span>
267
+ <span aria-hidden>·</span>
268
+ <span className="tabular-nums">
269
+ {total.units} {(rootChildLabel ?? "cấp dưới").toLowerCase()}
270
+ </span>
271
+ </span>
272
+
273
+ {annotation?.hint ? (
274
+ <span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
275
+ {annotation.hint}
276
+ </span>
277
+ ) : null}
278
+ </span>
279
+ </button>
280
+
281
+ {root.children.length > 0 ? (
282
+ <div>{root.children.map((child) => renderRow(child, 0))}</div>
283
+ ) : (
284
+ // Rỗng phải nói ra HỆ QUẢ, không chỉ báo trống: chưa có cấp dưới nghĩa
285
+ // là mọi người của tổ chức này dùng chung một phạm vi dữ liệu.
286
+ <p className="border-t border-border/60 px-3 py-2.5 text-xs text-muted-foreground">
287
+ Chưa chia {(rootChildLabel ?? "cấp dưới").toLowerCase()} — cả{" "}
288
+ {total.members} người dùng chung một phạm vi dữ liệu.
289
+ </p>
290
+ )}
291
+ </section>
292
+ );
293
+ }
@@ -33,7 +33,7 @@ export interface WorkspaceOption {
33
33
 
34
34
  export interface WorkspaceSwitcherProps {
35
35
  options: WorkspaceOption[];
36
- /** `null` = "Tất cả không gian" (chỉ hiện khi `allowAll`). */
36
+ /** `null` = "Tất cả workspace" (chỉ hiện khi `allowAll`). */
37
37
  value: string | null;
38
38
  onChange: (workspaceId: string | null) => void;
39
39
  /** Cho chọn "Tất cả" — chỉ bật cho người có quyền xem toàn bộ. */
@@ -47,7 +47,7 @@ export function WorkspaceSwitcher({
47
47
  value,
48
48
  onChange,
49
49
  allowAll = false,
50
- allLabel = "Tất cả không gian",
50
+ allLabel = "Tất cả workspace",
51
51
  className,
52
52
  }: WorkspaceSwitcherProps) {
53
53
  const [open, setOpen] = React.useState(false);
@@ -38,6 +38,15 @@ export interface WorkspaceTreeNode {
38
38
 
39
39
  export interface WorkspaceTreeViewProps {
40
40
  nodes: WorkspaceTreeNode[];
41
+ /**
42
+ * `nav` — cột điều hướng hẹp: chỉ tên + số thành viên, mọi thao tác nằm ở
43
+ * vùng chi tiết bên phải. `board` — bảng đầy đủ (mã, cấp, menu từng dòng).
44
+ *
45
+ * Cây tổ chức thường chỉ vài chục dòng còn danh sách người có thể hàng trăm;
46
+ * nhồi mã + nhãn cấp + hai con số vào cột hẹp thì cột nào cũng cụt và không có
47
+ * tiêu đề để hiểu con số đang đọc là gì.
48
+ */
49
+ variant?: "nav" | "board";
41
50
  /** Nhánh người dùng được uỷ quyền quản trị (đã bung con cháu). */
42
51
  adminIds?: string[];
43
52
  /** Xem-tất: mọi nút đều thao tác được. */
@@ -83,6 +92,7 @@ function collectIds(nodes: WorkspaceTreeNode[], out: Set<string>): Set<string> {
83
92
 
84
93
  export function WorkspaceTreeView({
85
94
  nodes,
95
+ variant = "board",
86
96
  adminIds,
87
97
  canManageAll = false,
88
98
  selectedId,
@@ -92,7 +102,7 @@ export function WorkspaceTreeView({
92
102
  onMove,
93
103
  onDeactivate,
94
104
  search = "",
95
- emptyMessage = "Chưa có không gian nào.",
105
+ emptyMessage = "Chưa có workspace nào.",
96
106
  }: WorkspaceTreeViewProps) {
97
107
  const visible = React.useMemo(
98
108
  () => filterTree(nodes, search),
@@ -120,25 +130,42 @@ export function WorkspaceTreeView({
120
130
  if (visible.length === 0) {
121
131
  return (
122
132
  <p className="px-4 py-10 text-center text-sm text-muted-foreground">
123
- {searching ? "Không có không gian nào khớp." : emptyMessage}
133
+ {searching ? "Không có workspace nào khớp." : emptyMessage}
124
134
  </p>
125
135
  );
126
136
  }
127
137
 
138
+ const nav = variant === "nav";
139
+
128
140
  const render = (node: WorkspaceTreeNode, depth: number): React.ReactNode => {
129
141
  const hasChildren = node.children.length > 0;
130
142
  const isOpen = searching || !collapsed.has(node.id);
131
143
  const manageable = canManage(node.id);
144
+ const isSelected = selectedId === node.id;
132
145
 
133
146
  return (
134
147
  <React.Fragment key={node.id}>
135
148
  <div
136
149
  className={cn(
137
- "group flex items-center gap-2 border-b border-border/60 px-2 py-1.5 text-sm transition-colors last:border-b-0",
138
- selectedId === node.id ? "bg-muted" : "hover:bg-muted/50",
150
+ "group flex items-center gap-2 text-sm transition-colors",
151
+ nav
152
+ ? // Cột điều hướng: dòng bo góc rời nhau, không kẻ ngang — kẻ ngang
153
+ // làm nó trông như bảng dữ liệu và người dùng chờ có tiêu đề cột.
154
+ "mx-1 rounded-md px-2 py-1.5"
155
+ : "border-b border-border/60 px-2 py-1.5 last:border-b-0",
156
+ isSelected
157
+ ? // Đang chọn phải NHÌN RA ngay: nền đậm + chữ đậm. `bg-muted` một
158
+ // mình quá nhạt, người dùng không biết panel bên phải đang nói về
159
+ // dòng nào.
160
+ "bg-primary/10 font-medium text-foreground"
161
+ : "hover:bg-muted/60",
139
162
  node.isActive === false && "opacity-55",
140
163
  )}
141
- style={{ paddingLeft: `${0.5 + depth * 1.25}rem` }}
164
+ style={{
165
+ paddingLeft: nav
166
+ ? `${0.5 + depth * 0.875}rem`
167
+ : `${0.5 + depth * 1.25}rem`,
168
+ }}
142
169
  >
143
170
  {hasChildren ? (
144
171
  <button
@@ -171,31 +198,45 @@ export function WorkspaceTreeView({
171
198
  ) : null}
172
199
  </button>
173
200
 
174
- <span className="hidden shrink-0 text-xs tabular-nums text-muted-foreground sm:inline">
175
- {node.code}
176
- </span>
177
-
178
- {node.kindLabel ? (
179
- <Badge variant="outline" className="hidden shrink-0 md:inline-flex">
180
- {node.kindLabel}
181
- </Badge>
201
+ {!nav ? (
202
+ <>
203
+ <span className="hidden shrink-0 text-xs tabular-nums text-muted-foreground sm:inline">
204
+ {node.code}
205
+ </span>
206
+ {node.kindLabel ? (
207
+ <Badge
208
+ variant="outline"
209
+ className="hidden shrink-0 md:inline-flex"
210
+ >
211
+ {node.kindLabel}
212
+ </Badge>
213
+ ) : null}
214
+ </>
182
215
  ) : null}
183
216
 
184
217
  {node.memberCount !== undefined ? (
185
- <span
186
- className="w-16 shrink-0 text-right text-xs tabular-nums text-muted-foreground"
187
- title={
188
- node.adminCount !== undefined
189
- ? `${node.memberCount} thành viên, ${node.adminCount} quản trị`
190
- : `${node.memberCount} thành viên`
191
- }
192
- >
193
- {node.memberCount}
194
- {node.adminCount ? ` · ${node.adminCount}` : ""}
218
+ // Con số trần giữa dòng không nói được nó là gì. Ở cột hẹp thì kèm
219
+ // luôn đơn vị; số quản trị đã có ở vùng chi tiết, không nhồi tiếp.
220
+ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
221
+ {nav ? (
222
+ <>{node.memberCount} người</>
223
+ ) : (
224
+ <span
225
+ className="inline-block w-16 text-right"
226
+ title={
227
+ node.adminCount !== undefined
228
+ ? `${node.memberCount} thành viên, ${node.adminCount} quản trị`
229
+ : `${node.memberCount} thành viên`
230
+ }
231
+ >
232
+ {node.memberCount}
233
+ {node.adminCount ? ` · ${node.adminCount}` : ""}
234
+ </span>
235
+ )}
195
236
  </span>
196
237
  ) : null}
197
238
 
198
- {manageable ? (
239
+ {manageable && !nav ? (
199
240
  <div className="flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
200
241
  {onAddChild ? (
201
242
  <Button
@@ -203,7 +244,7 @@ export function WorkspaceTreeView({
203
244
  size="icon"
204
245
  className="h-7 w-7"
205
246
  onClick={() => onAddChild(node)}
206
- title="Thêm không gian con"
247
+ title="Thêm workspace con"
207
248
  >
208
249
  <Plus className="h-3.5 w-3.5" />
209
250
  </Button>
@@ -124,7 +124,7 @@ export function assertWorkspacesInScope(
124
124
  if (actor.scope.adminIds.length === 0) {
125
125
  throw new DelegationError(
126
126
  "NO_DELEGATION",
127
- "Bạn không được uỷ quyền quản trị không gian nào.",
127
+ "Bạn không được uỷ quyền quản trị workspace nào.",
128
128
  );
129
129
  }
130
130
  const outside = requestedWorkspaceIds.filter(
@@ -152,7 +152,7 @@ export function assertNotSelf(
152
152
  if (actor.userId === targetUserId) {
153
153
  throw new DelegationError(
154
154
  "D3_SELF_ESCALATION",
155
- "Không thể tự sửa vai trò hoặc không gian của chính mình.",
155
+ "Không thể tự sửa vai trò hoặc workspace của chính mình.",
156
156
  );
157
157
  }
158
158
  }
@@ -176,13 +176,13 @@ export function assertFullyOwned(
176
176
  if (outside.length > 0) {
177
177
  throw new DelegationError(
178
178
  "D5_NOT_FULLY_OWNED",
179
- "Người dùng này còn thuộc không gian ngoài phạm vi của bạn — bạn xem được nhưng không sửa được.",
179
+ "Người dùng này còn thuộc workspace ngoài phạm vi của bạn — bạn xem được nhưng không sửa được.",
180
180
  );
181
181
  }
182
182
  if (target.workspaceIds.length === 0) {
183
183
  throw new DelegationError(
184
184
  "D5_NOT_FULLY_OWNED",
185
- "Người dùng này chưa thuộc không gian nào nên không nằm trong phạm vi quản trị của bạn.",
185
+ "Người dùng này chưa thuộc workspace nào nên không nằm trong phạm vi quản trị của bạn.",
186
186
  );
187
187
  }
188
188
  }
@@ -258,7 +258,7 @@ export function assertRoleAssignable(
258
258
  if (role.workspaceId && !isWorkspaceAdmin(actor.scope, role.workspaceId)) {
259
259
  throw new DelegationError(
260
260
  "D1_OUT_OF_SCOPE",
261
- `Vai trò "${role.code}" thuộc không gian ngoài phạm vi của bạn.`,
261
+ `Vai trò "${role.code}" thuộc workspace ngoài phạm vi của bạn.`,
262
262
  );
263
263
  }
264
264
  }
@@ -354,7 +354,7 @@ export function assertBoundaryIntact(
354
354
  if (removed.length > 0) {
355
355
  throw new DelegationError(
356
356
  "D7_BOUNDARY_TAMPER",
357
- "Không thể gỡ người dùng khỏi không gian nằm ngoài phạm vi quản trị của bạn.",
357
+ "Không thể gỡ người dùng khỏi workspace nằm ngoài phạm vi quản trị của bạn.",
358
358
  );
359
359
  }
360
360
  }
@@ -388,7 +388,7 @@ export function assertCanCreateUser(
388
388
  if (!actor.canManageAll && workspaceIds.length === 0) {
389
389
  throw new DelegationError(
390
390
  "D1_OUT_OF_SCOPE",
391
- "Phải chọn ít nhất một không gian trong phạm vi quản trị của bạn.",
391
+ "Phải chọn ít nhất một workspace trong phạm vi quản trị của bạn.",
392
392
  );
393
393
  }
394
394
  for (const role of roles) assertRoleAssignable(actor, role);
@@ -112,6 +112,7 @@ export {
112
112
  export {
113
113
  createWorkspaceCollectionHandlers,
114
114
  createWorkspaceItemHandlers,
115
+ createWorkspaceMemberHandlers,
115
116
  createWorkspaceMoveHandler,
116
117
  type WorkspaceHandlerDeps,
117
118
  } from "./route-handlers";
@@ -123,6 +124,21 @@ export {
123
124
  type WorkspaceTreeViewProps,
124
125
  } from "./components/workspace-tree-view";
125
126
 
127
+ export {
128
+ WorkspaceOrgBlock,
129
+ filterWorkspaceTree,
130
+ rollupCounts,
131
+ type WorkspaceOrgAnnotation,
132
+ type WorkspaceOrgBlockProps,
133
+ type WorkspaceRollup,
134
+ } from "./components/workspace-org-block";
135
+
136
+ export {
137
+ WorkspaceMembersPanel,
138
+ type WorkspaceMember,
139
+ type WorkspaceMembersPanelProps,
140
+ } from "./components/workspace-members-panel";
141
+
126
142
  export {
127
143
  WorkspaceSwitcher,
128
144
  type WorkspaceOption,