@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
@@ -352,7 +352,7 @@ describe("cổng tổng hợp", () => {
352
352
  assertCanCreateUser(actor, ["spa-qc"], [role()]),
353
353
  ).not.toThrow();
354
354
  expect(() => assertCanCreateUser(actor, [])).toThrow(
355
- /ít nhất một không gian/,
355
+ /ít nhất một workspace/,
356
356
  );
357
357
  expect(() =>
358
358
  assertCanCreateUser(actor, ["spa-qc"], [role({ rank: 10 })]),
@@ -0,0 +1,407 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // Đường GHI membership — nơi một admin khách hàng có thể tự bành trướng nếu
3
+ // hàng rào hở.
4
+ //
5
+ // Mọi ca gọi thẳng handler như một client tự chế (curl), không đi qua giao diện:
6
+ // trang có ẩn nút hay không là chuyện khác, hàng rào phải nằm ở đây. Ba đường
7
+ // leo thang được soi riêng: hút người của tenant khác về nhánh mình (D5), tự
8
+ // nâng chính mình (D3), và đẩy nạn nhân ra khỏi nhánh khác để chiếm (D7).
9
+
10
+ import { beforeEach, describe, expect, it } from "vitest";
11
+
12
+ import { createWorkspaceMemberHandlers } from "../route-handlers";
13
+ import { configureWorkspaces, resetWorkspaceConfig } from "../scope";
14
+ import { subtreePrefix } from "../tree";
15
+
16
+ beforeEach(() => {
17
+ resetWorkspaceConfig();
18
+ });
19
+
20
+ /**
21
+ * grp (tập đoàn)
22
+ * ├── tanloc (Nhà ăn Tấn Lộc — vận hành)
23
+ * └── spa (Spartronics — khách hàng)
24
+ * └── spa-qc (phòng QC của khách hàng)
25
+ */
26
+ const WORKSPACES = [
27
+ { id: "grp", path: "/grp/" },
28
+ { id: "tanloc", path: "/grp/tanloc/" },
29
+ { id: "spa", path: "/grp/spa/" },
30
+ { id: "spa-qc", path: "/grp/spa/spa-qc/" },
31
+ ];
32
+
33
+ interface FakeUser {
34
+ id: string;
35
+ name: string;
36
+ email: string;
37
+ isActive: boolean;
38
+ isProtected?: boolean;
39
+ permissionCeilingRoleId?: string | null;
40
+ workspaceIds: string[];
41
+ }
42
+
43
+ function fakeDb(users: FakeUser[]) {
44
+ const calls: { fn: string; args: any }[] = [];
45
+ const members = users.flatMap((u) =>
46
+ u.workspaceIds.map((workspaceId) => ({
47
+ userId: u.id,
48
+ workspaceId,
49
+ isAdmin: false,
50
+ isDefault: false,
51
+ })),
52
+ );
53
+
54
+ const findUser = (id: string) => users.find((u) => u.id === id) ?? null;
55
+
56
+ return {
57
+ calls,
58
+ members,
59
+ user: {
60
+ async findUnique(args: any) {
61
+ const u = findUser(args?.where?.id);
62
+ if (!u) return null;
63
+ return {
64
+ id: u.id,
65
+ isProtected: u.isProtected ?? false,
66
+ permissionCeilingRoleId: u.permissionCeilingRoleId ?? null,
67
+ userWorkspaces: members
68
+ .filter((m) => m.userId === u.id)
69
+ .map((m) => ({ workspaceId: m.workspaceId })),
70
+ };
71
+ },
72
+ async findMany(args: any) {
73
+ calls.push({ fn: "user.findMany", args });
74
+ // Không mô phỏng lại engine Prisma — ca kiểm tra `where` gửi xuống, còn
75
+ // ở đây chỉ cần trả một tập ổn định để handler chạy hết đường.
76
+ return users
77
+ .filter(
78
+ (u) =>
79
+ !members.some(
80
+ (m) => m.userId === u.id && m.workspaceId === "spa-qc",
81
+ ),
82
+ )
83
+ .map((u) => ({
84
+ id: u.id,
85
+ name: u.name,
86
+ email: u.email,
87
+ isActive: u.isActive,
88
+ }));
89
+ },
90
+ },
91
+ userWorkspace: {
92
+ async findMany(args: any) {
93
+ calls.push({ fn: "userWorkspace.findMany", args });
94
+ return members
95
+ .filter((m) => m.workspaceId === args?.where?.workspaceId)
96
+ .map((m) => ({
97
+ isAdmin: m.isAdmin,
98
+ isDefault: m.isDefault,
99
+ user: findUser(m.userId),
100
+ }));
101
+ },
102
+ async upsert(args: any) {
103
+ calls.push({ fn: "userWorkspace.upsert", args });
104
+ const { userId, workspaceId } = args.where.userId_workspaceId;
105
+ const found = members.find(
106
+ (m) => m.userId === userId && m.workspaceId === workspaceId,
107
+ );
108
+ if (found) Object.assign(found, args.update);
109
+ else members.push({ userId, workspaceId, ...args.create });
110
+ return { userId, workspaceId };
111
+ },
112
+ async updateMany(args: any) {
113
+ calls.push({ fn: "userWorkspace.updateMany", args });
114
+ return { count: 0 };
115
+ },
116
+ async deleteMany(args: any) {
117
+ calls.push({ fn: "userWorkspace.deleteMany", args });
118
+ const before = members.length;
119
+ for (let i = members.length - 1; i >= 0; i -= 1) {
120
+ const m = members[i];
121
+ if (
122
+ m.userId === args.where.userId &&
123
+ m.workspaceId === args.where.workspaceId
124
+ ) {
125
+ members.splice(i, 1);
126
+ }
127
+ }
128
+ return { count: before - members.length };
129
+ },
130
+ },
131
+ async $transaction(fn: any) {
132
+ return fn(this);
133
+ },
134
+ };
135
+ }
136
+
137
+ type Session = { userId: string; adminOf: string[]; viewAll?: boolean };
138
+
139
+ /** Quản trị viên phía khách hàng: quản trị nhánh `spa` (và con cháu). */
140
+ const customerAdmin: Session = { userId: "u-spa-admin", adminOf: ["spa"] };
141
+ /** Vận hành Tấn Lộc — toàn quyền, miễn D1–D7. */
142
+ const opsAdmin: Session = { userId: "u-ops", adminOf: [], viewAll: true };
143
+
144
+ function configure(db: ReturnType<typeof fakeDb>) {
145
+ void db;
146
+ configureWorkspaces<Session>({
147
+ canViewAll: (session) => Boolean(session?.viewAll),
148
+ getUserId: (session) => session?.userId ?? null,
149
+ getMemberships: (session) =>
150
+ (session?.adminOf ?? []).map((workspaceId) => ({
151
+ workspaceId,
152
+ isAdmin: true,
153
+ })),
154
+ expandDescendants: async (rootIds) => {
155
+ const prefixes = WORKSPACES.filter((w) => rootIds.includes(w.id)).map(
156
+ (w) => subtreePrefix(w.path),
157
+ );
158
+ return WORKSPACES.filter((w) =>
159
+ prefixes.some((p) => w.path.startsWith(p)),
160
+ ).map((w) => w.id);
161
+ },
162
+ });
163
+ }
164
+
165
+ const deps = (db: any, session: Session | null) => ({
166
+ prisma: db,
167
+ getSession: () => session,
168
+ canManageAll: (s: Session) => Boolean(s?.viewAll),
169
+ });
170
+
171
+ const ctx = (id: string) => ({ params: { id } });
172
+
173
+ const postReq = (body: unknown) =>
174
+ new Request("http://test/api/workspaces/x/members", {
175
+ method: "POST",
176
+ body: JSON.stringify(body),
177
+ });
178
+
179
+ const deleteReq = (userId: string) =>
180
+ new Request(`http://test/api/workspaces/x/members?userId=${userId}`, {
181
+ method: "DELETE",
182
+ });
183
+
184
+ const baseUsers = (): FakeUser[] => [
185
+ {
186
+ id: "u-qc",
187
+ name: "Nhân viên QC",
188
+ email: "qc@spa.vn",
189
+ isActive: true,
190
+ workspaceIds: ["spa"],
191
+ },
192
+ {
193
+ id: "u-tanloc",
194
+ name: "Nhân viên Tấn Lộc",
195
+ email: "nv@tanloc.vn",
196
+ isActive: true,
197
+ workspaceIds: ["tanloc"],
198
+ },
199
+ {
200
+ id: "u-orphan",
201
+ name: "Chưa gán",
202
+ email: "orphan@x.vn",
203
+ isActive: true,
204
+ workspaceIds: [],
205
+ },
206
+ {
207
+ id: "u-spa-admin",
208
+ name: "Admin Spartronics",
209
+ email: "admin@spa.vn",
210
+ isActive: true,
211
+ workspaceIds: ["spa"],
212
+ },
213
+ ];
214
+
215
+ describe("gán người vào không gian — đường hợp lệ", () => {
216
+ let db: ReturnType<typeof fakeDb>;
217
+ beforeEach(() => {
218
+ db = fakeDb(baseUsers());
219
+ configure(db);
220
+ });
221
+
222
+ it("admin nhánh gán được người của chính nhánh mình xuống phòng ban con", async () => {
223
+ const { POST } = createWorkspaceMemberHandlers(
224
+ deps(db, customerAdmin) as any,
225
+ );
226
+ const res = await POST(postReq({ userId: "u-qc" }), ctx("spa-qc"));
227
+
228
+ expect(res.status).toBe(201);
229
+ expect(
230
+ db.members.some((m) => m.userId === "u-qc" && m.workspaceId === "spa-qc"),
231
+ ).toBe(true);
232
+ });
233
+
234
+ it("đặt cờ mặc định thì HẠ cờ mặc định cũ — mỗi người chỉ một không gian mặc định", async () => {
235
+ const { POST } = createWorkspaceMemberHandlers(
236
+ deps(db, customerAdmin) as any,
237
+ );
238
+ await POST(postReq({ userId: "u-qc", isDefault: true }), ctx("spa-qc"));
239
+
240
+ const cleared = db.calls.find((c) => c.fn === "userWorkspace.updateMany");
241
+ expect(cleared).toBeDefined();
242
+ expect(cleared!.args.where).toMatchObject({
243
+ userId: "u-qc",
244
+ isDefault: true,
245
+ NOT: { workspaceId: "spa-qc" },
246
+ });
247
+ });
248
+
249
+ it("gán lại người đã ở trong = đổi cờ, không đẻ bản ghi trùng", async () => {
250
+ const { POST } = createWorkspaceMemberHandlers(
251
+ deps(db, customerAdmin) as any,
252
+ );
253
+ const res = await POST(
254
+ postReq({ userId: "u-qc", isAdmin: true }),
255
+ ctx("spa"),
256
+ );
257
+
258
+ expect(res.status).toBe(200);
259
+ expect(
260
+ db.members.filter((m) => m.userId === "u-qc" && m.workspaceId === "spa"),
261
+ ).toHaveLength(1);
262
+ expect(
263
+ db.members.find((m) => m.userId === "u-qc" && m.workspaceId === "spa")
264
+ ?.isAdmin,
265
+ ).toBe(true);
266
+ });
267
+ });
268
+
269
+ describe("gán người vào không gian — ba đường leo thang", () => {
270
+ let db: ReturnType<typeof fakeDb>;
271
+ beforeEach(() => {
272
+ db = fakeDb(baseUsers());
273
+ configure(db);
274
+ });
275
+
276
+ it("D1 — không gán được vào nhánh ngoài phạm vi quản trị", async () => {
277
+ const { POST } = createWorkspaceMemberHandlers(
278
+ deps(db, customerAdmin) as any,
279
+ );
280
+ const res = await POST(postReq({ userId: "u-qc" }), ctx("tanloc"));
281
+
282
+ expect(res.status).toBe(403);
283
+ expect(
284
+ db.members.some((m) => m.workspaceId === "tanloc" && m.userId === "u-qc"),
285
+ ).toBe(false);
286
+ });
287
+
288
+ it("D5 — không hút được người của tenant khác về nhánh mình", async () => {
289
+ const { POST } = createWorkspaceMemberHandlers(
290
+ deps(db, customerAdmin) as any,
291
+ );
292
+ const res = await POST(postReq({ userId: "u-tanloc" }), ctx("spa"));
293
+
294
+ expect(res.status).toBe(403);
295
+ expect(await res.json()).toMatchObject({ code: "D5_NOT_FULLY_OWNED" });
296
+ });
297
+
298
+ it("D5 — người chưa thuộc không gian nào thì admin nhánh KHÔNG tự nhận về", async () => {
299
+ const { POST } = createWorkspaceMemberHandlers(
300
+ deps(db, customerAdmin) as any,
301
+ );
302
+ const res = await POST(postReq({ userId: "u-orphan" }), ctx("spa"));
303
+
304
+ expect(res.status).toBe(403);
305
+ expect(await res.json()).toMatchObject({ code: "D5_NOT_FULLY_OWNED" });
306
+ });
307
+
308
+ it("nhưng vận hành toàn quyền thì gán được người chưa thuộc đâu cả", async () => {
309
+ const { POST } = createWorkspaceMemberHandlers(deps(db, opsAdmin) as any);
310
+ const res = await POST(postReq({ userId: "u-orphan" }), ctx("spa"));
311
+
312
+ expect(res.status).toBe(201);
313
+ });
314
+
315
+ it("D3 — không tự sửa membership của chính mình", async () => {
316
+ const { POST } = createWorkspaceMemberHandlers(
317
+ deps(db, customerAdmin) as any,
318
+ );
319
+ const res = await POST(
320
+ postReq({ userId: "u-spa-admin", isAdmin: true }),
321
+ ctx("spa-qc"),
322
+ );
323
+
324
+ expect(res.status).toBe(403);
325
+ expect(await res.json()).toMatchObject({ code: "D3_SELF_ESCALATION" });
326
+ });
327
+ });
328
+
329
+ describe("gỡ người khỏi không gian", () => {
330
+ let db: ReturnType<typeof fakeDb>;
331
+ beforeEach(() => {
332
+ db = fakeDb(baseUsers());
333
+ configure(db);
334
+ });
335
+
336
+ it("chặn gỡ không gian CUỐI CÙNG — gỡ xong thì chính mình cũng hết đụng được", async () => {
337
+ const { DELETE } = createWorkspaceMemberHandlers(
338
+ deps(db, customerAdmin) as any,
339
+ );
340
+ const res = await DELETE(deleteReq("u-qc"), ctx("spa"));
341
+
342
+ expect(res.status).toBe(400);
343
+ expect(db.members.some((m) => m.userId === "u-qc")).toBe(true);
344
+ });
345
+
346
+ it("gỡ được khi người đó còn không gian khác trong phạm vi", async () => {
347
+ const { POST, DELETE } = createWorkspaceMemberHandlers(
348
+ deps(db, customerAdmin) as any,
349
+ );
350
+ await POST(postReq({ userId: "u-qc" }), ctx("spa-qc"));
351
+ const res = await DELETE(deleteReq("u-qc"), ctx("spa"));
352
+
353
+ expect(res.status).toBe(200);
354
+ expect(
355
+ db.members.some((m) => m.userId === "u-qc" && m.workspaceId === "spa"),
356
+ ).toBe(false);
357
+ });
358
+
359
+ it("không gỡ được người thuộc nhánh ngoài phạm vi", async () => {
360
+ const { DELETE } = createWorkspaceMemberHandlers(
361
+ deps(db, customerAdmin) as any,
362
+ );
363
+ const res = await DELETE(deleteReq("u-tanloc"), ctx("tanloc"));
364
+
365
+ expect(res.status).toBe(403);
366
+ expect(db.members.some((m) => m.userId === "u-tanloc")).toBe(true);
367
+ });
368
+ });
369
+
370
+ describe("tìm ứng viên để thêm", () => {
371
+ let db: ReturnType<typeof fakeDb>;
372
+ beforeEach(() => {
373
+ db = fakeDb(baseUsers());
374
+ configure(db);
375
+ });
376
+
377
+ it("chỉ tìm trong nhánh QUẢN TRỊ được, và loại người đã ở trong", async () => {
378
+ const { GET } = createWorkspaceMemberHandlers(
379
+ deps(db, customerAdmin) as any,
380
+ );
381
+ const res = await GET(
382
+ new Request("http://test/api/workspaces/x/members?candidates=1&q=nh"),
383
+ ctx("spa-qc"),
384
+ );
385
+ expect(res.status).toBe(200);
386
+
387
+ const call = db.calls.find((c) => c.fn === "user.findMany");
388
+ const and = call!.args.where.AND;
389
+ // Ràng buộc phạm vi phải bám `adminIds` (spa + spa-qc), KHÔNG phải allowedIds.
390
+ expect(JSON.stringify(and[0])).toContain("spa-qc");
391
+ expect(JSON.stringify(and[0])).not.toContain("tanloc");
392
+ expect(and[1]).toMatchObject({
393
+ userWorkspaces: { none: { workspaceId: "spa-qc" } },
394
+ });
395
+ });
396
+
397
+ it("người thường (không quản trị nhánh nào) không tìm được ứng viên", async () => {
398
+ const plain: Session = { userId: "u-plain", adminOf: [] };
399
+ const { GET } = createWorkspaceMemberHandlers(deps(db, plain) as any);
400
+ const res = await GET(
401
+ new Request("http://test/api/workspaces/x/members?candidates=1"),
402
+ ctx("spa"),
403
+ );
404
+
405
+ expect(res.status).toBe(403);
406
+ });
407
+ });
@@ -121,6 +121,15 @@ function fakeDb() {
121
121
  return rows.filter((r) => r.parentId === args?.where?.parentId).length;
122
122
  },
123
123
  },
124
+ // GET đếm quản trị bằng `groupBy` riêng: `_count` của Prisma không lọc được
125
+ // theo cờ `isAdmin`. Chỉ `spa` có một quản trị — để test phân biệt được
126
+ // "đếm thật" với "trả 0 cho mọi nút".
127
+ userWorkspace: {
128
+ async groupBy(args: any) {
129
+ calls.push({ fn: "userWorkspace.groupBy", args });
130
+ return [{ workspaceId: "spa", _count: { _all: 1 } }];
131
+ },
132
+ },
124
133
  };
