@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/bin/goerp-features.mjs +11 -1
  3. package/features/workspaces/README.md +72 -0
  4. package/features/workspaces/migrations/0001_init.sql +63 -0
  5. package/features/workspaces/migrations/0002_delegation-columns.sql +34 -0
  6. package/features/workspaces/schema.prisma +56 -0
  7. package/package.json +2 -1
  8. package/scripts/feature-sync.mjs +31 -3
  9. package/src/branch-scope/context.ts +20 -37
  10. package/src/features/__tests__/feature-sync.test.ts +41 -0
  11. package/src/guardrails/__tests__/guardrails.test.ts +47 -0
  12. package/src/guardrails/primitives.ts +14 -1
  13. package/src/guardrails/rules/one-door.ts +23 -0
  14. package/src/guardrails/scanner.ts +9 -0
  15. package/src/guardrails/types.ts +7 -0
  16. package/src/user/__tests__/user-service-scope.test.ts +148 -0
  17. package/src/user/components/unified-profile-dialog.tsx +160 -0
  18. package/src/user/pages/users-client-page.tsx +12 -0
  19. package/src/user/user-service.ts +64 -10
  20. package/src/workspace/__tests__/workspace-delegation.test.ts +363 -0
  21. package/src/workspace/__tests__/workspace-member-handlers.test.ts +407 -0
  22. package/src/workspace/__tests__/workspace-route-handlers.test.ts +449 -0
  23. package/src/workspace/__tests__/workspace-scope.test.ts +592 -0
  24. package/src/workspace/__tests__/workspace-service.test.ts +339 -0
  25. package/src/workspace/components/scope-level-select.tsx +91 -0
  26. package/src/workspace/components/workspace-members-panel.tsx +454 -0
  27. package/src/workspace/components/workspace-org-block.tsx +293 -0
  28. package/src/workspace/components/workspace-switcher.tsx +139 -0
  29. package/src/workspace/components/workspace-tree-view.tsx +301 -0
  30. package/src/workspace/context.ts +78 -0
  31. package/src/workspace/delegation.ts +400 -0
  32. package/src/workspace/guard.ts +138 -0
  33. package/src/workspace/index.ts +173 -0
  34. package/src/workspace/pages/workspace-list-page.tsx +802 -0
  35. package/src/workspace/route-handlers.ts +550 -0
  36. package/src/workspace/scope.ts +396 -0
  37. package/src/workspace/service.ts +301 -0
  38. package/src/workspace/tree.ts +193 -0
  39. package/src/workspace/types.ts +182 -0
@@ -70,6 +70,29 @@ export const oneDoorRules: GuardrailRule[] = [
70
70
  ctx.files.some((f) => /\b(getBranchScope|scopedBranchWhere)\s*\(/.test(ctx.readCode(f))),
71
71
  }),
72
72
 
