@goplusvn/core 0.1.77 → 0.1.79

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.
@@ -0,0 +1,330 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ // `Role.landingPath` là cột BỔ SUNG: chỉ app nào đã migrate mới có. Hai app
3
+ // đang chạy thật dùng chung core này, nên handler phải tự dò và im lặng bỏ qua
4
+ // — ghi bừa một cột không tồn tại là 500 ở mọi lần lưu vai trò.
5
+ //
6
+ // Đây cũng là chỗ chặn đường dẫn ra ngoài: `landingPath` là nơi người dùng bị
7
+ // đưa tới ngay sau khi đăng nhập, để lọt "//host" là mở đường phishing.
8
+
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ createRoleItemHandlers,
13
+ createRolesCollectionHandlers,
14
+ } from "../route-handlers";
15
+
16
+ function fakeDb(opts: { withLandingPath: boolean }) {
17
+ const existing = {
18
+ id: "r1",
19
+ code: "staff",
20
+ name: "Nhân viên",
21
+ description: "",
22
+ status: "active",
23
+ landingPath: null as string | null,
24
+ rolePermissions: [],
25
+ };
26
+ const created: any[] = [];
27
+ const updated: any[] = [];
28
+
29
+ const tx = {
30
+ role: {
31
+ create: async ({ data }: any) => {
32
+ created.push(data);
33
+ return { ...existing, ...data };
34
+ },
35
+ update: async ({ data }: any) => {
36
+ updated.push(data);
37
+ return { ...existing, ...data };
38
+ },
39
+ },
40
+ rolePermission: {
41
+ deleteMany: async () => undefined,
42
+ createMany: async () => undefined,
43
+ },
44
+ };
45
+
46
+ const role: any = { findUnique: async () => existing };
47
+ // Prisma sinh sẵn `model.fields` — chỉ có khi schema thật khai cột đó.
48
+ if (opts.withLandingPath) role.fields = { landingPath: { name: "landingPath" } };
49
+
50
+ const prisma: any = {
51
+ role,
52
+ systemConfig: { upsert: async () => undefined },
53
+ $transaction: async (fn: any) => fn(tx),
54
+ };
55
+
56
+ const deps = {
57
+ prisma,
58
+ getSession: async () => ({ user: { id: "u1" } }),
59
+ getCrudPermissions: async () => ({
60
+ read: true,
61
+ create: true,
62
+ update: true,
63
+ delete: true,
64
+ }),
65
+ };
66
+
67
+ return { deps, created, updated };
68
+ }
69
+
70
+ const post = (body: unknown) =>
71
+ new Request("http://app.test/api/roles", {
72
+ method: "POST",
73
+ body: JSON.stringify(body),
74
+ });
75
+
76
+ const put = (body: unknown) =>
77
+ new Request("http://app.test/api/roles/r1", {
78
+ method: "PUT",
79
+ body: JSON.stringify(body),
80
+ });
81
+
82
+ const ctx = { params: Promise.resolve({ id: "r1" }) };
83
+
84
+ describe("roles route handlers — landingPath", () => {
85
+ it("ghi landingPath khi app ĐÃ có cột", async () => {
86
+ const { deps, created } = fakeDb({ withLandingPath: true });
87
+ const res = await createRolesCollectionHandlers(deps as any).POST(
88
+ post({ code: "staff", name: "Nhân viên", landingPath: "/ordering" })
89
+ );
90
+
91
+ expect(res.status).toBe(201);
92
+ expect(created[0].landingPath).toBe("/ordering");
93
+ });
94
+
95
+ it("app CHƯA migrate: không đưa landingPath vào data (không làm 500)", async () => {
96
+ const { deps, created } = fakeDb({ withLandingPath: false });
97
+ const res = await createRolesCollectionHandlers(deps as any).POST(
98
+ post({ code: "staff", name: "Nhân viên", landingPath: "/ordering" })
99
+ );
100
+
101
+ expect(res.status).toBe(201);
102
+ expect(created[0]).not.toHaveProperty("landingPath");
103
+ });
104
+
105
+ it("không gửi khóa landingPath thì KHÔNG đụng giá trị đang có", async () => {
106
+ const { deps, updated } = fakeDb({ withLandingPath: true });
107
+ await createRoleItemHandlers(deps as any).PUT(
108
+ put({ name: "Nhân viên" }),
109
+ ctx as any
110
+ );
111
+
112
+ expect(updated[0]).not.toHaveProperty("landingPath");
113
+ });
114
+
115
+ it("chuỗi rỗng = trả về 'tự suy ra' (null)", async () => {
116
+ const { deps, updated } = fakeDb({ withLandingPath: true });
117
+ await createRoleItemHandlers(deps as any).PUT(
118
+ put({ name: "Nhân viên", landingPath: "" }),
119
+ ctx as any
120
+ );
121
+
122
+ expect(updated[0].landingPath).toBeNull();
123
+ });
124
+
125
+ it.each(["//evil.example/x", "https://evil.example", "javascript:alert(1)", "ordering"])(
126
+ "chặn đường dẫn ra ngoài app: %s",
127
+ async (value) => {
128
+ const { deps, updated } = fakeDb({ withLandingPath: true });
129
+ await createRoleItemHandlers(deps as any).PUT(
130
+ put({ name: "Nhân viên", landingPath: value }),
131
+ ctx as any
132
+ );
133
+
134
+ expect(updated[0].landingPath).toBeNull();
135
+ }
136
+ );
137
+
138
+ it("GET trả landingPath để form sửa prefill được", async () => {
139
+ const { deps } = fakeDb({ withLandingPath: true });
140
+ const res = await createRoleItemHandlers(deps as any).GET(
141
+ new Request("http://app.test/api/roles/r1"),
142
+ ctx as any
143
+ );
144
+
145
+ expect(await res.json()).toMatchObject({ landingPath: null });
146
+ });
147
+ });
148
+
149
+ // ── Uỷ quyền vai trò (app multi-tenant) ────────────────────────────────────
150
+ // Chặn *gán* vai trò mạnh mà để ngỏ đường *tạo* vai trò mạnh là hàng rào có
151
+ // cửa sau: quản trị viên đơn vị chỉ cần tạo "Trợ lý" kèm `role:update`.
152
+
153
+ function tenantDb(role: Partial<Record<string, any>> = {}) {
154
+ const existing = {
155
+ id: "r1",
156
+ code: "to_truong",
157
+ name: "Tổ trưởng",
158
+ description: "",
159
+ status: "active",
160
+ landingPath: null,
161
+ workspaceId: "spa",
162
+ rank: 100,
163
+ isSystem: false,
164
+ rolePermissions: [{ resourceCode: "meal-order", actionCode: "view" }],
165
+ _count: { userRoles: 0 },
166
+ ...role,
167
+ };
168
+ const created: any[] = [];
169
+ const updated: any[] = [];
170
+ const deleted: string[] = [];
171
+
172
+ const tx = {
173
+ role: {
174
+ create: async ({ data }: any) => {
175
+ created.push(data);
176
+ return { ...existing, ...data };
177
+ },
178
+ update: async ({ data }: any) => {
179
+ updated.push(data);
180
+ return { ...existing, ...data };
181
+ },
182
+ delete: async ({ where }: any) => {
183
+ deleted.push(where.id);
184
+ },
185
+ },
186
+ rolePermission: {
187
+ deleteMany: async () => undefined,
188
+ createMany: async () => undefined,
189
+ },
190
+ };
191
+
192
+ const prisma: any = {
193
+ role: {
194
+ findUnique: async () => existing,
195
+ fields: {
196
+ workspaceId: { name: "workspaceId" },
197
+ rank: { name: "rank" },
198
+ isSystem: { name: "isSystem" },
199
+ },
200
+ },
201
+ systemConfig: { upsert: async () => undefined },
202
+ $transaction: async (fn: any) => fn(tx),
203
+ };
204
+
205
+ const actor = {
206
+ userId: "u-spa",
207
+ scope: {
208
+ canViewAll: false,
209
+ rootIds: ["spa"],
210
+ allowedIds: ["spa"],
211
+ adminIds: ["spa"],
212
+ defaultId: "spa",
213
+ },
214
+ canManageAll: false,
215
+ permissions: new Set(["meal-order:view", "meal-order:create"]),
216
+ rank: 50,
217
+ };
218
+
219
+ const deps = {
220
+ prisma,
221
+ getSession: async () => ({ user: { id: "u-spa" } }),
222
+ getCrudPermissions: async () => ({
223
+ read: true,
224
+ create: true,
225
+ update: true,
226
+ delete: true,
227
+ }),
228
+ getDelegationActor: async () => actor,
229
+ };
230
+
231
+ return { deps, actor, created, updated, deleted };
232
+ }
233
+
234
+ describe("roles route handlers — uỷ quyền", () => {
235
+ it("POST đóng dấu đơn vị của người tạo, không để vai trò dùng chung", async () => {
236
+ const { deps, created } = tenantDb();
237
+ const res = await createRolesCollectionHandlers(deps as any).POST(
238
+ post({ code: "ca_truong", name: "Ca trưởng", rank: 90, permissions: ["view:meal-order"] })
239
+ );
240
+
241
+ expect(res.status).toBe(201);
242
+ expect(created[0].workspaceId).toBe("spa");
243
+ expect(created[0].rank).toBe(90);
244
+ });
245
+
246
+ it("POST từ chối quyền quản trị hệ thống (403 kèm mã D4)", async () => {
247
+ const { deps, created } = tenantDb();
248
+ const res = await createRolesCollectionHandlers(deps as any).POST(
249
+ post({ code: "tro_ly", name: "Trợ lý", rank: 90, permissions: ["create:role"] })
250
+ );
251
+
252
+ expect(res.status).toBe(403);
253
+ expect((await res.json()).code).toBe("D4_DANGEROUS_ROLE");
254
+ expect(created).toHaveLength(0);
255
+ });
256
+
257
+ it("POST từ chối quyền vượt trần của chính người tạo", async () => {
258
+ const { deps } = tenantDb();
259
+ const res = await createRolesCollectionHandlers(deps as any).POST(
260
+ post({ code: "kt", name: "Kế toán", rank: 90, permissions: ["view:payroll"] })
261
+ );
262
+
263
+ expect(res.status).toBe(403);
264
+ expect((await res.json()).code).toBe("D2_EXCEEDS_CEILING");
265
+ });
266
+
267
+ it("PUT chặn sửa vai trò DÙNG CHUNG (workspaceId null)", async () => {
268
+ const { deps, updated } = tenantDb({ workspaceId: null });
269
+ const res = await createRoleItemHandlers(deps as any).PUT(
270
+ put({ name: "Đổi tên", permissions: ["view:meal-order"] }),
271
+ ctx as any
272
+ );
273
+
274
+ expect(res.status).toBe(403);
275
+ expect(updated).toHaveLength(0);
276
+ });
277
+
278
+ it("PUT chặn sửa vai trò của đơn vị khác", async () => {
279
+ const { deps } = tenantDb({ workspaceId: "tanloc" });
280
+ const res = await createRoleItemHandlers(deps as any).PUT(
281
+ put({ name: "Đổi tên", permissions: [] }),
282
+ ctx as any
283
+ );
284
+
285
+ expect(res.status).toBe(403);
286
+ });
287
+
288
+ it("PUT sửa vai trò trong nhánh mình thì qua", async () => {
289
+ const { deps, updated } = tenantDb();
290
+ const res = await createRoleItemHandlers(deps as any).PUT(
291
+ put({ name: "Tổ trưởng bếp", permissions: ["view:meal-order"] }),
292
+ ctx as any
293
+ );
294
+
295
+ expect(res.status).toBe(200);
296
+ expect(updated[0].name).toBe("Tổ trưởng bếp");
297
+ });
298
+
299
+ it("DELETE chặn vai trò hệ thống", async () => {
300
+ const { deps, deleted } = tenantDb({ isSystem: true });
301
+ const res = await createRoleItemHandlers(deps as any).DELETE(
302
+ new Request("http://app.test/api/roles/r1", { method: "DELETE" }),
303
+ ctx as any
304
+ );
305
+
306
+ expect(res.status).toBe(403);
307
+ expect(deleted).toHaveLength(0);
308
+ });
309
+
310
+ it("GET vai trò ngoài phạm vi trả 404 chứ không phải 403 (đừng xác nhận nó tồn tại)", async () => {
311
+ const { deps } = tenantDb({ workspaceId: "tanloc" });
312
+ const res = await createRoleItemHandlers(deps as any).GET(
313
+ new Request("http://app.test/api/roles/r1"),
314
+ ctx as any
315
+ );
316
+
317
+ expect(res.status).toBe(404);
318
+ });
319
+
320
+ it("GET vai trò dùng chung: xem được, canManage = false", async () => {
321
+ const { deps } = tenantDb({ workspaceId: null });
322
+ const res = await createRoleItemHandlers(deps as any).GET(
323
+ new Request("http://app.test/api/roles/r1"),
324
+ ctx as any
325
+ );
326
+
327
+ expect(res.status).toBe(200);
328
+ expect(await res.json()).toMatchObject({ canManage: false });
329
+ });
330
+ });
@@ -49,6 +49,14 @@ export const RoleCard = memo(function RoleCard({
49
49
  const router = useRouter();
50
50
  const permissionsCount = role.permissions.length;
51
51
  const roleColor = roleColors[role.code] || "bg-indigo-500";
52
+ // Vai trò dùng chung / vai trò hệ thống / vai trò đơn vị khác: XEM được (để
53
+ // biết mình đang gán gì) nhưng không sửa. `undefined` = app không xét.
54
+ const managed = role.canManage !== false;
55
+ const readOnlyLabel = role.isSystem
56
+ ? "Hệ thống"
57
+ : role.canManage === false && !role.workspaceId
58
+ ? "Dùng chung"
59
+ : null;
52
60
 
53
61
  const handleEdit = useCallback(() => {
54
62
  router.push(`/${lang}/roles/${role.id}/edit`);
@@ -77,21 +85,32 @@ export const RoleCard = memo(function RoleCard({
77
85
  </CardDescription>
78
86
  </div>
79
87
  </div>
80
- {role.status === "active" ? (
81
- <Badge
82
- variant="outline"
83
- className="bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20 text-[10px] px-2 py-0 h-5 font-semibold"
84
- >
85
- Hoạt động
86
- </Badge>
87
- ) : (
88
- <Badge
89
- variant="outline"
90
- className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
91
- >
92
- Khóa
93
- </Badge>
94
- )}
88
+ <div className="flex items-center gap-1.5">
89
+ {readOnlyLabel && (
90
+ <Badge
91
+ variant="outline"
92
+ className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
93
+ title="Vai trò do cấp trên quản lý — bạn xem được nhưng không sửa"
94
+ >
95
+ {readOnlyLabel}
96
+ </Badge>
97
+ )}
98
+ {role.status === "active" ? (
99
+ <Badge
100
+ variant="outline"
101
+ className="bg-emerald-50 text-emerald-600 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20 text-[10px] px-2 py-0 h-5 font-semibold"
102
+ >
103
+ Hoạt động
104
+ </Badge>
105
+ ) : (
106
+ <Badge
107
+ variant="outline"
108
+ className="bg-muted text-muted-foreground border-border text-[10px] px-2 py-0 h-5 font-semibold"
109
+ >
110
+ Khóa
111
+ </Badge>
112
+ )}
113
+ </div>
95
114
  </div>
96
115
  </CardHeader>
97
116
  <CardContent className="p-4 pt-0 space-y-3">
@@ -119,7 +138,7 @@ export const RoleCard = memo(function RoleCard({
119
138
  </div>
120
139
 
121
140
  <div className="flex gap-2 pt-2">
122
- {canUpdate && (
141
+ {canUpdate && managed && (
123
142
  <Button
124
143
  variant="outline"
125
144
  size="sm"
@@ -141,7 +160,7 @@ export const RoleCard = memo(function RoleCard({
141
160
  Chép
142
161
  </Button>
143
162
  )}
144
- {canDelete && (
163
+ {canDelete && managed && (
145
164
  <Button
146
165
  variant="outline"
147
166
  size="sm"
@@ -0,0 +1,140 @@
1
+ import { cache } from "react";
2
+
3
+ import type { NavigationType } from "../types";
4
+ import type { Session } from "../auth";
5
+ import { checkPermission } from "../auth";
6
+
7
+ /**
8
+ * TRANG MỞ ĐẦU của một người — nơi đưa họ tới sau khi đăng nhập, và nơi đá về
9
+ * khi họ mở một trang không có quyền.
10
+ *
11
+ * Mặc định của mọi app là cắm cứng "/" cho tất cả. Hệ quả: người chỉ có một
12
+ * phần việc (đặt cơm, nhận món, chấm công) vẫn bị ném vào bảng số liệu tổng —
13
+ * và khi trang chủ có quyền riêng thì "/" còn là ngõ cụt thật sự với họ.
14
+ *
15
+ * Thứ tự quyết định, dừng ở cái đầu tiên hợp lệ:
16
+ *
17
+ * 1. `landingPath` của vai trò, ưu tiên vai trò MẠNH nhất (`rank` nhỏ nhất) —
18
+ * người kiêm nhiều vai vào trang của vai cao nhất.
19
+ * 2. Mục ĐẦU TIÊN trên menu mà họ mở được, theo đúng thứ tự sidebar.
20
+ * 3. "/" — chịu thua, để trang chủ tự nói là chưa có quyền.
21
+ *
22
+ * Bước 1 LUÔN kiểm tra lại quyền thật. Cấu hình nhập một lần rồi quyền đổi sau
23
+ * đó; một `landingPath` trỏ tới trang người ta không mở được sẽ thành vòng
24
+ * chuyển hướng vô hạn — người dùng chỉ thấy trình duyệt quay mãi.
25
+ */
26
+
27
+ /** Chỉ cần đúng một câu hỏi trên bảng `roles` — app truyền client nào cũng được. */
28
+ export interface LandingPrismaClient {
29
+ role: {
30
+ findMany: (args: any) => Promise<Array<{ landingPath?: string | null }>>;
31
+ // `fields` của Prisma là object sinh sẵn theo model, không có index
32
+ // signature — khai lỏng để client thật của app nào cũng khớp.
33
+ fields?: object;
34
+ };
35
+ }
36
+
37
+ export interface LandingPathDeps {
38
+ prisma: LandingPrismaClient;
39
+ /** Cây menu GỐC của app (chưa lọc quyền) — thứ tự ở đây là thứ tự ưu tiên. */
40
+ navigations: readonly NavigationType[];
41
+ }
42
+
43
+ /**
44
+ * Chuẩn hoá giá trị người dùng nhập/chọn trước khi ghi DB. Chỉ nhận đường dẫn
45
+ * NỘI BỘ; rỗng hoặc bậy = "tự suy ra" (null).
46
+ *
47
+ * "//host" là URL giao thức tương đối — chặn, nếu không cấu hình vai trò thành
48
+ * chỗ đá người dùng sang tên miền lạ ngay sau khi đăng nhập.
49
+ */
50
+ export function normalizeLandingPath(value: unknown): string | null {
51
+ if (typeof value !== "string") return null;
52
+ const path = value.trim();
53
+ if (!path.startsWith("/") || path.startsWith("//")) return null;
54
+ return path;
55
+ }
56
+
57
+ interface Leaf {
58
+ href: string;
59
+ resource?: string;
60
+ action?: string;
61
+ }
62
+
63
+ /** Mọi mục có `href`, phẳng ra theo đúng thứ tự hiển thị trên sidebar. */
64
+ function menuLeaves(navigations: readonly NavigationType[]): Leaf[] {
65
+ const out: Leaf[] = [];
66
+ const walk = (items: readonly unknown[]) => {
67
+ for (const raw of items) {
68
+ const item = raw as {
69
+ href?: string;
70
+ resource?: string;
71
+ action?: string;
72
+ items?: readonly unknown[];
73
+ };
74
+ if (item.items?.length) {
75
+ walk(item.items);
76
+ continue;
77
+ }
78
+ if (item.href) {
79
+ out.push({
80
+ href: item.href,
81
+ resource: item.resource,
82
+ action: item.action,
83
+ });
84
+ }
85
+ }
86
+ };
87
+ for (const group of navigations) walk(group.items ?? []);
88
+ return out;
89
+ }
90
+
91
+ /** Mục không khai `resource` là trang công khai với người đã đăng nhập. */
92
+ function canOpen(session: Session, leaf: Leaf): boolean {
93
+ if (!leaf.resource) return true;
94
+ return checkPermission(session, leaf.resource, leaf.action ?? "view");
95
+ }
96
+
97
+ const withLocale = (locale: string, href: string) =>
98
+ href === "/" ? `/${locale}` : `/${locale}${href}`;
99
+
100
+ /**
101
+ * App nào CHƯA migrate cột `landing_path` thì bỏ qua bước 1, vẫn chạy bình
102
+ * thường bằng mục menu đầu tiên — hỏi cột không tồn tại là Prisma ném thẳng.
103
+ */
104
+ const hasField = (prisma: LandingPrismaClient, field: string) =>
105
+ Boolean((prisma as any)?.role?.fields?.[field]);
106
+
107
+ const hasLandingPath = (prisma: LandingPrismaClient) =>
108
+ hasField(prisma, "landingPath");
109
+
110
+ export function createLandingPathResolver({
111
+ prisma,
112
+ navigations,
113
+ }: LandingPathDeps) {
114
+ return cache(
115
+ async (session: Session | null, locale: string): Promise<string> => {
116
+ if (!session?.user) return `/${locale}/sign-in`;
117
+
118
+ const leaves = menuLeaves(navigations);
119
+ const roleCodes = ((session.user as any).roles ?? []) as string[];
120
+
121
+ if (roleCodes.length > 0 && hasLandingPath(prisma)) {
122
+ const configured = await prisma.role.findMany({
123
+ where: { code: { in: roleCodes }, landingPath: { not: null } },
124
+ select: { landingPath: true },
125
+ // `rank` nhỏ = quyền lực cao. App nào chưa có cột `rank` (vinhhoa) thì
126
+ // bỏ orderBy — sắp theo cột không tồn tại là Prisma ném.
127
+ ...(hasField(prisma, "rank") ? { orderBy: { rank: "asc" } } : {}),
128
+ });
129
+ for (const role of configured) {
130
+ const leaf = leaves.find((l) => l.href === role.landingPath);
131
+ if (leaf && canOpen(session, leaf))
132
+ return withLocale(locale, leaf.href);
133
+ }
134
+ }
135
+
136
+ const first = leaves.find((l) => canOpen(session, l));
137
+ return withLocale(locale, first?.href ?? "/");
138
+ },
139
+ );
140
+ }