125
134
  }
126
135
 
@@ -214,6 +223,32 @@ describe("workspace route handlers — GET", () => {
214
223
  ]);
215
224
  });
216
225
 
226
+ /**
227
+ * Cây tải lại sau mỗi lần thêm/sửa phải mang ĐỦ những gì trang server render
228
+ * đã có. Thiếu `kindLabel` thì huy hiệu cấp lặng lẽ biến mất, thiếu
229
+ * `adminCount` thì cột "quản trị" tụt về 0 — cả hai đều không đỏ ở đâu cả,
230
+ * người dùng chỉ thấy số tự nhiên đổi sau một thao tác không liên quan.
231
+ */
232
+ it("trả kèm kindLabel và adminCount cho cây tải lại", async () => {
233
+ const { GET } = createWorkspaceCollectionHandlers({
234
+ ...(deps(db, opsAdmin) as any),
235
+ kinds: [
236
+ { key: "unit", label: "Đơn vị" },
237
+ { key: "department", label: "Bộ phận" },
238
+ ],
239
+ });
240
+ const tree = await (
241
+ await GET(new Request("http://test/api/workspaces"))
242
+ ).json();
243
+
244
+ const spa = tree[0].children.find((c: any) => c.id === "spa");
245
+ expect(spa.kindLabel).toBe("Đơn vị");
246
+ expect(spa.adminCount).toBe(1);
247
+ // Nút không có quản trị nào phải là 0, không phải `undefined`.
248
+ expect(spa.children[0].adminCount).toBe(0);
249
+ expect(spa.children[0].kindLabel).toBe("Bộ phận");
250
+ });
251
+
217
252
  it("chưa đăng nhập → 401, không chạm DB", async () => {
218
253
  const { GET } = createWorkspaceCollectionHandlers(deps(db, null) as any);
219
254
  const res = await GET(new Request("http://test/api/workspaces"));
@@ -199,7 +199,7 @@ describe("createWorkspace", () => {
199
199
  kind: "department",
200
200
  parentId: "spa-qc",
201
201
  }),
202
- ).rejects.toThrow(/không được có không gian con/);
202
+ ).rejects.toThrow(/không được có workspace con/);
203
203
  });
