@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
|
@@ -56,12 +56,13 @@ import { cn } from "../../utils";
|
|
|
56
56
|
import { countTreeNodes } from "../components/workspace-tree-view";
|
|
57
57
|
import type { WorkspaceTreeNode } from "../components/workspace-tree-view";
|
|
58
58
|
import {
|
|
59
|
-
|
|
59
|
+
WorkspaceTreePanel,
|
|
60
60
|
filterWorkspaceTree,
|
|
61
61
|
rollupCounts,
|
|
62
|
-
} from "../components/workspace-
|
|
63
|
-
import type { WorkspaceOrgAnnotation } from "../components/workspace-
|
|
62
|
+
} from "../components/workspace-tree-panel";
|
|
63
|
+
import type { WorkspaceOrgAnnotation } from "../components/workspace-tree-panel";
|
|
64
64
|
import { WorkspaceMembersPanel } from "../components/workspace-members-panel";
|
|
65
|
+
import type { CandidateFilter } from "../components/workspace-members-panel";
|
|
65
66
|
import type { WorkspaceKindConfig } from "../types";
|
|
66
67
|
|
|
67
68
|
export interface WorkspaceListPageProps {
|
|
@@ -83,6 +84,11 @@ export interface WorkspaceListPageProps {
|
|
|
83
84
|
* bảng chúng là hai dòng y hệt nhau. Bỏ trống thì mọi khối vẽ trung tính.
|
|
84
85
|
*/
|
|
85
86
|
rootAnnotations?: Record<string, WorkspaceOrgAnnotation>;
|
|
87
|
+
/**
|
|
88
|
+
* Trục lọc cho chế độ gán hàng loạt ở panel thành viên (chi nhánh, bộ phận…).
|
|
89
|
+
* Bỏ trống thì panel giữ nguyên hành vi thêm từng người một.
|
|
90
|
+
*/
|
|
91
|
+
memberFilters?: CandidateFilter[];
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
interface FormState {
|
|
@@ -182,6 +188,7 @@ export function WorkspaceListPage({
|
|
|
182
188
|
apiEndpoint = "/api/workspaces",
|
|
183
189
|
title = "Workspace",
|
|
184
190
|
rootAnnotations,
|
|
191
|
+
memberFilters,
|
|
185
192
|
}: WorkspaceListPageProps) {
|
|
186
193
|
const [tree, setTree] = React.useState(initialTree);
|
|
187
194
|
const [search, setSearch] = React.useState("");
|
|
@@ -386,11 +393,44 @@ export function WorkspaceListPage({
|
|
|
386
393
|
if (!deactivating) return;
|
|
387
394
|
const ok = await submit(`${apiEndpoint}/${deactivating.id}`, "DELETE");
|
|
388
395
|
if (ok) {
|
|
389
|
-
toast.success("Đã
|
|
396
|
+
toast.success("Đã xoá cả nhánh khỏi danh sách đang dùng.");
|
|
390
397
|
setDeactivating(null);
|
|
391
398
|
}
|
|
392
399
|
};
|
|
393
400
|
|
|
401
|
+
/** Bật lại một nhánh đã xoá — cùng đường PATCH, đi cả nhánh ở phía server. */
|
|
402
|
+
const restore = async (node: WorkspaceTreeNode) => {
|
|
403
|
+
const ok = await submit(`${apiEndpoint}/${node.id}`, "PATCH", {
|
|
404
|
+
isActive: true,
|
|
405
|
+
});
|
|
406
|
+
if (ok) toast.success("Đã khôi phục cả nhánh.");
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
/** Nút nào người đang đăng nhập ghi được — dùng cho menu trên từng dòng cây. */
|
|
410
|
+
const canManageNode = React.useCallback(
|
|
411
|
+
(node: WorkspaceTreeNode) =>
|
|
412
|
+
canManageAll || (adminIds ?? []).includes(node.id),
|
|
413
|
+
[canManageAll, adminIds],
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
const openEdit = React.useCallback(
|
|
417
|
+
(node: WorkspaceTreeNode) =>
|
|
418
|
+
setForm({
|
|
419
|
+
mode: "edit",
|
|
420
|
+
id: node.id,
|
|
421
|
+
parentId: null,
|
|
422
|
+
code: node.code,
|
|
423
|
+
name: node.name,
|
|
424
|
+
kind: node.kind ?? kinds[0]?.key ?? "unit",
|
|
425
|
+
}),
|
|
426
|
+
[kinds],
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const openMove = React.useCallback((node: WorkspaceTreeNode) => {
|
|
430
|
+
setMoving(node);
|
|
431
|
+
setMoveTarget("__root__");
|
|
432
|
+
}, []);
|
|
433
|
+
|
|
394
434
|
// Không cho chuyển vào chính mình hay con cháu — chặn ở dropdown luôn cho gọn.
|
|
395
435
|
const moveOptions = React.useMemo(() => {
|
|
396
436
|
if (!moving) return [];
|
|
@@ -438,7 +478,7 @@ export function WorkspaceListPage({
|
|
|
438
478
|
) : null}
|
|
439
479
|
</RbacPageBar>
|
|
440
480
|
|
|
441
|
-
<div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,
|
|
481
|
+
<div className="grid gap-3 lg:mx-3 lg:grid-cols-[minmax(0,23rem)_minmax(0,1fr)] lg:items-start">
|
|
442
482
|
{/* KHUNG TRÁI — điều hướng. Ô tìm kiếm ở đây chứ không ở đầu trang: nó
|
|
443
483
|
lọc cây, không lọc thứ đang mở bên phải. */}
|
|
444
484
|
<div
|
|
@@ -507,16 +547,22 @@ export function WorkspaceListPage({
|
|
|
507
547
|
) : null}
|
|
508
548
|
</div>
|
|
509
549
|
) : (
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
550
|
+
<WorkspaceTreePanel
|
|
551
|
+
nodes={visibleRoots}
|
|
552
|
+
annotations={rootAnnotations}
|
|
553
|
+
activeId={selectedId}
|
|
554
|
+
onSelect={selectNode}
|
|
555
|
+
// Đang lọc thì mở hết: nút khớp thường nằm ở cấp sâu, để nguyên
|
|
556
|
+
// trạng thái gấp thì người dùng gõ đúng tên mà màn hình không đổi.
|
|
557
|
+
expandAll={Boolean(search)}
|
|
558
|
+
childKindLabel={addChildLabelOf}
|
|
559
|
+
canManage={canManageNode}
|
|
560
|
+
onAddChild={openCreate}
|
|
561
|
+
onEdit={openEdit}
|
|
562
|
+
onMove={openMove}
|
|
563
|
+
onDelete={setDeactivating}
|
|
564
|
+
onRestore={(node) => void restore(node)}
|
|
565
|
+
/>
|
|
520
566
|
)}
|
|
521
567
|
</div>
|
|
522
568
|
|
|
@@ -607,38 +653,32 @@ export function WorkspaceListPage({
|
|
|
607
653
|
</DropdownMenuTrigger>
|
|
608
654
|
<DropdownMenuContent align="end">
|
|
609
655
|
<DropdownMenuItem
|
|
610
|
-
onClick={() =>
|
|
611
|
-
setForm({
|
|
612
|
-
mode: "edit",
|
|
613
|
-
id: selected.id,
|
|
614
|
-
parentId: null,
|
|
615
|
-
code: selected.code,
|
|
616
|
-
name: selected.name,
|
|
617
|
-
kind: selected.kind ?? kinds[0]?.key ?? "unit",
|
|
618
|
-
})
|
|
619
|
-
}
|
|
656
|
+
onClick={() => openEdit(selected)}
|
|
620
657
|
>
|
|
621
|
-
Sửa
|
|
658
|
+
Sửa tên / mã
|
|
622
659
|
</DropdownMenuItem>
|
|
623
|
-
<DropdownMenuItem
|
|
624
|
-
onClick={() => {
|
|
625
|
-
setMoving(selected);
|
|
626
|
-
setMoveTarget("__root__");
|
|
627
|
-
}}
|
|
628
|
-
>
|
|
660
|
+
<DropdownMenuItem onClick={() => openMove(selected)}>
|
|
629
661
|
Chuyển nhánh
|
|
630
662
|
</DropdownMenuItem>
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
663
|
+
<DropdownMenuSeparator />
|
|
664
|
+
{selected.isActive === false ? (
|
|
665
|
+
<DropdownMenuItem
|
|
666
|
+
onClick={() => void restore(selected)}
|
|
667
|
+
>
|
|
668
|
+
Khôi phục
|
|
669
|
+
</DropdownMenuItem>
|
|
670
|
+
) : (
|
|
671
|
+
// Chữ "Xoá" phải có mặt: người đi tìm chức năng xoá
|
|
672
|
+
// quét mắt tìm đúng chữ đó, "Ngừng hoạt động" họ đọc
|
|
673
|
+
// thành một trạng thái nào khác rồi kết luận là
|
|
674
|
+
// trang không có xoá.
|
|
675
|
+
<DropdownMenuItem
|
|
676
|
+
className="text-destructive focus:text-destructive"
|
|
677
|
+
onClick={() => setDeactivating(selected)}
|
|
678
|
+
>
|
|
679
|
+
Xoá workspace
|
|
680
|
+
</DropdownMenuItem>
|
|
681
|
+
)}
|
|
642
682
|
</DropdownMenuContent>
|
|
643
683
|
</DropdownMenu>
|
|
644
684
|
</div>
|
|
@@ -652,6 +692,16 @@ export function WorkspaceListPage({
|
|
|
652
692
|
</div>
|
|
653
693
|
|
|
654
694
|
<StatStrip items={stats} />
|
|
695
|
+
|
|
696
|
+
{/* Câu giải thích phạm vi ở ĐÂY chứ không trên cây: cột trái
|
|
697
|
+
là danh sách để quét mắt, một đoạn ba dòng chen giữa các
|
|
698
|
+
dòng làm mất luôn nhịp đọc. Bên này đang nói về đúng một
|
|
699
|
+
nút nên có chỗ. */}
|
|
700
|
+
{rootAnnotations?.[selected.code]?.hint ? (
|
|
701
|
+
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
702
|
+
{rootAnnotations[selected.code].hint}
|
|
703
|
+
</p>
|
|
704
|
+
) : null}
|
|
655
705
|
</div>
|
|
656
706
|
|
|
657
707
|
<WorkspaceMembersPanel
|
|
@@ -662,6 +712,12 @@ export function WorkspaceListPage({
|
|
|
662
712
|
canManage={canManageSelected}
|
|
663
713
|
apiEndpoint={apiEndpoint}
|
|
664
714
|
onCountChange={applyMemberCount}
|
|
715
|
+
bulkFilters={memberFilters}
|
|
716
|
+
// Cây làm phẳng: hộp thêm thành viên lọc được "đang ở bộ phận
|
|
717
|
+
// nào", trục mà người dùng thật sự dùng ("chuyển cả tổ QC sang
|
|
718
|
+
// Kho"). Cây đầy đủ chứ không phải cây đã lọc — ô tìm bên trái
|
|
719
|
+
// lọc cây điều hướng, không lọc bộ lọc của hộp thoại.
|
|
720
|
+
workspaceOptions={flat}
|
|
665
721
|
/>
|
|
666
722
|
</>
|
|
667
723
|
) : (
|
|
@@ -791,9 +847,13 @@ export function WorkspaceListPage({
|
|
|
791
847
|
<ConfirmDialog
|
|
792
848
|
open={deactivating !== null}
|
|
793
849
|
onOpenChange={(open) => !open && setDeactivating(null)}
|
|
794
|
-
title="
|
|
795
|
-
|
|
796
|
-
|
|
850
|
+
title="Xoá workspace?"
|
|
851
|
+
// Nói rõ đây là xoá MỀM: dữ liệu cũ tham chiếu tới nút này (chứng từ,
|
|
852
|
+
// hồ sơ) vẫn còn nguyên phạm vi, và khôi phục lại được — nếu không nói
|
|
853
|
+
// ra thì người dùng hoặc sợ không dám bấm, hoặc bấm xong tưởng mất dữ
|
|
854
|
+
// liệu thật.
|
|
855
|
+
description={`"${deactivating?.name}" và toàn bộ cấp dưới sẽ bị gỡ khỏi danh sách đang dùng. Dữ liệu cũ giữ nguyên và khôi phục lại được.`}
|
|
856
|
+
confirmText="Xoá"
|
|
797
857
|
variant="destructive"
|
|
798
858
|
onConfirm={confirmDeactivate}
|
|
799
859
|
/>
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
buildTree,
|
|
26
26
|
createWorkspace,
|
|
27
27
|
deactivateSubtree,
|
|
28
|
+
reactivateSubtree,
|
|
28
29
|
moveWorkspace,
|
|
29
30
|
} from "./service";
|
|
30
31
|
import { WorkspaceTreeError } from "./tree";
|
|
@@ -50,6 +51,54 @@ export interface WorkspaceHandlerDeps {
|
|
|
50
51
|
* cho tới khi người dùng F5.
|
|
51
52
|
*/
|
|
52
53
|
kinds?: { key: string; label: string }[];
|
|
54
|
+
/**
|
|
55
|
+
* Bộ lọc ứng viên do APP định nghĩa, cho màn hình gán hàng loạt.
|
|
56
|
+
*
|
|
57
|
+
* Core cố ý không biết "chi nhánh" hay "bộ phận" là gì — mỗi app có trục
|
|
58
|
+
* khác nhau. App nhận `URLSearchParams` và trả về một mảnh `where` của
|
|
59
|
+
* Prisma trên model `User`; core chỉ AND nó vào truy vấn ứng viên. Bỏ trống
|
|
60
|
+
* thì hộp tìm kiếm chạy y như cũ.
|
|
61
|
+
*
|
|
62
|
+
* Mảnh `where` này KHÔNG thay thế `memberScopeWhere(..., adminOnly)` — nó
|
|
63
|
+
* chồng thêm, nên app không thể dùng nó để với ra ngoài phạm vi quản trị.
|
|
64
|
+
*/
|
|
65
|
+
candidateFilter?: (params: URLSearchParams) => unknown;
|
|
66
|
+
/**
|
|
67
|
+
* Các nhánh tìm kiếm THÊM cho ô "tìm người", ngoài tên và email của core.
|
|
68
|
+
*
|
|
69
|
+
* Người đi gán gõ MÃ NHÂN VIÊN, không gõ email — nhưng mã nhân viên nằm ở
|
|
70
|
+
* bảng hồ sơ của app (`Employee.employee_code` ở spartronics), core không
|
|
71
|
+
* biết bảng đó tồn tại. App trả về các mảnh `where` sẽ được OR vào cùng tên
|
|
72
|
+
* và email. Bỏ trống thì tìm kiếm giữ nguyên phạm vi tên + email.
|
|
73
|
+
*/
|
|
74
|
+
candidateSearch?: (query: string) => unknown[];
|
|
75
|
+
/**
|
|
76
|
+
* Danh tính người dùng để hiện KÈM mỗi dòng: chi nhánh nào, vai trò gì.
|
|
77
|
+
*
|
|
78
|
+
* Cùng lý do với `candidateFilter`: "chi nhánh" và "vai trò" là quan hệ của
|
|
79
|
+
* app, không phải của core — nhét thẳng `userBranches` / `userRoles` vào
|
|
80
|
+
* truy vấn thì app nào không có đúng hai quan hệ đó sẽ vỡ ngay lúc chạy.
|
|
81
|
+
* Nên app đưa `select` của mình vào, và tự rút gọn dòng user thành nhãn.
|
|
82
|
+
*
|
|
83
|
+
* Bỏ trống thì danh sách chỉ còn tên + email + các không gian đang thuộc về
|
|
84
|
+
* (phần đó core tự biết) — đúng như trước khi có tuỳ chọn này.
|
|
85
|
+
*/
|
|
86
|
+
memberIdentity?: {
|
|
87
|
+
/** Mảnh `select` ghép thêm vào truy vấn `User`. */
|
|
88
|
+
select: Record<string, unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* Rút gọn một dòng user thành nhãn hiển thị.
|
|
91
|
+
*
|
|
92
|
+
* `code` là mã nhân viên (hoặc mã nhân sự tương đương) — hiện ngay cạnh
|
|
93
|
+
* tên, vì đó là thứ người đi gán đọc để chắc chắn không nhầm hai người
|
|
94
|
+
* trùng tên.
|
|
95
|
+
*/
|
|
96
|
+
describe: (user: any) => {
|
|
97
|
+
branch?: string | null;
|
|
98
|
+
roles?: string[];
|
|
99
|
+
code?: string | null;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
53
102
|
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
54
103
|
}
|
|
55
104
|
|
|
@@ -235,6 +284,17 @@ export function createWorkspaceItemHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
235
284
|
if (body?.name !== undefined) data.name = String(body.name).trim();
|
|
236
285
|
if (body?.kind !== undefined) data.kind = body.kind;
|
|
237
286
|
if (body?.settings !== undefined) data.settings = body.settings;
|
|
287
|
+
|
|
288
|
+
// Bật lại một nút đã ngừng — đi cả nhánh, nên tách khỏi `update` thường.
|
|
289
|
+
// Chỉ nhận `isActive: true`: TẮT vẫn phải đi qua DELETE, nơi có câu xác
|
|
290
|
+
// nhận nói rõ là cả nhánh cùng ngừng.
|
|
291
|
+
if (body?.isActive === true) {
|
|
292
|
+
const count = await reactivateSubtree(deps.prisma, id);
|
|
293
|
+
if (Object.keys(data).length > 0) {
|
|
294
|
+
await deps.prisma.workspace.update({ where: { id }, data });
|
|
295
|
+
}
|
|
296
|
+
return json({ reactivated: count });
|
|
297
|
+
}
|
|
238
298
|
// `path` / `depth` / `parentId` cố ý KHÔNG nhận từ body: sửa tay là cách
|
|
239
299
|
// làm hỏng cây mà UI vẫn trông đúng. Đổi cha đi qua `/move`.
|
|
240
300
|
|
|
@@ -329,6 +389,33 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
329
389
|
};
|
|
330
390
|
}
|
|
331
391
|
|
|
392
|
+
/**
|
|
393
|
+
* Các không gian một người ĐANG thuộc về, do core tự đọc.
|
|
394
|
+
*
|
|
395
|
+
* Không phải trang trí: phạm vi đọc của một người là HỢP các không gian họ
|
|
396
|
+
* thuộc về, nên "còn ở đâu nữa" là thứ quyết định việc gán này có cô lập
|
|
397
|
+
* được họ hay không. Thiếu cột đó thì người gán bấm "Thêm" và tưởng đã xong.
|
|
398
|
+
*/
|
|
399
|
+
const membershipSelect = {
|
|
400
|
+
userWorkspaces: {
|
|
401
|
+
select: { workspaceId: true, workspace: { select: { name: true } } },
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Ghép nhãn danh tính vào một dòng user; `skipId` bỏ chính không gian đang mở. */
|
|
406
|
+
function describeUser(user: any, skipId?: string) {
|
|
407
|
+
const extra = deps.memberIdentity?.describe(user) ?? {};
|
|
408
|
+
return {
|
|
409
|
+
code: extra.code ?? null,
|
|
410
|
+
branch: extra.branch ?? null,
|
|
411
|
+
roles: extra.roles ?? [],
|
|
412
|
+
workspaces: (user.userWorkspaces ?? [])
|
|
413
|
+
.filter((m: any) => m.workspaceId !== skipId)
|
|
414
|
+
.map((m: any) => m.workspace?.name)
|
|
415
|
+
.filter(Boolean) as string[],
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
332
419
|
async function GET(req: Request, ctx: Ctx) {
|
|
333
420
|
try {
|
|
334
421
|
const actor = await resolveActor(deps);
|
|
@@ -353,26 +440,64 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
353
440
|
// Ứng viên chỉ lấy trong nhánh mình QUẢN TRỊ (adminOnly), không phải
|
|
354
441
|
// nhánh mình xem được: xem được cả công ty mà thêm được ai cũng vào
|
|
355
442
|
// nhánh mình thì D1 chỉ còn là trang trí.
|
|
443
|
+
// Gán hàng loạt cần nhìn thấy cả danh sách để tick, không phải 20 dòng
|
|
444
|
+
// đầu — nhưng vẫn có trần, danh sách vài nghìn dòng thì trình duyệt
|
|
445
|
+
// đơ chứ không giúp ai chọn nhanh hơn.
|
|
446
|
+
const take = Math.min(
|
|
447
|
+
Math.max(Number(url.searchParams.get("take")) || 20, 1),
|
|
448
|
+
200,
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* "Lấy người đang ở bộ phận X" — trục lọc do CORE lo, không phải app.
|
|
453
|
+
*
|
|
454
|
+
* Bảng `userWorkspace` là của core, nên đây là trục duy nhất core tự
|
|
455
|
+
* dựng được. Nó cũng là cách người dùng thật mô tả việc mình đang làm
|
|
456
|
+
* ("chuyển cả tổ QC sang Kho"), trong khi `candidateFilter` của app lo
|
|
457
|
+
* những trục app mới biết (chi nhánh, vai trò).
|
|
458
|
+
*/
|
|
459
|
+
const inWorkspaceId = url.searchParams.get("inWorkspaceId") ?? "";
|
|
460
|
+
|
|
356
461
|
const rows = await deps.prisma.user.findMany({
|
|
357
462
|
where: {
|
|
358
463
|
AND: [
|
|
359
464
|
memberScopeWhere(actor.scope, { adminOnly: true }),
|
|
360
465
|
{ userWorkspaces: { none: { workspaceId: id } } },
|
|
466
|
+
inWorkspaceId
|
|
467
|
+
? { userWorkspaces: { some: { workspaceId: inWorkspaceId } } }
|
|
468
|
+
: {},
|
|
361
469
|
query
|
|
362
470
|
? {
|
|
363
471
|
OR: [
|
|
364
472
|
{ name: { contains: query, mode: "insensitive" } },
|
|
365
473
|
{ email: { contains: query, mode: "insensitive" } },
|
|
474
|
+
...(deps.candidateSearch?.(query) ?? []),
|
|
366
475
|
],
|
|
367
476
|
}
|
|
368
477
|
: {},
|
|
478
|
+
(deps.candidateFilter?.(url.searchParams) ?? {}) as object,
|
|
369
479
|
],
|
|
370
480
|
},
|
|
371
|
-
select: {
|
|
481
|
+
select: {
|
|
482
|
+
id: true,
|
|
483
|
+
name: true,
|
|
484
|
+
email: true,
|
|
485
|
+
isActive: true,
|
|
486
|
+
...membershipSelect,
|
|
487
|
+
...(deps.memberIdentity?.select ?? {}),
|
|
488
|
+
},
|
|
372
489
|
orderBy: { name: "asc" },
|
|
373
|
-
take
|
|
490
|
+
take,
|
|
374
491
|
});
|
|
375
|
-
return json(
|
|
492
|
+
return json(
|
|
493
|
+
rows.map((u: any) => ({
|
|
494
|
+
id: u.id,
|
|
495
|
+
name: u.name,
|
|
496
|
+
email: u.email,
|
|
497
|
+
isActive: u.isActive,
|
|
498
|
+
...describeUser(u),
|
|
499
|
+
})),
|
|
500
|
+
);
|
|
376
501
|
}
|
|
377
502
|
|
|
378
503
|
const rows = await deps.prisma.userWorkspace.findMany({
|
|
@@ -381,7 +506,14 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
381
506
|
isAdmin: true,
|
|
382
507
|
isDefault: true,
|
|
383
508
|
user: {
|
|
384
|
-
select: {
|
|
509
|
+
select: {
|
|
510
|
+
id: true,
|
|
511
|
+
name: true,
|
|
512
|
+
email: true,
|
|
513
|
+
isActive: true,
|
|
514
|
+
...membershipSelect,
|
|
515
|
+
...(deps.memberIdentity?.select ?? {}),
|
|
516
|
+
},
|
|
385
517
|
},
|
|
386
518
|
},
|
|
387
519
|
orderBy: [{ isAdmin: "desc" }, { user: { name: "asc" } }],
|
|
@@ -394,6 +526,9 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
394
526
|
isActive: r.user.isActive,
|
|
395
527
|
isAdmin: r.isAdmin,
|
|
396
528
|
isDefault: r.isDefault,
|
|
529
|
+
// Bỏ chính nút đang mở ra khỏi danh sách "cũng ở": ai cũng thuộc về
|
|
530
|
+
// nó, nhắc lại trên mọi dòng thì cột này không còn nói được gì.
|
|
531
|
+
...describeUser(r.user, id),
|
|
397
532
|
})),
|
|
398
533
|
);
|
|
399
534
|
} catch (error) {
|
|
@@ -411,6 +546,85 @@ export function createWorkspaceMemberHandlers(deps: WorkspaceHandlerDeps) {
|
|
|
411
546
|
if (denied) return denied;
|
|
412
547
|
|
|
413
548
|
const body = await req.json();
|
|
549
|
+
|
|
550
|
+
// Đường GÁN HÀNG LOẠT. Tách hẳn khỏi đường một-người bên dưới vì ngữ
|
|
551
|
+
// nghĩa khác nhau: ở đây `mode: "move"` GỠ mọi membership khác của người
|
|
552
|
+
// đó. Phải gỡ thật — phạm vi là HỢP của các membership, nên "thêm vào bộ
|
|
553
|
+
// phận Kho" mà vẫn giữ membership gốc thì người đó vẫn thấy toàn hệ
|
|
554
|
+
// thống, tức là bấm gán xong mà không cô lập được gì.
|
|
555
|
+
if (Array.isArray(body?.userIds)) {
|
|
556
|
+
const userIds = [
|
|
557
|
+
...new Set(
|
|
558
|
+
body.userIds
|
|
559
|
+
.filter((v: unknown) => typeof v === "string")
|
|
560
|
+
.map((v: string) => v.trim())
|
|
561
|
+
.filter(Boolean),
|
|
562
|
+
),
|
|
563
|
+
] as string[];
|
|
564
|
+
if (userIds.length === 0) {
|
|
565
|
+
return json({ error: "Chưa chọn người dùng nào." }, 400);
|
|
566
|
+
}
|
|
567
|
+
const move = body?.mode !== "add";
|
|
568
|
+
|
|
569
|
+
const failed: { userId: string; error: string }[] = [];
|
|
570
|
+
let changed = 0;
|
|
571
|
+
|
|
572
|
+
for (const target0 of userIds) {
|
|
573
|
+
const target = await loadTarget(target0);
|
|
574
|
+
if (!target) {
|
|
575
|
+
failed.push({
|
|
576
|
+
userId: target0,
|
|
577
|
+
error: "Không tìm thấy người dùng.",
|
|
578
|
+
});
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
const next = move ? [id] : [...new Set([...target.workspaceIds, id])];
|
|
582
|
+
try {
|
|
583
|
+
assertCanUpdateUser(toDelegationActor(actor), target, {
|
|
584
|
+
workspaceIds: next,
|
|
585
|
+
});
|
|
586
|
+
} catch (error) {
|
|
587
|
+
failed.push({
|
|
588
|
+
userId: target0,
|
|
589
|
+
error:
|
|
590
|
+
error instanceof DelegationError
|
|
591
|
+
? error.message
|
|
592
|
+
: "Không cập nhật được.",
|
|
593
|
+
});
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
await deps.prisma.$transaction(async (tx: any) => {
|
|
598
|
+
if (move) {
|
|
599
|
+
await tx.userWorkspace.deleteMany({
|
|
600
|
+
where: { userId: target0, NOT: { workspaceId: id } },
|
|
601
|
+
});
|
|
602
|
+
} else {
|
|
603
|
+
await tx.userWorkspace.updateMany({
|
|
604
|
+
where: {
|
|
605
|
+
userId: target0,
|
|
606
|
+
isDefault: true,
|
|
607
|
+
NOT: { workspaceId: id },
|
|
608
|
+
},
|
|
609
|
+
data: { isDefault: false },
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
await tx.userWorkspace.upsert({
|
|
613
|
+
where: {
|
|
614
|
+
userId_workspaceId: { userId: target0, workspaceId: id },
|
|
615
|
+
},
|
|
616
|
+
// Không đụng `isAdmin` của người đã ở sẵn trong nút: gán hàng loạt
|
|
617
|
+
// mà hạ cờ quản trị của họ là một tác dụng phụ không ai yêu cầu.
|
|
618
|
+
create: { userId: target0, workspaceId: id, isDefault: true },
|
|
619
|
+
update: { isDefault: true },
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
changed += 1;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return json({ changed, failed, mode: move ? "move" : "add" });
|
|
626
|
+
}
|
|
627
|
+
|
|
414
628
|
const userId = String(body?.userId ?? "").trim();
|
|
415
629
|
if (!userId) return json({ error: "Thiếu userId." }, 400);
|
|
416
630
|
|
package/src/workspace/scope.ts
CHANGED
|
@@ -66,6 +66,8 @@ export function createWorkspaceScope(input: {
|
|
|
66
66
|
allowedIds?: string[];
|
|
67
67
|
adminIds?: string[];
|
|
68
68
|
defaultId?: string;
|
|
69
|
+
level?: ScopeLevel;
|
|
70
|
+
userId?: string;
|
|
69
71
|
}): WorkspaceScope {
|
|
70
72
|
if (input.canViewAll) {
|
|
71
73
|
return {
|
|
@@ -73,6 +75,8 @@ export function createWorkspaceScope(input: {
|
|
|
73
75
|
rootIds: input.rootIds ?? [],
|
|
74
76
|
adminIds: input.adminIds ?? [],
|
|
75
77
|
defaultId: input.defaultId,
|
|
78
|
+
level: input.level,
|
|
79
|
+
userId: input.userId,
|
|
76
80
|
};
|
|
77
81
|
}
|
|
78
82
|
const allowed =
|
|
@@ -86,6 +90,8 @@ export function createWorkspaceScope(input: {
|
|
|
86
90
|
allowedBranchIds: allowed,
|
|
87
91
|
adminIds: input.adminIds ?? [],
|
|
88
92
|
defaultId: input.defaultId,
|
|
93
|
+
level: input.level,
|
|
94
|
+
userId: input.userId,
|
|
89
95
|
};
|
|
90
96
|
}
|
|
91
97
|
|
|
@@ -97,8 +103,18 @@ export async function getWorkspaceScope<TSession>(
|
|
|
97
103
|
session: TSession,
|
|
98
104
|
): Promise<WorkspaceScope> {
|
|
99
105
|
const config = requireConfigured();
|
|
106
|
+
const userId = config.getUserId(session) ?? undefined;
|
|
107
|
+
// Nấc cô lập của vai trò. `all` mở toàn bộ, `none` đóng sạch — hai nấc này
|
|
108
|
+
// quyết định xong là không cần đọc membership nữa.
|
|
109
|
+
const level = (await config.getScopeLevel?.(session)) ?? undefined;
|
|
110
|
+
if (level === "all") {
|
|
111
|
+
return createWorkspaceScope({ canViewAll: true, level, userId });
|
|
112
|
+
}
|
|
113
|
+
if (level === "none") {
|
|
114
|
+
return createWorkspaceScope({ canViewAll: false, allowedIds: [], level, userId });
|
|
115
|
+
}
|
|
100
116
|
if (config.canViewAll(session)) {
|
|
101
|
-
return createWorkspaceScope({ canViewAll: true });
|
|
117
|
+
return createWorkspaceScope({ canViewAll: true, level, userId });
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
const memberships = await readMemberships(config, session);
|
|
@@ -109,7 +125,10 @@ export async function getWorkspaceScope<TSession>(
|
|
|
109
125
|
const defaultId =
|
|
110
126
|
memberships.find((m) => m.isDefault)?.workspaceId ?? rootIds[0];
|
|
111
127
|
|
|
112
|
-
|
|
128
|
+
// `workspace` = đúng nút được gán, KHÔNG bung con cháu. Gấp ngay vào
|
|
129
|
+
// `allowedIds` ở đây thay vì bắt từng trang nhớ gọi `scopeLevelWhere`.
|
|
130
|
+
const allowedIds =
|
|
131
|
+
level === "workspace" ? rootIds : await expand(config, rootIds);
|
|
113
132
|
// Quản trị viên của nhánh nào thì quản trị được cả con cháu nhánh đó — cùng
|
|
114
133
|
// luật cộng dồn với phạm vi đọc, nếu không thì admin nút cha lại không đụng
|
|
115
134
|
// được phòng ban con của chính mình.
|
|
@@ -122,6 +141,8 @@ export async function getWorkspaceScope<TSession>(
|
|
|
122
141
|
allowedIds,
|
|
123
142
|
adminIds,
|
|
124
143
|
defaultId,
|
|
144
|
+
level,
|
|
145
|
+
userId,
|
|
125
146
|
});
|
|
126
147
|
}
|
|
127
148
|
|
|
@@ -252,6 +273,8 @@ export function memberScopeWhere(
|
|
|
252
273
|
scope: WorkspaceScope,
|
|
253
274
|
options?: { relation?: string; field?: string; adminOnly?: boolean },
|
|
254
275
|
): Record<string, unknown> {
|
|
276
|
+
// Nấc `own` trên chính bảng NGƯỜI DÙNG = chỉ thấy hồ sơ của mình.
|
|
277
|
+
if (scope.level === "own") return { id: scope.userId ?? NO_WORKSPACE_ACCESS };
|
|
255
278
|
if (scope.canViewAll) return {};
|
|
256
279
|
const config = configured;
|
|
257
280
|
const relation =
|
|
@@ -274,8 +297,23 @@ export function memberScopeWhere(
|
|
|
274
297
|
*/
|
|
275
298
|
export function scopedWhere(
|
|
276
299
|
scope: WorkspaceScope,
|
|
277
|
-
options?: { field?: string; relation?: string },
|
|
300
|
+
options?: { field?: string; relation?: string; ownerField?: string },
|
|
278
301
|
): Record<string, unknown> {
|
|
302
|
+
// Nấc `own` là nấc DUY NHẤT không gấp được vào `allowedIds` — nó lọc theo
|
|
303
|
+
// người chứ không theo không gian. Không khai `ownerField` (ở đây hoặc trong
|
|
304
|
+
// `configureWorkspaces`) thì đóng lại: fail-closed, đừng lặng lẽ nới thành
|
|
305
|
+
// `subtree`.
|
|
306
|
+
if (scope.level === "own") {
|
|
307
|
+
const ownerField = options?.ownerField ?? configured?.ownerField;
|
|
308
|
+
if (options?.relation) {
|
|
309
|
+
return ownerField
|
|
310
|
+
? { [options.relation]: { [ownerField]: scope.userId ?? NO_WORKSPACE_ACCESS } }
|
|
311
|
+
: { [options.relation]: { id: NO_WORKSPACE_ACCESS } };
|
|
312
|
+
}
|
|
313
|
+
return ownerField
|
|
314
|
+
? { [ownerField]: scope.userId ?? NO_WORKSPACE_ACCESS }
|
|
315
|
+
: { id: NO_WORKSPACE_ACCESS };
|
|
316
|
+
}
|
|
279
317
|
if (scope.canViewAll) return {};
|
|
280
318
|
const field = options?.field ?? workspaceScopeField();
|
|
281
319
|
const ids = scope.allowedIds ?? [NO_WORKSPACE_ACCESS];
|
package/src/workspace/service.ts
CHANGED
|
@@ -255,6 +255,28 @@ export async function deactivateSubtree(
|
|
|
255
255
|
return nodes.length;
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Bật lại cả nhánh — đối xứng với `deactivateSubtree`.
|
|
260
|
+
*
|
|
261
|
+
* Phải có, nếu không thì "xoá" là một chiều: nút đã ngừng nằm mãi trong danh
|
|
262
|
+
* sách với chữ "(ngừng)" và không đường nào đưa về. Bật lại CẢ NHÁNH chứ không
|
|
263
|
+
* riêng nút được bấm: nhánh tắt theo nhau thì cũng phải bật theo nhau, bật mỗi
|
|
264
|
+
* nút cha để lại một cây nửa sống nửa chết mà giao diện không nói ra được.
|
|
265
|
+
*/
|
|
266
|
+
export async function reactivateSubtree(
|
|
267
|
+
db: WorkspaceServiceDb,
|
|
268
|
+
rootId: string,
|
|
269
|
+
): Promise<number> {
|
|
270
|
+
const nodes = await listSubtree(db, rootId);
|
|
271
|
+
for (const node of nodes) {
|
|
272
|
+
await db.workspace.update({
|
|
273
|
+
where: { id: node.id },
|
|
274
|
+
data: { isActive: true },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return nodes.length;
|
|
278
|
+
}
|
|
279
|
+
|
|
258
280
|
/** Dựng cây lồng nhau cho UI từ danh sách phẳng. */
|
|
259
281
|
export interface WorkspaceTreeItem extends WorkspaceNode {
|
|
260
282
|
children: WorkspaceTreeItem[];
|