73
+ singleDoorImport({
74
+ id: "one-door/workspace",
75
+ title: "chỉ file hạ tầng được import @goerp/core/workspace",
76
+ why:
77
+ "Cùng một engine, cùng một AsyncLocalStorage với branch-scope — chỉ tổng " +
78
+ "quát hơn (chi nhánh / đơn vị / phòng ban là một cây). Import thẳng lấy " +
79
+ "được hàm nhưng bỏ lỡ `configureWorkspaces()`, và ở module này 'bỏ lỡ' " +
80
+ "nghĩa là rò dữ liệu chéo không gian — im lặng, không exception.",
81
+ fix: "Import qua cửa của app (`@/lib/workspace` hoặc `@/lib/branch-scope`); composition root phải gọi `configureWorkspaces`.",
82
+ pattern: /from\s+["']@goerp\/core\/workspace["']/,
83
+ doors: (ctx) => ctx.options.doors.workspace,
84
+ mustConfigure: /configureWorkspaces\s*[<(]/,
85
+ mustConfigureName: "configureWorkspaces",
86
+ // Lớp 2 (`createScopeGuardExtension`) và các hàm cây thuần không cần
87
+ // singleton; chỉ lớp 1 mới cần, và thiếu thì nó NÉM.
88
+ mustConfigureWhen: (ctx) =>
89
+ ctx.files.some((f) =>
90
+ /\b(getWorkspaceScope|scopedWhere|scopeLevelWhere|workspaceScopeField)\s*\(/.test(
91
+ ctx.readCode(f),
92
+ ),
93
+ ),
94
+ }),
95
+
73
96
  forbidPattern({
74
97
  id: "one-door/export-no-window-open",
75
98
  title: "không window.open(...) tới endpoint /export",
@@ -79,6 +79,15 @@ export function resolveOptions(
79
79
  "lib/rbac/branch-scope.ts",
80
80
  "lib/prisma.ts",
81
81
  ],
82
+ // `lib/workspace.ts` đứng trước để app nào tách file riêng thì phép kiểm
83
+ // "cửa duy nhất phải gọi configureWorkspaces" nhìn đúng file đó.
84
+ workspace: options.doors?.workspace ??
85
+ options.doors?.branchScope ?? [
86
+ "lib/workspace.ts",
87
+ "lib/branch-scope.ts",
88
+ "lib/rbac/branch-scope.ts",
89
+ "lib/prisma.ts",
90
+ ],
82
91
  },
83
92
  };
84
93
  }
@@ -137,6 +137,12 @@ export interface GuardrailOptions {
137
137
  storage?: string[];
138
138
  /** File hạ tầng được import `@goerp/core/branch-scope`. */
139
139
  branchScope?: string[];
140
+ /**
141
+ * File hạ tầng được import `@goerp/core/workspace`. Mặc định gồm cả các cửa
142
+ * của `branchScope` — hai module chạy chung MỘT AsyncLocalStorage, nên app
143
+ * cắm chúng ở cùng một composition root là chuyện thường.
144
+ */
145
+ workspace?: string[];
140
146
  };
141
147
  }
142
148
 
@@ -149,6 +155,7 @@ export type ResolvedGuardrailOptions = GuardrailOptions &
149
155
  prisma: string;
150
156
  storage: string[];
151
157
  branchScope: string[];
158
+ workspace: string[];
152
159
  };
153
160
  };
154
161
 