204
204
 
205
205
  it("kind ngoài childKinds của cha thì chặn", async () => {
@@ -22,8 +22,8 @@ import type { ScopeLevel } from "../types";
22
22
  const HINTS: Record<ScopeLevel, string> = {
23
23
  none: "Không thấy gì",
24
24
  own: "Chỉ bản ghi do chính mình tạo",
25
- workspace: "Đúng không gian được gán",
26
- subtree: "Không gian được gán và cấp dưới",
25
+ workspace: "Đúng workspace được gán",
26
+ subtree: "Workspace được gán và cấp dưới",
27
27
  all: "Toàn hệ thống",
28
28
  };
29
29
 
@@ -35,14 +35,14 @@ export interface ScopeLevelSelectProps {
35
35
  * dropdown thay vì để họ chọn rồi server mới từ chối.
36
36
  */
37
37
  maxLevel?: ScopeLevel;
38
- /** Nhãn thay cho "Không gian" theo app: "Chi nhánh", "Đơn vị", "Phòng ban". */
38
+ /** Nhãn thay cho "Workspace" theo app: "Chi nhánh", "Đơn vị", "Phòng ban". */
39
39
  kindLabel?: string;
40
40
  disabled?: boolean;
41
41
  className?: string;
42
42
  id?: string;
43
43
  }
44
44
 
45
- /** Nhãn nấc, đã thay chữ "Không gian" bằng nhãn của app. */
45
+ /** Nhãn nấc, đã thay chữ "Workspace" bằng nhãn của app. */
46
46
  export function scopeLevelLabel(level: ScopeLevel, kindLabel?: string): string {
47
47
  const base = SCOPE_LEVEL_LABELS[level];
48
48
  if (!kindLabel) return base;