@goplusvn/core 0.1.75 → 0.1.77
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 +58 -0
- package/bin/goerp-features.mjs +11 -1
- package/features/workspaces/README.md +72 -0
- package/features/workspaces/migrations/0001_init.sql +63 -0
- package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
- package/features/workspaces/schema.prisma +56 -0
- package/package.json +2 -1
- package/scripts/feature-sync.mjs +31 -3
- package/src/branch-scope/context.ts +20 -37
- package/src/features/__tests__/feature-sync.test.ts +41 -0
- package/src/guardrails/__tests__/guardrails.test.ts +47 -0
- package/src/guardrails/primitives.ts +14 -1
- package/src/guardrails/rules/one-door.ts +23 -0
- package/src/guardrails/scanner.ts +9 -0
- package/src/guardrails/types.ts +7 -0
- package/src/user/__tests__/user-service-scope.test.ts +148 -0
- package/src/user/components/unified-profile-dialog.tsx +160 -0
- package/src/user/pages/users-client-page.tsx +12 -0
- package/src/user/user-service.ts +64 -10
- package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
- package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
- package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
- package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
- package/src/workspace/__tests__/workspace-service.test.ts +339 -0
- package/src/workspace/components/scope-level-select.tsx +91 -0
- package/src/workspace/components/workspace-members-panel.tsx +454 -0
- package/src/workspace/components/workspace-org-block.tsx +293 -0
- package/src/workspace/components/workspace-switcher.tsx +139 -0
- package/src/workspace/components/workspace-tree-view.tsx +301 -0
- package/src/workspace/context.ts +78 -0
- package/src/workspace/delegation.ts +400 -0
- package/src/workspace/guard.ts +138 -0
- package/src/workspace/index.ts +173 -0
- package/src/workspace/pages/workspace-list-page.tsx +802 -0
- package/src/workspace/route-handlers.ts +550 -0
- package/src/workspace/scope.ts +396 -0
- package/src/workspace/service.ts +301 -0
- package/src/workspace/tree.ts +193 -0
- package/src/workspace/types.ts +182 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Cột "Thành viên" của trang Không gian làm việc.
|
|
4
|
+
//
|
|
5
|
+
// Đây là chỗ DUY NHẤT trên giao diện ghi bảng membership. Nút bị ẩn ở đây không
|
|
6
|
+
// phải hàng rào — hàng rào nằm ở `createWorkspaceMemberHandlers`; panel này chỉ
|
|
7
|
+
// có nhiệm vụ đừng mời người dùng bấm vào thứ chắc chắn 403.
|
|
8
|
+
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Loader2,
|
|
13
|
+
MoreHorizontal,
|
|
14
|
+
Search,
|
|
15
|
+
Shield,
|
|
16
|
+
Trash2,
|
|
17
|
+
UserPlus,
|
|
18
|
+
Users,
|
|
19
|
+
X,
|
|
20
|
+
} from "lucide-react";
|
|
21
|
+
import { toast } from "sonner";
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
Badge,
|
|
25
|
+
Button,
|
|
26
|
+
DropdownMenu,
|
|
27
|
+
DropdownMenuContent,
|
|
28
|
+
DropdownMenuItem,
|
|
29
|
+
DropdownMenuSeparator,
|
|
30
|
+
DropdownMenuTrigger,
|
|
31
|
+
Input,
|
|
32
|
+
Skeleton,
|
|
33
|
+
} from "../../ui";
|
|
34
|
+
import { cn } from "../../utils";
|
|
35
|
+
|
|
36
|
+
/** Chữ cái đầu cho ô avatar — hai ký tự là đủ nhận diện, ba trở lên thành rối. */
|
|
37
|
+
function initials(name?: string | null, email?: string | null): string {
|
|
38
|
+
const source = (name ?? email ?? "?").trim();
|
|
39
|
+
const words = source.split(/\s+/).filter(Boolean);
|
|
40
|
+
if (words.length >= 2) {
|
|
41
|
+
return (
|
|
42
|
+
words[words.length - 2][0] + words[words.length - 1][0]
|
|
43
|
+
).toUpperCase();
|
|
44
|
+
}
|
|
45
|
+
return source.slice(0, 2).toUpperCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WorkspaceMember {
|
|
49
|
+
userId: string;
|
|
50
|
+
name: string | null;
|
|
51
|
+
email: string | null;
|
|
52
|
+
isActive?: boolean;
|
|
53
|
+
isAdmin?: boolean;
|
|
54
|
+
isDefault?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Candidate {
|
|
58
|
+
id: string;
|
|
59
|
+
name: string | null;
|
|
60
|
+
email: string | null;
|
|
61
|
+
isActive?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface WorkspaceMembersPanelProps {
|
|
65
|
+
/** Nút đang chọn trên cây; `null` = chưa chọn gì. */
|
|
66
|
+
workspaceId: string | null;
|
|
67
|
+
workspaceName?: string | null;
|
|
68
|
+
/** Nhãn cấp của app ("Đơn vị" / "Phòng ban" / "Chi nhánh") — core không tự đặt tên. */
|
|
69
|
+
kindLabel?: string;
|
|
70
|
+
/** Người dùng có quản trị được ĐÚNG nút này không (đã tính ở server). */
|
|
71
|
+
canManage?: boolean;
|
|
72
|
+
apiEndpoint?: string;
|
|
73
|
+
/**
|
|
74
|
+
* Báo số thành viên THẬT sau mỗi lần tải/thêm/gỡ, để cây bên trái cập nhật.
|
|
75
|
+
*
|
|
76
|
+
* Không có nó thì con số trên cây là ảnh chụp lúc server render: thêm người
|
|
77
|
+
* xong, panel hiện 1 mà cây vẫn "0 người" — người dùng đọc hai con số mâu
|
|
78
|
+
* thuẫn trên cùng một màn hình và không biết tin cái nào.
|
|
79
|
+
*/
|
|
80
|
+
onCountChange?: (workspaceId: string, count: number) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function WorkspaceMembersPanel({
|
|
84
|
+
workspaceId,
|
|
85
|
+
workspaceName,
|
|
86
|
+
kindLabel,
|
|
87
|
+
canManage = false,
|
|
88
|
+
apiEndpoint = "/api/workspaces",
|
|
89
|
+
onCountChange,
|
|
90
|
+
}: WorkspaceMembersPanelProps) {
|
|
91
|
+
const [members, setMembers] = React.useState<WorkspaceMember[]>([]);
|
|
92
|
+
const [loading, setLoading] = React.useState(false);
|
|
93
|
+
const [busyUserId, setBusyUserId] = React.useState<string | null>(null);
|
|
94
|
+
const [adding, setAdding] = React.useState(false);
|
|
95
|
+
const [query, setQuery] = React.useState("");
|
|
96
|
+
const [candidates, setCandidates] = React.useState<Candidate[]>([]);
|
|
97
|
+
const [searching, setSearching] = React.useState(false);
|
|
98
|
+
|
|
99
|
+
const base = workspaceId ? `${apiEndpoint}/${workspaceId}/members` : null;
|
|
100
|
+
|
|
101
|
+
// Giữ callback trong ref: trang cha thường truyền hàm inline, để nó vào deps
|
|
102
|
+
// của `reload` là mỗi lần cha render lại thì panel gọi API lại một vòng.
|
|
103
|
+
const countRef = React.useRef(onCountChange);
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
countRef.current = onCountChange;
|
|
106
|
+
}, [onCountChange]);
|
|
107
|
+
|
|
108
|
+
const reload = React.useCallback(async () => {
|
|
109
|
+
if (!base) {
|
|
110
|
+
setMembers([]);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setLoading(true);
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(base, { cache: "no-store" });
|
|
116
|
+
if (!res.ok) throw new Error((await res.json())?.error ?? "Lỗi tải");
|
|
117
|
+
const list: WorkspaceMember[] = await res.json();
|
|
118
|
+
setMembers(list);
|
|
119
|
+
if (workspaceId) countRef.current?.(workspaceId, list.length);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
toast.error(
|
|
122
|
+
error instanceof Error ? error.message : "Không tải được thành viên.",
|
|
123
|
+
);
|
|
124
|
+
setMembers([]);
|
|
125
|
+
} finally {
|
|
126
|
+
setLoading(false);
|
|
127
|
+
}
|
|
128
|
+
}, [base, workspaceId]);
|
|
129
|
+
|
|
130
|
+
React.useEffect(() => {
|
|
131
|
+
void reload();
|
|
132
|
+
// Đổi nút thì đóng luôn ô thêm — để mở là người dùng tưởng đang thêm vào nút cũ.
|
|
133
|
+
setAdding(false);
|
|
134
|
+
setQuery("");
|
|
135
|
+
setCandidates([]);
|
|
136
|
+
}, [reload]);
|
|
137
|
+
|
|
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
|
+
async function mutate(
|
|
170
|
+
init: RequestInit & { url: string },
|
|
171
|
+
userId: string,
|
|
172
|
+
okMessage: string,
|
|
173
|
+
) {
|
|
174
|
+
setBusyUserId(userId);
|
|
175
|
+
try {
|
|
176
|
+
const res = await fetch(init.url, init);
|
|
177
|
+
const payload = await res.json().catch(() => ({}));
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
// Câu từ server là câu giải thích VÌ SAO (D1/D3/D5/D7) — hiển thị nguyên
|
|
180
|
+
// văn, đừng nuốt thành "Có lỗi xảy ra".
|
|
181
|
+
throw new Error(payload?.error ?? "Thao tác không thành công.");
|
|
182
|
+
}
|
|
183
|
+
toast.success(okMessage);
|
|
184
|
+
await reload();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
toast.error(error instanceof Error ? error.message : "Thao tác lỗi.");
|
|
187
|
+
} finally {
|
|
188
|
+
setBusyUserId(null);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
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
|
+
const toggleAdmin = (member: WorkspaceMember) =>
|
|
205
|
+
mutate(
|
|
206
|
+
{
|
|
207
|
+
url: base!,
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "content-type": "application/json" },
|
|
210
|
+
body: JSON.stringify({
|
|
211
|
+
userId: member.userId,
|
|
212
|
+
isAdmin: !member.isAdmin,
|
|
213
|
+
isDefault: member.isDefault,
|
|
214
|
+
}),
|
|
215
|
+
},
|
|
216
|
+
member.userId,
|
|
217
|
+
member.isAdmin ? "Đã bỏ quyền quản trị." : "Đã đặt làm quản trị.",
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const remove = (userId: string) =>
|
|
221
|
+
mutate(
|
|
222
|
+
{ url: `${base}?userId=${encodeURIComponent(userId)}`, method: "DELETE" },
|
|
223
|
+
userId,
|
|
224
|
+
"Đã gỡ khỏi " + (kindLabel?.toLowerCase() ?? "workspace"),
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!workspaceId) {
|
|
228
|
+
return (
|
|
229
|
+
<div className="flex h-full min-h-40 items-center justify-center p-6 text-center text-sm text-muted-foreground">
|
|
230
|
+
Chọn một {kindLabel?.toLowerCase() ?? "workspace"} ở bên trái để xem và
|
|
231
|
+
gán người dùng.
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div className="flex h-full flex-col">
|
|
238
|
+
{/* Thanh của DANH SÁCH, không phải của không gian: tên/mã/cấp đã nằm ở
|
|
239
|
+
đầu vùng chi tiết bên trên, lặp lại ở đây chỉ tốn dòng. */}
|
|
240
|
+
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
241
|
+
<Users className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
242
|
+
<p className="text-sm font-medium">
|
|
243
|
+
Thành viên
|
|
244
|
+
{!loading ? (
|
|
245
|
+
<span className="ml-1.5 tabular-nums text-muted-foreground">
|
|
246
|
+
{members.length}
|
|
247
|
+
</span>
|
|
248
|
+
) : null}
|
|
249
|
+
</p>
|
|
250
|
+
{canManage ? (
|
|
251
|
+
<Button
|
|
252
|
+
size="sm"
|
|
253
|
+
variant={adding ? "secondary" : "default"}
|
|
254
|
+
className="ml-auto"
|
|
255
|
+
onClick={() => setAdding((v) => !v)}
|
|
256
|
+
>
|
|
257
|
+
{adding ? (
|
|
258
|
+
<X className="h-3.5 w-3.5" />
|
|
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>
|
|
265
|
+
</Button>
|
|
266
|
+
) : null}
|
|
267
|
+
</div>
|
|
268
|
+
|
|
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
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
329
|
+
{loading ? (
|
|
330
|
+
<div className="space-y-2 p-3">
|
|
331
|
+
<Skeleton className="h-10 w-full" />
|
|
332
|
+
<Skeleton className="h-10 w-5/6" />
|
|
333
|
+
</div>
|
|
334
|
+
) : members.length === 0 ? (
|
|
335
|
+
// Đang mở ô thêm thì bảng hướng dẫn im lặng: người dùng đã làm đúng
|
|
336
|
+
// việc nó bảo làm, để lại chỉ tổ tranh chỗ với danh sách ứng viên.
|
|
337
|
+
adding ? null : (
|
|
338
|
+
// 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
|
|
339
|
+
// vào đây thì họ được gì, và ai mới cần đặt làm quản trị.
|
|
340
|
+
<div className="mx-auto max-w-sm px-6 py-12 text-center">
|
|
341
|
+
<Users className="mx-auto h-8 w-8 text-muted-foreground/60" />
|
|
342
|
+
<p className="mt-3 text-sm font-medium">
|
|
343
|
+
Chưa có ai trong {kindLabel?.toLowerCase() ?? "workspace"} này
|
|
344
|
+
</p>
|
|
345
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
|
|
346
|
+
Người được thêm vào đây sẽ thấy dữ liệu của{" "}
|
|
347
|
+
{workspaceName ?? "workspace này"}. Đặt một người làm{" "}
|
|
348
|
+
<strong className="font-medium text-foreground">
|
|
349
|
+
quản trị
|
|
350
|
+
</strong>{" "}
|
|
351
|
+
thì họ tự tạo được tài khoản và phân quyền trong phạm vi đó.
|
|
352
|
+
</p>
|
|
353
|
+
{canManage ? (
|
|
354
|
+
<Button
|
|
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
|
+
)
|
|
365
|
+
) : (
|
|
366
|
+
<ul>
|
|
367
|
+
{members.map((m) => (
|
|
368
|
+
<li
|
|
369
|
+
key={m.userId}
|
|
370
|
+
className={cn(
|
|
371
|
+
"flex items-center gap-3 border-b border-border/60 px-3 py-2 last:border-b-0",
|
|
372
|
+
busyUserId === m.userId && "opacity-50",
|
|
373
|
+
)}
|
|
374
|
+
>
|
|
375
|
+
<span
|
|
376
|
+
aria-hidden
|
|
377
|
+
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-muted-foreground"
|
|
378
|
+
>
|
|
379
|
+
{initials(m.name, m.email)}
|
|
380
|
+
</span>
|
|
381
|
+
|
|
382
|
+
<span className="min-w-0 flex-1">
|
|
383
|
+
<span className="block truncate text-sm font-medium">
|
|
384
|
+
{m.name ?? m.email}
|
|
385
|
+
{m.isActive === false ? (
|
|
386
|
+
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
|
387
|
+
(ngừng hoạt động)
|
|
388
|
+
</span>
|
|
389
|
+
) : null}
|
|
390
|
+
</span>
|
|
391
|
+
{m.name && m.email ? (
|
|
392
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
393
|
+
{m.email}
|
|
394
|
+
</span>
|
|
395
|
+
) : null}
|
|
396
|
+
</span>
|
|
397
|
+
|
|
398
|
+
{/* Cột vai trò: mọi dòng đều có chữ, không chỉ dòng quản trị —
|
|
399
|
+
có chữ ở cả hai trạng thái thì mới đọc ra đây là một CỘT. */}
|
|
400
|
+
<span className="shrink-0">
|
|
401
|
+
{m.isAdmin ? (
|
|
402
|
+
<Badge variant="secondary" className="gap-1">
|
|
403
|
+
<Shield className="h-3 w-3" />
|
|
404
|
+
Quản trị
|
|
405
|
+
</Badge>
|
|
406
|
+
) : (
|
|
407
|
+
<span className="text-xs text-muted-foreground">
|
|
408
|
+
Thành viên
|
|
409
|
+
</span>
|
|
410
|
+
)}
|
|
411
|
+
</span>
|
|
412
|
+
|
|
413
|
+
{canManage ? (
|
|
414
|
+
<DropdownMenu>
|
|
415
|
+
<DropdownMenuTrigger asChild>
|
|
416
|
+
<Button
|
|
417
|
+
size="sm"
|
|
418
|
+
variant="ghost"
|
|
419
|
+
className="h-7 w-7 shrink-0 p-0 text-muted-foreground"
|
|
420
|
+
disabled={busyUserId === m.userId}
|
|
421
|
+
title="Thao tác"
|
|
422
|
+
>
|
|
423
|
+
{busyUserId === m.userId ? (
|
|
424
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
425
|
+
) : (
|
|
426
|
+
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
427
|
+
)}
|
|
428
|
+
</Button>
|
|
429
|
+
</DropdownMenuTrigger>
|
|
430
|
+
<DropdownMenuContent align="end">
|
|
431
|
+
<DropdownMenuItem onClick={() => toggleAdmin(m)}>
|
|
432
|
+
{m.isAdmin ? "Bỏ quyền quản trị" : "Đặt làm quản trị"}
|
|
433
|
+
</DropdownMenuItem>
|
|
434
|
+
<DropdownMenuSeparator />
|
|
435
|
+
<DropdownMenuItem
|
|
436
|
+
className="text-destructive focus:text-destructive"
|
|
437
|
+
onClick={() => remove(m.userId)}
|
|
438
|
+
>
|
|
439
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
440
|
+
<span className="ml-2">
|
|
441
|
+
Gỡ khỏi {kindLabel?.toLowerCase() ?? "workspace"}
|
|
442
|
+
</span>
|
|
443
|
+
</DropdownMenuItem>
|
|
444
|
+
</DropdownMenuContent>
|
|
445
|
+
</DropdownMenu>
|
|
446
|
+
) : null}
|
|
447
|
+
</li>
|
|
448
|
+
))}
|
|
449
|
+
</ul>
|
|
450
|
+
)}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
);
|
|
454
|
+
}
|