@@ -0,0 +1,148 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ configureWorkspaces,
5
+ createWorkspaceScope,
6
+ resetWorkspaceConfig,
7
+ } from "../../workspace/scope";
8
+ import {
9
+ getUsersData,
10
+ getUserStats,
11
+ getUsersWithDetails,
12
+ } from "../user-service";
13
+ import type { UserPrismaClient } from "../user-service";
14
+
15
+ /**
16
+ * Lỗ hổng đang được vá ở đây: danh sách người dùng của core dựng `where` từ
17
+ * search/status/roleCode và KHÔNG có điều kiện phạm vi nào — admin nhánh của
18
+ * khách hàng A nhìn thấy toàn bộ người dùng của khách hàng B.
19
+ *
20
+ * Test soi thẳng `where` gửi xuống Prisma chứ không soi kết quả: kết quả là do
21
+ * DB giả trả, còn `where` mới là thứ chạy trên DB thật.
22
+ */
23
+ function spyDb() {
24
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
+ const calls: { fn: string; where: any }[] = [];
26
+ const db = {
27
+ user: {
28
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
29
+ findMany: async (args: any) => {
30
+ calls.push({ fn: "user.findMany", where: args.where });
31
+ return [];
32
+ },
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ count: async (args?: any) => {
35
+ calls.push({ fn: "user.count", where: args?.where });
36
+ return 0;
37
+ },
38
+ },
39
+ role: { count: async () => 0, findMany: async () => [] },
40
+ department: { findMany: async () => [] },
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ $transaction: async (args: any) => args,
43
+ } as unknown as UserPrismaClient;
44
+ return { db, calls };
45
+ }
46
+
47
+ const SCOPE_WHERE = {
48
+ userBranches: { some: { branchId: { in: ["spa", "spa-qc"] } } },
49
+ };
50
+
51
+ function scopeOfSpa() {
52
+ return createWorkspaceScope({
53
+ canViewAll: false,
54
+ rootIds: ["spa"],
55
+ allowedIds: ["spa", "spa-qc"],
56
+ adminIds: ["spa"],
57
+ });
58
+ }
59
+
60
+ beforeEach(() => {
61
+ resetWorkspaceConfig();
62
+ configureWorkspaces({
63
+ getUserId: () => undefined,
64
+ canViewAll: () => false,
65
+ membershipRelation: "userBranches",
66
+ membershipField: "branchId",
67
+ });
68
+ });
69
+
70
+ describe("getUsersData", () => {
71
+ it("không truyền scope ⇒ where y hệt hôm nay (L2: additive)", async () => {
72
+ const { db, calls } = spyDb();
73
+ await getUsersData(db, { search: "an" });
74
+ expect(JSON.stringify(calls[0].where)).not.toContain("userBranches");
75
+ });
76
+
77
+ it("truyền scope ⇒ điều kiện phạm vi vào CẢ findMany lẫn count", async () => {
78
+ const { db, calls } = spyDb();
79
+ await getUsersData(db, { scope: scopeOfSpa() });
80
+ const find = calls.find((c) => c.fn === "user.findMany")!;
81
+ const count = calls.find((c) => c.fn === "user.count")!;
82
+ expect(find.where.AND).toContainEqual(SCOPE_WHERE);
83
+ expect(count.where.AND).toContainEqual(SCOPE_WHERE);
84
+ });
85
+
86
+ it("phạm vi cộng THÊM vào bộ lọc, không thay thế", async () => {
87
+ const { db, calls } = spyDb();
88
+ await getUsersData(db, {
89
+ search: "an",
90
+ status: "active",
91
+ scope: scopeOfSpa(),
92
+ });
93
+ expect(calls[0].where.AND).toHaveLength(3);
94
+ expect(calls[0].where.AND).toContainEqual(SCOPE_WHERE);
95
+ });
96
+
97
+ it("xem được tất ⇒ không thêm điều kiện", async () => {
98
+ const { db, calls } = spyDb();
99
+ await getUsersData(db, {
100
+ scope: createWorkspaceScope({ canViewAll: true }),
101
+ });
102
+ expect(JSON.stringify(calls[0].where)).not.toContain("userBranches");
103
+ });
104
+
105
+ it("adminOnly thu hẹp về nhánh được uỷ quyền", async () => {
106
+ const { db, calls } = spyDb();
107
+ await getUsersData(db, { scope: scopeOfSpa(), adminOnly: true });
108
+ expect(calls[0].where.AND).toContainEqual({
109
+ userBranches: { some: { branchId: { in: ["spa"] } } },
110
+ });
111
+ });
112
+ });
113
+
114
+ describe("getUsersWithDetails — kể cả các ô thống kê", () => {
115
+ it("MỌI truy vấn đếm đều mang phạm vi, không sót ô nào", async () => {
116
+ const { db, calls } = spyDb();
117
+ await getUsersWithDetails(db, { scope: scopeOfSpa() });
118
+ const userQueries = calls.filter((c) => c.fn.startsWith("user."));
119
+ expect(userQueries.length).toBeGreaterThan(3);
120
+ for (const call of userQueries) {
121
+ expect(JSON.stringify(call.where)).toContain("userBranches");
122
+ }
123
+ });
124
+
125
+ it("không có scope thì không truy vấn nào bị thêm điều kiện", async () => {
126
+ const { db, calls } = spyDb();
127
+ await getUsersWithDetails(db, {});
128
+ for (const call of calls) {
129
+ expect(JSON.stringify(call.where ?? {})).not.toContain("userBranches");
130
+ }
131
+ });
132
+ });
133
+
134
+ describe("getUserStats", () => {
135
+ it("đếm trong phạm vi khi được truyền scope", async () => {
136
+ const { db, calls } = spyDb();
137
+ await getUserStats(db, scopeOfSpa());
138
+ for (const call of calls.filter((c) => c.fn === "user.count")) {
139
+ expect(JSON.stringify(call.where)).toContain("userBranches");
140
+ }
141
+ });
142
+
143
+ it("không scope ⇒ đếm toàn hệ thống như cũ", async () => {
144
+ const { db, calls } = spyDb();
145
+ await getUserStats(db);
146
+ expect(calls.find((c) => c.fn === "user.count")!.where).toEqual({});
147
+ });
148
+ });
@@ -52,6 +52,25 @@ interface UnifiedProfileDialogProps {
52
52
  onSaveProfile?: (data: any, id?: string) => Promise<void>;
53
53
  onSaveRoles?: (userId: string, roleCodes: string[]) => Promise<void>;
54
54
  onSavePassword?: (userId: string, password: string) => Promise<void>;
55
+ /**
56
+ * Bật ô chọn KHÔNG GIAN LÀM VIỆC bằng cách trỏ tới API cây không gian
57
+ * (thường `/api/workspaces`). Bỏ trống ⇒ không fetch, không render, hộp thoại
58
+ * y hệt trước — app chưa bật tính năng workspace (vinhhoa) không đổi một pixel.
59
+ */
60
+ workspaceApiUrl?: string;
61
+ /** Nhãn theo nghiệp vụ của app: "Đơn vị", "Tổ chức", "Chi nhánh"… */
62
+ workspaceLabel?: string;
63
+ }
64
+
65
+ /** Cây → danh sách phẳng, giữ thứ tự duyệt để dòng con nằm ngay dưới dòng cha. */
66
+ function flattenWorkspaceTree(
67
+ nodes: any[],
68
+ depth = 0,
69
+ ): { id: string; name: string; depth: number }[] {
70
+ return (nodes ?? []).flatMap((node) => [
71
+ { id: node.id, name: node.name, depth },
72
+ ...flattenWorkspaceTree(node.children ?? [], depth + 1),
73
+ ]);
55
74
  }
