@goplusvn/core 0.1.79 → 0.1.82
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 +69 -0
- package/package.json +1 -1
- package/src/rbac/__tests__/route-handlers.test.ts +69 -0
- package/src/rbac/components/roles/role-card.tsx +24 -0
- package/src/rbac/pages/role-form-page.tsx +86 -3
- package/src/rbac/role-service.ts +27 -0
- package/src/rbac/route-handlers.ts +13 -1
- package/src/rbac/types.ts +6 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +73 -6
- 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 +6 -8
- package/src/workspace/index.ts +13 -3
- package/src/workspace/pages/workspace-list-page.tsx +106 -46
- package/src/workspace/route-handlers.ts +218 -4
- package/src/workspace/service.ts +22 -0
- package/src/workspace/components/workspace-org-block.tsx +0 -293
|
@@ -11,12 +11,10 @@ import * as React from "react";
|
|
|
11
11
|
import {
|
|
12
12
|
Loader2,
|
|
13
13
|
MoreHorizontal,
|
|
14
|
-
Search,
|
|
15
14
|
Shield,
|
|
16
15
|
Trash2,
|
|
17
16
|
UserPlus,
|
|
18
17
|
Users,
|
|
19
|
-
X,
|
|
20
18
|
} from "lucide-react";
|
|
21
19
|
import { toast } from "sonner";
|
|
22
20
|
|
|
@@ -28,10 +26,19 @@ import {
|
|
|
28
26
|
DropdownMenuItem,
|
|
29
27
|
DropdownMenuSeparator,
|
|
30
28
|
DropdownMenuTrigger,
|
|
31
|
-
Input,
|
|
32
29
|
Skeleton,
|
|
33
30
|
} from "../../ui";
|
|
34
31
|
import { cn } from "../../utils";
|
|
32
|
+
import {
|
|
33
|
+
WorkspaceIdentityLine,
|
|
34
|
+
WorkspaceMemberPickerDialog,
|
|
35
|
+
} from "./workspace-member-picker-dialog";
|
|
36
|
+
import type {
|
|
37
|
+
CandidateFilter,
|
|
38
|
+
WorkspaceUserIdentity,
|
|
39
|
+
} from "./workspace-member-picker-dialog";
|
|
40
|
+
|
|
41
|
+
export type { CandidateFilter };
|
|
35
42
|
|
|
36
43
|
/** Chữ cái đầu cho ô avatar — hai ký tự là đủ nhận diện, ba trở lên thành rối. */
|
|
37
44
|
function initials(name?: string | null, email?: string | null): string {
|
|
@@ -45,7 +52,7 @@ function initials(name?: string | null, email?: string | null): string {
|
|
|
45
52
|
return source.slice(0, 2).toUpperCase();
|
|
46
53
|
}
|
|
47
54
|
|
|
48
|
-
export interface WorkspaceMember {
|
|
55
|
+
export interface WorkspaceMember extends WorkspaceUserIdentity {
|
|
49
56
|
userId: string;
|
|
50
57
|
name: string | null;
|
|
51
58
|
email: string | null;
|
|
@@ -54,13 +61,6 @@ export interface WorkspaceMember {
|
|
|
54
61
|
isDefault?: boolean;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
|
-
interface Candidate {
|
|
58
|
-
id: string;
|
|
59
|
-
name: string | null;
|
|
60
|
-
email: string | null;
|
|
61
|
-
isActive?: boolean;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
64
|
export interface WorkspaceMembersPanelProps {
|
|
65
65
|
/** Nút đang chọn trên cây; `null` = chưa chọn gì. */
|
|
66
66
|
workspaceId: string | null;
|
|
@@ -78,6 +78,10 @@ export interface WorkspaceMembersPanelProps {
|
|
|
78
78
|
* thuẫn trên cùng một màn hình và không biết tin cái nào.
|
|
79
79
|
*/
|
|
80
80
|
onCountChange?: (workspaceId: string, count: number) => void;
|
|
81
|
+
/** Trục lọc của app cho hộp thoại thêm thành viên (chi nhánh, vai trò…). */
|
|
82
|
+
bulkFilters?: CandidateFilter[];
|
|
83
|
+
/** Cây đã làm phẳng, cho trục lọc "đang ở bộ phận nào" của hộp thoại. */
|
|
84
|
+
workspaceOptions?: { id: string; name: string; depth: number }[];
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
export function WorkspaceMembersPanel({
|
|
@@ -87,14 +91,13 @@ export function WorkspaceMembersPanel({
|
|
|
87
91
|
canManage = false,
|
|
88
92
|
apiEndpoint = "/api/workspaces",
|
|
89
93
|
onCountChange,
|
|
94
|
+
bulkFilters,
|
|
95
|
+
workspaceOptions,
|
|
90
96
|
}: WorkspaceMembersPanelProps) {
|
|
91
97
|
const [members, setMembers] = React.useState<WorkspaceMember[]>([]);
|
|
92
98
|
const [loading, setLoading] = React.useState(false);
|
|
93
99
|
const [busyUserId, setBusyUserId] = React.useState<string | null>(null);
|
|
94
|
-
const [
|
|
95
|
-
const [query, setQuery] = React.useState("");
|
|
96
|
-
const [candidates, setCandidates] = React.useState<Candidate[]>([]);
|
|
97
|
-
const [searching, setSearching] = React.useState(false);
|
|
100
|
+
const [picking, setPicking] = React.useState(false);
|
|
98
101
|
|
|
99
102
|
const base = workspaceId ? `${apiEndpoint}/${workspaceId}/members` : null;
|
|
100
103
|
|
|
@@ -129,43 +132,11 @@ export function WorkspaceMembersPanel({
|
|
|
129
132
|
|
|
130
133
|
React.useEffect(() => {
|
|
131
134
|
void reload();
|
|
132
|
-
// Đổi nút thì đóng luôn
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
setCandidates([]);
|
|
135
|
+
// Đổi nút thì đóng luôn hộp thêm — để mở là người dùng tưởng đang thêm vào
|
|
136
|
+
// nút cũ, mà hộp đã gắn với nút mới rồi.
|
|
137
|
+
setPicking(false);
|
|
136
138
|
}, [reload]);
|
|
137
139
|
|
|
138
|
-
// Tìm ứng viên: gõ tới đâu hỏi tới đó, có hoãn để không bắn mỗi phím một request.
|
|
139
|
-
React.useEffect(() => {
|
|
140
|
-
if (!adding || !base) return;
|
|
141
|
-
let cancelled = false;
|
|
142
|
-
setSearching(true);
|
|
143
|
-
const timer = setTimeout(async () => {
|
|
144
|
-
try {
|
|
145
|
-
const url = `${base}?candidates=1&q=${encodeURIComponent(query)}`;
|
|
146
|
-
const res = await fetch(url, { cache: "no-store" });
|
|
147
|
-
if (!res.ok) throw new Error((await res.json())?.error ?? "Lỗi tìm");
|
|
148
|
-
const data = await res.json();
|
|
149
|
-
if (!cancelled) setCandidates(data);
|
|
150
|
-
} catch (error) {
|
|
151
|
-
if (!cancelled) {
|
|
152
|
-
setCandidates([]);
|
|
153
|
-
toast.error(
|
|
154
|
-
error instanceof Error
|
|
155
|
-
? error.message
|
|
156
|
-
: "Không tìm được người dùng.",
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
} finally {
|
|
160
|
-
if (!cancelled) setSearching(false);
|
|
161
|
-
}
|
|
162
|
-
}, 250);
|
|
163
|
-
return () => {
|
|
164
|
-
cancelled = true;
|
|
165
|
-
clearTimeout(timer);
|
|
166
|
-
};
|
|
167
|
-
}, [adding, query, base]);
|
|
168
|
-
|
|
169
140
|
async function mutate(
|
|
170
141
|
init: RequestInit & { url: string },
|
|
171
142
|
userId: string,
|
|
@@ -189,18 +160,6 @@ export function WorkspaceMembersPanel({
|
|
|
189
160
|
}
|
|
190
161
|
}
|
|
191
162
|
|
|
192
|
-
const add = (userId: string) =>
|
|
193
|
-
mutate(
|
|
194
|
-
{
|
|
195
|
-
url: base!,
|
|
196
|
-
method: "POST",
|
|
197
|
-
headers: { "content-type": "application/json" },
|
|
198
|
-
body: JSON.stringify({ userId }),
|
|
199
|
-
},
|
|
200
|
-
userId,
|
|
201
|
-
"Đã thêm vào " + (kindLabel?.toLowerCase() ?? "workspace"),
|
|
202
|
-
);
|
|
203
|
-
|
|
204
163
|
const toggleAdmin = (member: WorkspaceMember) =>
|
|
205
164
|
mutate(
|
|
206
165
|
{
|
|
@@ -250,81 +209,15 @@ export function WorkspaceMembersPanel({
|
|
|
250
209
|
{canManage ? (
|
|
251
210
|
<Button
|
|
252
211
|
size="sm"
|
|
253
|
-
variant={adding ? "secondary" : "default"}
|
|
254
212
|
className="ml-auto"
|
|
255
|
-
onClick={() =>
|
|
213
|
+
onClick={() => setPicking(true)}
|
|
256
214
|
>
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
) : (
|
|
260
|
-
<UserPlus className="h-3.5 w-3.5" />
|
|
261
|
-
)}
|
|
262
|
-
<span className="ml-1.5">
|
|
263
|
-
{adding ? "Đóng" : "Thêm thành viên"}
|
|
264
|
-
</span>
|
|
215
|
+
<UserPlus className="h-3.5 w-3.5" />
|
|
216
|
+
<span className="ml-1.5">Thêm thành viên</span>
|
|
265
217
|
</Button>
|
|
266
218
|
) : null}
|
|
267
219
|
</div>
|
|
268
220
|
|
|
269
|
-
{/* Nền + vạch dưới của ô thêm là BẮT BUỘC: ngay bên dưới là danh sách
|
|
270
|
-
thành viên hiện có, cũng gồm tên + email. Bỏ ranh giới thì người vừa
|
|
271
|
-
được thêm hiện hai lần sát nhau (một dòng ứng viên, một dòng thành
|
|
272
|
-
viên) mà không có gì nói đó là hai danh sách khác nhau. */}
|
|
273
|
-
{adding ? (
|
|
274
|
-
<div className="border-b border-border bg-muted/30 p-3">
|
|
275
|
-
<div className="relative max-w-md">
|
|
276
|
-
<Search className="pointer-events-none absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
|
277
|
-
<Input
|
|
278
|
-
autoFocus
|
|
279
|
-
placeholder="Tìm theo tên hoặc email…"
|
|
280
|
-
className="h-8 pl-8 text-sm"
|
|
281
|
-
value={query}
|
|
282
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
283
|
-
/>
|
|
284
|
-
</div>
|
|
285
|
-
<div className="mt-2 max-h-56 max-w-md space-y-0.5 overflow-y-auto">
|
|
286
|
-
{searching ? (
|
|
287
|
-
<p className="px-2 py-3 text-xs text-muted-foreground">
|
|
288
|
-
Đang tìm…
|
|
289
|
-
</p>
|
|
290
|
-
) : candidates.length === 0 ? (
|
|
291
|
-
// Danh sách rỗng ở đây gần như luôn là "ngoài phạm vi", không phải
|
|
292
|
-
// "hệ thống không có ai" — nói thẳng để khỏi tưởng mất dữ liệu.
|
|
293
|
-
<p className="px-2 py-3 text-xs text-muted-foreground">
|
|
294
|
-
Không có người dùng nào trong phạm vi quản trị của bạn khớp tìm
|
|
295
|
-
kiếm.
|
|
296
|
-
</p>
|
|
297
|
-
) : (
|
|
298
|
-
candidates.map((c) => (
|
|
299
|
-
<button
|
|
300
|
-
key={c.id}
|
|
301
|
-
type="button"
|
|
302
|
-
disabled={busyUserId === c.id}
|
|
303
|
-
onClick={() => add(c.id)}
|
|
304
|
-
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent disabled:opacity-50"
|
|
305
|
-
>
|
|
306
|
-
<span className="min-w-0 flex-1">
|
|
307
|
-
<span className="block truncate text-sm">
|
|
308
|
-
{c.name ?? c.email}
|
|
309
|
-
</span>
|
|
310
|
-
{c.name && c.email ? (
|
|
311
|
-
<span className="block truncate text-xs text-muted-foreground">
|
|
312
|
-
{c.email}
|
|
313
|
-
</span>
|
|
314
|
-
) : null}
|
|
315
|
-
</span>
|
|
316
|
-
{busyUserId === c.id ? (
|
|
317
|
-
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
318
|
-
) : (
|
|
319
|
-
<UserPlus className="h-3.5 w-3.5 text-muted-foreground" />
|
|
320
|
-
)}
|
|
321
|
-
</button>
|
|
322
|
-
))
|
|
323
|
-
)}
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
) : null}
|
|
327
|
-
|
|
328
221
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
329
222
|
{loading ? (
|
|
330
223
|
<div className="space-y-2 p-3">
|
|
@@ -332,36 +225,26 @@ export function WorkspaceMembersPanel({
|
|
|
332
225
|
<Skeleton className="h-10 w-5/6" />
|
|
333
226
|
</div>
|
|
334
227
|
) : members.length === 0 ? (
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
</
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
size="sm"
|
|
356
|
-
className="mt-4"
|
|
357
|
-
onClick={() => setAdding(true)}
|
|
358
|
-
>
|
|
359
|
-
<UserPlus className="h-3.5 w-3.5" />
|
|
360
|
-
<span className="ml-1.5">Thêm thành viên</span>
|
|
361
|
-
</Button>
|
|
362
|
-
) : null}
|
|
363
|
-
</div>
|
|
364
|
-
)
|
|
228
|
+
// Trạng thái rỗng phải DẠY việc, không chỉ báo trống: nói thêm người
|
|
229
|
+
// vào đây thì họ được gì, và ai mới cần đặt làm quản trị.
|
|
230
|
+
<div className="mx-auto max-w-sm px-6 py-12 text-center">
|
|
231
|
+
<Users className="mx-auto h-8 w-8 text-muted-foreground/60" />
|
|
232
|
+
<p className="mt-3 text-sm font-medium">
|
|
233
|
+
Chưa có ai trong {kindLabel?.toLowerCase() ?? "workspace"} này
|
|
234
|
+
</p>
|
|
235
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
236
|
+
Người được thêm vào đây sẽ thấy dữ liệu của{" "}
|
|
237
|
+
{workspaceName ?? "workspace này"}. Đặt một người làm{" "}
|
|
238
|
+
<strong className="font-medium text-foreground">quản trị</strong>{" "}
|
|
239
|
+
thì họ tự tạo được tài khoản và phân quyền trong phạm vi đó.
|
|
240
|
+
</p>
|
|
241
|
+
{canManage ? (
|
|
242
|
+
<Button size="sm" className="mt-4" onClick={() => setPicking(true)}>
|
|
243
|
+
<UserPlus className="h-3.5 w-3.5" />
|
|
244
|
+
<span className="ml-1.5">Thêm thành viên</span>
|
|
245
|
+
</Button>
|
|
246
|
+
) : null}
|
|
247
|
+
</div>
|
|
365
248
|
) : (
|
|
366
249
|
<ul>
|
|
367
250
|
{members.map((m) => (
|
|
@@ -393,6 +276,13 @@ export function WorkspaceMembersPanel({
|
|
|
393
276
|
{m.email}
|
|
394
277
|
</span>
|
|
395
278
|
) : null}
|
|
279
|
+
{/* "cũng ở" chứ không phải "ở": họ chắc chắn ở nút đang mở,
|
|
280
|
+
cái đáng cảnh báo là những nhánh CÒN LẠI — còn nhánh nào
|
|
281
|
+
thì họ còn thấy dữ liệu ở đó. */}
|
|
282
|
+
<WorkspaceIdentityLine
|
|
283
|
+
identity={m}
|
|
284
|
+
workspacePrefix="cũng ở:"
|
|
285
|
+
/>
|
|
396
286
|
</span>
|
|
397
287
|
|
|
398
288
|
{/* Cột vai trò: mọi dòng đều có chữ, không chỉ dòng quản trị —
|
|
@@ -449,6 +339,18 @@ export function WorkspaceMembersPanel({
|
|
|
449
339
|
</ul>
|
|
450
340
|
)}
|
|
451
341
|
</div>
|
|
342
|
+
|
|
343
|
+
<WorkspaceMemberPickerDialog
|
|
344
|
+
open={picking}
|
|
345
|
+
onOpenChange={setPicking}
|
|
346
|
+
workspaceId={workspaceId}
|
|
347
|
+
workspaceName={workspaceName}
|
|
348
|
+
kindLabel={kindLabel}
|
|
349
|
+
apiEndpoint={apiEndpoint}
|
|
350
|
+
filters={bulkFilters}
|
|
351
|
+
workspaceOptions={workspaceOptions}
|
|
352
|
+
onDone={() => void reload()}
|
|
353
|
+
/>
|
|
452
354
|
</div>
|
|
453
355
|
);
|
|
454
356
|
}
|