56
75
 
57
76
  const fetcher = async (url: string) => {
@@ -87,6 +106,8 @@ export function UnifiedProfileDialog({
87
106
  onSaveProfile,
88
107
  onSaveRoles,
89
108
  onSavePassword,
109
+ workspaceApiUrl,
110
+ workspaceLabel = "Không gian làm việc",
90
111
  }: UnifiedProfileDialogProps) {
91
112
  // -- Data Fetching --
92
113
  const { data: departments } = useSWR<{ id: string; name: string }[]>(
@@ -109,6 +130,16 @@ export function UnifiedProfileDialog({
109
130
  "/api/branches",
110
131
  fetcher,
111
132
  );
133
+ // Key `null` ⇒ SWR không gọi gì cả. App chưa bật workspace không phát sinh
134
+ // thêm một request nào.
135
+ const { data: workspaceTree } = useSWR<any[]>(
136
+ workspaceApiUrl ?? null,
137
+ fetcher,
138
+ );
139
+ const workspaces = useMemo(
140
+ () => flattenWorkspaceTree(workspaceTree ?? []),
141
+ [workspaceTree],
142
+ );
112
143
 
113
144
  // -- State --
114
145
  const [activeSection, setActiveSection] = useState("general");
@@ -121,6 +152,10 @@ export function UnifiedProfileDialog({
121
152
  const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
122
153
  const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
123
154
  const [defaultBranchId, setDefaultBranchId] = useState<string | null>(null);
155
+ const [selectedWorkspaces, setSelectedWorkspaces] = useState<string[]>([]);
156
+ const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(
157
+ null,
158
+ );
124
159
  const [passwordData, setPasswordData] = useState({
125
160
  password: "",
126
161
  confirm: "",
@@ -193,6 +228,8 @@ export function UnifiedProfileDialog({
193
228
  setSelectedRoles([]);
194
229
  setSelectedBranches([]);
195
230
  setDefaultBranchId(null);
231
+ setSelectedWorkspaces([]);
232
+ setDefaultWorkspaceId(null);
196
233
  setPasswordData({ password: "", confirm: "" });
197
234
  setEnableAccount(false);
198
235
  } else {
@@ -226,6 +263,10 @@ export function UnifiedProfileDialog({
226
263
  setDefaultBranchId(
227
264
  d.defaultBranchId || (d.branchIds && d.branchIds[0]) || null,
228
265
  );
266
+ setSelectedWorkspaces(d.workspaceIds || []);
267
+ setDefaultWorkspaceId(
268
+ d.defaultWorkspaceId || (d.workspaceIds && d.workspaceIds[0]) || null,
269
+ );
229
270
  setPasswordData({ password: "", confirm: "" });
230
271
  // Enable account if user has roles or if it's not a customer (employees always have accounts?)
231
272
  // For now, if roles exist, we assume account is enabled.
@@ -245,6 +286,38 @@ export function UnifiedProfileDialog({
245
286
  }
246
287
  }, [open, branches, mode, data]);
247
288
 
289
+ // Chỉ có đúng một không gian để chọn thì chọn sẵn — cùng cách cư xử với ô chi
290
+ // nhánh ngay bên cạnh, đỡ một cú bấm bắt buộc mà không có lựa chọn nào khác.
291
+ useEffect(() => {
292
+ if (!open || !workspaceApiUrl || workspaces.length !== 1) return;
293
+ if (mode === "create" && selectedWorkspaces.length === 0) {
294
+ setSelectedWorkspaces([workspaces[0].id]);
295
+ setDefaultWorkspaceId(workspaces[0].id);
296
+ }
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
298
+ }, [open, workspaces, mode, workspaceApiUrl]);
299
+
300
+ // -- Workspace selection helpers — giữ đúng khuôn của cặp hàm chi nhánh dưới đây.
301
+ const toggleWorkspace = (workspaceId: string) => {
302
+ if (selectedWorkspaces.includes(workspaceId)) {
303
+ const next = selectedWorkspaces.filter((id) => id !== workspaceId);
304
+ setSelectedWorkspaces(next);
305
+ if (defaultWorkspaceId === workspaceId) {
306
+ setDefaultWorkspaceId(next[0] ?? null);
307
+ }
308
+ } else {
309
+ setSelectedWorkspaces([...selectedWorkspaces, workspaceId]);
310
+ if (!defaultWorkspaceId) setDefaultWorkspaceId(workspaceId);
311
+ }
312
+ };
313
+
314
+ const markDefaultWorkspace = (workspaceId: string) => {
315
+ if (!selectedWorkspaces.includes(workspaceId)) {
316
+ setSelectedWorkspaces((prev) => [...prev, workspaceId]);
317
+ }
318
+ setDefaultWorkspaceId(workspaceId);
319
+ };
320
+
248
321
  // -- Branch selection helpers --
249
322
  // Toggle a branch's membership while keeping a valid default branch.
250
323
  const toggleBranch = (branchId: string) => {
@@ -311,6 +384,16 @@ export function UnifiedProfileDialog({
311
384
  : (selectedBranches[0] ?? null),
312
385
  };
313
386
 
387
+ // Chỉ gửi khi app bật tính năng — gửi mảng rỗng lên app chưa bật là mời
388
+ // route xoá sạch membership của người đang sửa.
389
+ if (workspaceApiUrl) {
390
+ payload.workspaceIds = selectedWorkspaces;
391
+ payload.defaultWorkspaceId =
392
+ defaultWorkspaceId && selectedWorkspaces.includes(defaultWorkspaceId)
393
+ ? defaultWorkspaceId
394
+ : (selectedWorkspaces[0] ?? null);
395
+ }
396
+
314
397
  // Handle Password for Customer
315
398
  if (isCustomer) {
316
399
  if (enableAccount) {
@@ -943,6 +1026,83 @@ export function UnifiedProfileDialog({
943
1026
  })}
944
1027
  </div>
945
1028
  </div>
1029
+
1030
+ {workspaceApiUrl ? (
1031
+ <div className="col-span-2 space-y-3 pt-2">
1032
+ <div className="flex items-center justify-between gap-3 flex-wrap">
1033
+ <Label className="text-[13px] font-medium text-foreground">
1034
+ {workspaceLabel}
1035
+ </Label>
1036
+ <span className="inline-flex items-center gap-1 text-[12px] text-muted-foreground">
1037
+ <Star className="h-3 w-3" />
1038
+ Đánh dấu một mục mặc định
1039
+ </span>
1040
+ </div>
1041
+ {workspaces.length === 0 ? (
1042
+ // Rỗng ở đây gần như luôn là "ngoài phạm vi của bạn",
1043
+ // không phải "hệ thống chưa có" — nói rõ để khỏi
1044
+ // tưởng mất dữ liệu.
1045
+ <p className="text-[13px] text-muted-foreground">
1046
+ Không có mục nào trong phạm vi của bạn.
1047
+ </p>
1048
+ ) : (
1049
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1050
+ {workspaces.map((ws) => {
1051
+ const isSelected = selectedWorkspaces.includes(
1052
+ ws.id,
1053
+ );
1054
+ const isDefault =
1055
+ isSelected && defaultWorkspaceId === ws.id;
1056
+ return (
1057
+ <div
1058
+ key={ws.id}
1059
+ className={cn(
1060
+ "flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-accent/50 transition-colors",
1061
+ isSelected
1062
+ ? "border-primary bg-primary/5"
1063
+ : "bg-background border-border",
1064
+ )}
1065
+ onClick={() => toggleWorkspace(ws.id)}
1066
+ >
1067
+ <Checkbox
1068
+ checked={isSelected}
1069
+ className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
1070
+ />
1071
+ <span
1072
+ className="flex-1 text-sm font-medium text-foreground truncate"
1073
+ // Thụt theo cấp để dòng con đọc ra là con
1074
+ // của dòng ngay trên nó.
1075
+ style={{ paddingLeft: ws.depth * 12 }}
1076
+ >
1077
+ {ws.name}
1078
+ </span>
1079
+ {isSelected &&
1080
+ (isDefault ? (
1081
+ <span className="inline-flex items-center gap-1 rounded-full bg-primary px-2 py-0.5 text-[11px] font-semibold text-primary-foreground shrink-0">
1082
+ <Star className="h-3 w-3 fill-current" />
1083
+ Mặc định
1084
+ </span>
1085
+ ) : (
1086
+ <button
1087
+ type="button"
1088
+ onClick={(e) => {
1089
+ e.stopPropagation();
1090
+ markDefaultWorkspace(ws.id);
1091
+ }}
1092
+ title="Đặt làm mặc định"
1093
+ className="inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground hover:border-primary/50 hover:text-primary transition-colors shrink-0"
1094
+ >
1095
+ <Star className="h-3 w-3" />
1096
+ Đặt mặc định
1097
+ </button>
1098
+ ))}
1099
+ </div>
1100
+ );
1101
+ })}
1102
+ </div>
1103
+ )}
1104
+ </div>
1105
+ ) : null}
946
1106
  </div>
947
1107
  </div>
948
1108
  )}
@@ -37,6 +37,12 @@ interface UsersClientPageProps {
37
37
  /** Cây menu + meta action cho dialog "Quyền hiệu lực" (additive). */
38
38
  menuTree?: EffectiveMenuTreeSection[];
39
39
  actionMeta?: Record<string, { label?: string; flow?: string; description?: string }>;
40
+ /**
41
+ * Bật ô chọn không gian làm việc trong hộp thoại người dùng (additive). Bỏ
42
+ * trống ⇒ hộp thoại giữ nguyên như cũ, không fetch gì thêm.
43
+ */
44
+ workspaceApiUrl?: string;
45
+ workspaceLabel?: string;
40
46
  }
41
47
 
42
48
  const DEFAULT_PAGE_SIZE = 20;
@@ -52,6 +58,8 @@ export function UsersClientPage({
52
58
  onResetPassword,
53
59
  menuTree,
54
60
  actionMeta,
61
+ workspaceApiUrl,
62
+ workspaceLabel,
55
63
  }: UsersClientPageProps) {
56
64
  const [permUser, setPermUser] = useState<any | null>(null);
57
65
  const router = useRouter();
@@ -282,6 +290,8 @@ export function UsersClientPage({
282
290
  mode="create"
283
291
  viewMode="admin"
284
292
  roles={roles}
293
+ workspaceApiUrl={workspaceApiUrl}
294
+ workspaceLabel={workspaceLabel}
285
295
  onSaveProfile={(data) => handleUserSubmit(data)}
286
296
  />
287
297
 
@@ -294,6 +304,8 @@ export function UsersClientPage({
294
304
  mode="edit"
295
305
  viewMode="admin"
296
306
  roles={roles}
307
+ workspaceApiUrl={workspaceApiUrl}
308
+ workspaceLabel={workspaceLabel}
297
309
  onSaveProfile={handleUserSubmit}
298
310
  onSaveRoles={handleAssignRoles}
299
311
  onSavePassword={handleResetPassword}
@@ -1,3 +1,6 @@
1
+ import { memberScopeWhere } from "../workspace/scope";
2
+
3
+ import type { WorkspaceScope } from "../workspace/types";
1
4
  import type { CrudResponse } from "../types";
2
5
 
3
6
  // Define the shape of the Prisma Client required by this service
@@ -16,6 +19,24 @@ export interface GetUsersParams {
16
19
  status?: string;
17
20
  roleCode?: string;
18
21
  departmentId?: string;
22
+ /**
23
+ * Phạm vi không gian của người ĐANG XEM. Bỏ trống = không lọc, giữ nguyên hành
24
+ * vi cũ cho app một-tổ-chức (luật L2: additive).
25
+ *
26
+ * App nhiều tổ chức PHẢI truyền: thiếu nó thì admin nhánh khách hàng A nhìn
27
+ * thấy toàn bộ người dùng của khách hàng B. Lọc ở tầng service chứ không ở
28
+ * tầng page — Entra từng thừa nhận đúng lỗi này (UI lọc, API thì không).
29
+ */
30
+ scope?: WorkspaceScope;
31
+ /** Chỉ hiện người dùng trong nhánh mình QUẢN TRỊ, không phải mọi nhánh nhìn được. */
32
+ adminOnly?: boolean;
33
+ }
34
+
35
+ /** Điều kiện phạm vi cho danh sách người dùng; `null` khi không phải lọc. */
36
+ function scopeCondition(params: GetUsersParams): Record<string, unknown> | null {
37
+ if (!params.scope) return null;
38
+ const where = memberScopeWhere(params.scope, { adminOnly: params.adminOnly });
39
+ return Object.keys(where).length > 0 ? where : null;
19
40
  }
20
41
 
21
42
  export interface UserWithDetails {
@@ -82,6 +103,9 @@ export async function getUsersData(
82
103
  });
83
104
  }
84
105
 
106
+ const scoped = scopeCondition(params);
107
+ if (scoped) whereConditions.push(scoped);
108
+
85
109
  const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
86
110
 
87
111
  // ⚡ Bolt: Execute independent read queries concurrently to reduce latency
@@ -252,8 +276,15 @@ export async function getUsersWithDetails(
252
276
  });
253
277
  }
254
278
 
279
+ const scoped = scopeCondition(params);
280
+ if (scoped) whereConditions.push(scoped);
281
+
255
282
  const where: any = whereConditions.length > 0 ? { AND: whereConditions } : {};
256
283
 
284
+ /** Đếm KHÔNG theo bộ lọc nhưng VẪN theo phạm vi. */
285
+ const scopedCount = (extra: Record<string, unknown>): any =>
286
+ scoped ? { AND: [extra, scoped] } : extra;
287
+
257
288
  // ⚡ Bolt: Execute independent read queries concurrently to reduce latency
258
289
  const [users, total, activeCount, rolesCount, customers, suppliers] =
259
290
  await Promise.all([
@@ -327,13 +358,22 @@ export async function getUsersWithDetails(
327
358
  db.user.count({ where }),
328
359
  db.user.count({ where: { ...where, isActive: true } }),
329
360
  db.role.count({ where: { status: "active" } }),
330
- db.user.count({ where: { userType: "customer" } }).catch(() => 0),
331
- db.user.count({ where: { userType: "supplier" } }).catch(() => 0),
361
+ // Hai ô thống này cố ý BỎ QUA bộ lọc tìm kiếm, nên phải tự cộng lại
362
+ // điều kiện phạm vi không thì con số vẫn đếm cả người dùng của tổ chức
363
+ // khác. Rò một con số vẫn là rò.
364
+ db.user
365
+ .count({ where: scopedCount({ userType: "customer" }) })
366
+ .catch(() => 0),
367
+ db.user
368
+ .count({ where: scopedCount({ userType: "supplier" }) })
369
+ .catch(() => 0),
332
370
  ]);
333
371
 
334
- // Get total users count (without filters)
335
- const totalUsersCount = await db.user.count();
336
- const totalActiveCount = await db.user.count({ where: { isActive: true } });
372
+ // Tổng số người dùng bỏ bộ lọc nhưng KHÔNG bỏ phạm vi.
373
+ const totalUsersCount = await db.user.count({ where: scoped ?? {} });
374
+ const totalActiveCount = await db.user.count({
375
+ where: scopedCount({ isActive: true }),
376
+ });
337
377
  const employees = Math.max(0, totalUsersCount - customers - suppliers);
338
378
 
339
379
  // Transform data
@@ -420,13 +460,27 @@ export async function getActiveDepartments(db: UserPrismaClient) {
420
460
  /**
421
461
  * Get user statistics
422
462
  */
423
- export async function getUserStats(db: UserPrismaClient) {
463
+ export async function getUserStats(
464
+ db: UserPrismaClient,
465
+ /** Bỏ trống = đếm toàn hệ thống (hành vi cũ). Truyền vào để đếm trong phạm vi. */
466
+ scope?: WorkspaceScope,
467
+ ) {
468
+ const scoped = scope ? scopeCondition({ scope }) : null;
469
+ const withScope = (extra?: Record<string, unknown>): any => {
470
+ if (!scoped) return extra ?? {};
471
+ return extra ? { AND: [extra, scoped] } : scoped;
472
+ };
473
+
424
474
  const [totalUsers, activeUsers, customers, suppliers, totalRoles] =
425
475
  await Promise.all([
426
- db.user.count(),
427
- db.user.count({ where: { isActive: true } }),
428
- db.user.count({ where: { userType: "customer" } }).catch(() => 0),
429
- db.user.count({ where: { userType: "supplier" } }).catch(() => 0),
476
+ db.user.count({ where: withScope() }),
477
+ db.user.count({ where: withScope({ isActive: true }) }),
478
+ db.user
479
+ .count({ where: withScope({ userType: "customer" }) })
480
+ .catch(() => 0),
481
+ db.user
482
+ .count({ where: withScope({ userType: "supplier" }) })
483
+ .catch(() => 0),
430
484
  db.role.count({ where: { status: "active" } }),
431
485
  ]);
432
486