@goplusvn/core 0.1.56 → 0.1.57

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.56",
4
+ "version": "0.1.57",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -119,7 +119,6 @@
119
119
  "eslint-plugin-react-hooks": "^7.0.1",
120
120
  "globals": "16.5.0",
121
121
  "jsdom": "^27.2.0",
122
- "next-auth": "4.24.11",
123
122
  "tsup": "^8.5.1",
124
123
  "typescript": "^5.7.3",
125
124
  "typescript-eslint": "^8.50.1",
@@ -0,0 +1,27 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ // Đọc theo cwd chứ không theo import.meta.url: môi trường test là jsdom nên
7
+ // import.meta.url là URL http, fileURLToPath ném "URL must be of scheme file".
8
+ const source = readFileSync(resolve(process.cwd(), "src/auth/proxy-gate.ts"), "utf8")
9
+ // Bỏ chú thích: phần header có ví dụ dùng, trong đó cũng có chữ `import`.
10
+ .replace(/\/\*[\s\S]*?\*\//g, "")
11
+ .replace(/^\s*\/\/.*$/gm, "");
12
+
13
+ describe("proxy-gate không kéo theo thư viện auth nào", () => {
14
+ // Bẫy đã trả giá: proxy-gate từng `await import("next-auth/jwt")` trong nhánh
15
+ // mặc định. Bundler phân giải TĨNH cả dynamic import, nên mọi app Better Auth
16
+ // (không cài next-auth) đều gãy middleware bằng "Module not found" — mà trong
17
+ // workspace này thì không lộ, vì next-auth nằm ở devDependencies của core.
18
+ // Middleware chạy ở Edge: đừng thêm import nào không phải next/server.
19
+ it("chỉ import next/server", () => {
20
+ const specifiers = [
21
+ ...source.matchAll(/(?:^|\s)import\s+(?:type\s+)?[^"']*from\s*["']([^"']+)["']/gm),
22
+ ...source.matchAll(/\bimport\(\s*["']([^"']+)["']\s*\)/g),
23
+ ].map((m) => m[1]);
24
+
25
+ expect([...new Set(specifiers)].sort()).toEqual(["next/server"]);
26
+ });
27
+ });
@@ -1,7 +1,7 @@
1
1
  // @goerp/core/auth/proxy-gate — server-only request-gate for the Next.js
2
2
  // proxy/middleware. Default-DENY authentication (authN); route-level authZ still
3
3
  // happens via getCrudPermissions/checkPermission. Isolated in its own subpath so
4
- // `next/server` + `next-auth/jwt` never leak into client bundles via the auth barrel.
4
+ // `next/server` never leaks into client bundles via the auth barrel.
5
5
  //
6
6
  // Usage (app side):
7
7
  // // src/proxy.ts
@@ -14,7 +14,7 @@ import { NextResponse } from "next/server";
14
14
  import type { NextRequest } from "next/server";
15
15
 
16
16
  export interface AuthProxyOptions {
17
- /** API prefixes served without a session (NextAuth + public). Default: /api/auth, /api/public. */
17
+ /** API prefixes served without a session (auth handler + public). Default: /api/auth, /api/public. */
18
18
  publicApiPrefixes?: string[];
19
19
  /** Pages reachable while logged out. Default: /sign-in. */
20
20
  publicPages?: string[];
@@ -22,33 +22,49 @@ export interface AuthProxyOptions {
22
22
  signInPath?: string;
23
23
  /** Where to send a logged-in user who hits a guest page. Default: "/". */
24
24
  homePath?: string;
25
- /** Override token reader (tests / custom JWT). Default: next-auth getToken. */
25
+ /**
26
+ * Session reader. STRONGLY recommended — pass the one your auth library ships
27
+ * (Better Auth: `getSessionCookie` from "better-auth/cookies"; NextAuth:
28
+ * `getToken` from "next-auth/jwt"). Default: presence of a known session
29
+ * cookie, see SESSION_COOKIE_NAMES.
30
+ */
26
31
  getToken?: (req: NextRequest) => Promise<unknown | null>;
27
32
  }
28
33
 
29
34
  const startsWithAny = (pathname: string, list: string[]) =>
30
35
  list.some((p) => pathname === p || pathname.startsWith(`${p}/`));
31
36
 
37
+ /**
38
+ * Cookie tên gì thì coi như "có phiên" — dùng cho trường hợp app không truyền
39
+ * getToken. Middleware chạy ở Edge nên đây CHỈ là rào authN thô; chữ ký/hạn
40
+ * dùng vẫn do từng route kiểm qua getSession(). Không import next-auth ở đây:
41
+ * bundler phân giải tĩnh cả `await import()`, nên một dòng import next-auth
42
+ * trong nhánh chết cũng đủ làm mọi app Better Auth gãy middleware bằng
43
+ * "Module not found: Can't resolve 'next-auth/jwt'".
44
+ */
45
+ const SESSION_COOKIE_NAMES = [
46
+ "better-auth.session_token",
47
+ "__Secure-better-auth.session_token",
48
+ "next-auth.session-token",
49
+ "__Secure-next-auth.session-token",
50
+ "authjs.session-token",
51
+ "__Secure-authjs.session-token",
52
+ ];
53
+
32
54
  export function createAuthProxy(options: AuthProxyOptions = {}) {
33
55
  const publicApiPrefixes = options.publicApiPrefixes ?? ["/api/auth", "/api/public"];
34
56
  const publicPages = options.publicPages ?? ["/sign-in"];
35
57
  const signInPath = options.signInPath ?? "/sign-in";
36
58
  const homePath = options.homePath ?? "/";
37
- // next-auth chỉ được LAZY-load khi app không truyền getToken riêng — app đã
38
- // sang Better Auth (không cài next-auth) sẽ không dính module-not-found lúc
39
- // import proxy-gate (trước đây import top-level, next-auth lại chỉ nằm ở
40
- // devDependencies của core).
41
59
  const readToken =
42
60
  options.getToken ??
43
- (async (req: NextRequest) => {
44
- const { getToken } = await import("next-auth/jwt");
45
- return getToken({ req });
46
- });
61
+ (async (req: NextRequest) =>
62
+ SESSION_COOKIE_NAMES.some((name) => req.cookies.has(name)) ? { cookie: true } : null);
47
63
 
48
64
  return async function proxy(request: NextRequest) {
49
65
  const { pathname, search } = request.nextUrl;
50
66
 
51
- // API routes that authenticate themselves (NextAuth) or are public → pass.
67
+ // API routes that authenticate themselves (the auth handler) or are public → pass.
52
68
  if (startsWithAny(pathname, publicApiPrefixes)) return NextResponse.next();
53
69
 
54
70
  const token = await readToken(request);
@@ -245,13 +245,13 @@ export function CommandMenu({
245
245
  buttonClassName,
246
246
  )}
247
247
  onClick={() => setOpen(true)}
248
- aria-label={dictionary.search.search}
248
+ aria-label={dictionary?.search?.search ?? "Tìm kiếm"}
249
249
  {...props}
250
250
  >
251
251
  <Search className={cn("h-4 w-4", variant !== "icon" && "me-2")} />
252
252
  {variant !== "icon" && (
253
253
  <>
254
- <span>{dictionary.search.search}</span>
254
+ <span>{dictionary?.search?.search ?? "Tìm kiếm"}</span>
255
255
  {variant !== "fiori" && <Keyboard className="ms-auto">K</Keyboard>}
256
256
  </>
257
257
  )}
@@ -270,7 +270,7 @@ export function CommandMenu({
270
270
  <Search className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 shrink-0 opacity-50" />
271
271
  <input
272
272
  className="flex h-11 w-full rounded-md bg-transparent py-3 pl-10 pr-10 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
273
- placeholder={dictionary.search.typeCommand}
273
+ placeholder={dictionary?.search?.typeCommand ?? "Nhập lệnh hoặc từ khoá…"}
274
274
  value={query}
275
275
  onChange={(e) => setQuery(e.target.value)}
276
276
  autoFocus
@@ -288,7 +288,7 @@ export function CommandMenu({
288
288
  </div>
289
289
  ) : (
290
290
  !searchResults?.length &&
291
- query && <CommandEmpty>{dictionary.search.noResults}</CommandEmpty>
291
+ query && <CommandEmpty>{dictionary?.search?.noResults ?? "Không có kết quả"}</CommandEmpty>
292
292
  )}
293
293
 
294
294
  <ScrollArea className="h-[300px] max-h-[300px]">
@@ -73,10 +73,10 @@ export function NotificationDropdown({
73
73
  <Card className="border-0 shadow-none">
74
74
  <div className="flex items-center justify-between border-b border-border p-3">
75
75
  <h3 className="text-sm font-semibold">
76
- {dictionary.navigation.notifications.notifications}
76
+ {dictionary?.navigation?.notifications?.notifications ?? "Thông báo"}
77
77
  </h3>
78
78
  <Button variant="link" className="text-primary h-auto p-0">
79
- {dictionary.navigation.notifications.dismissAll}
79
+ {dictionary?.navigation?.notifications?.dismissAll ?? "Bỏ qua tất cả"}
80
80
  </Button>
81
81
  </div>
82
82
  <ScrollArea className="max-h-[300px]">
@@ -117,7 +117,7 @@ export function NotificationDropdown({
117
117
  "text-primary text-center",
118
118
  )}
119
119
  >
120
- {dictionary.navigation.notifications.seeAllNotifications}
120
+ {dictionary?.navigation?.notifications?.seeAllNotifications ?? "Xem tất cả thông báo"}
121
121
  </Link>
122
122
  </CardFooter>
123
123
  </Card>
@@ -149,7 +149,7 @@ export function UserDropdown({
149
149
  className="flex items-center w-full"
150
150
  >
151
151
  <User className="me-2 size-4 text-muted-foreground group-hover:text-primary group-focus:text-primary transition-colors" />
152
- <span>{dictionary.navigation.userNav.profile}</span>
152
+ <span>{dictionary?.navigation?.userNav?.profile ?? "Hồ sơ"}</span>
153
153
  </Link>
154
154
  </DropdownMenuItem>
155
155
  {/* <DropdownMenuItem
@@ -161,7 +161,7 @@ export function UserDropdown({
161
161
  className="flex items-center w-full"
162
162
  >
163
163
  <UserCog className="me-2 size-4 text-muted-foreground group-hover:text-primary group-focus:text-primary transition-colors" />
164
- <span>{dictionary.navigation.userNav.settings}</span>
164
+ <span>{dictionary?.navigation?.userNav?.settings ?? "Cài đặt"}</span>
165
165
  </Link>
166
166
  </DropdownMenuItem> */}
167
167
  </DropdownMenuGroup>
@@ -173,7 +173,7 @@ export function UserDropdown({
173
173
  className="h-8 px-2 rounded-md cursor-pointer text-red-600 focus:bg-red-50 focus:text-red-700 dark:text-red-400 dark:focus:bg-red-950/30 dark:focus:text-red-300 transition-colors duration-200 group text-sm"
174
174
  >
175
175
  <LogOut className="me-2 size-4 group-hover:text-red-700 dark:group-hover:text-red-300 transition-colors" />
176
- <span>{dictionary.navigation.userNav.signOut}</span>
176
+ <span>{dictionary?.navigation?.userNav?.signOut ?? "Đăng xuất"}</span>
177
177
  </DropdownMenuItem>
178
178
  </DropdownMenuContent>
179
179
  </DropdownMenu>
@@ -432,14 +432,19 @@ export function generateId(prefix?: string): string {
432
432
  }
433
433
 
434
434
  /**
435
- * Get dictionary value safely
435
+ * Get dictionary value safely.
436
+ *
437
+ * `section` được phép undefined: app mới bắt đầu với `dictionary = {}` (chưa
438
+ * localize gì) thì `dictionary.navigation` là undefined, và trước đây cả shell
439
+ * đổ "Cannot read properties of undefined" — hỏng TOÀN BỘ trang chứ không phải
440
+ * hiện sai một nhãn. Thiếu từ điển thì rơi về chính key.
436
441
  */
437
442
  export function getDictionaryValue(
438
443
  key: string,
439
- section: Record<string, unknown>,
444
+ section: Record<string, unknown> | undefined | null,
440
445
  fallback?: string,
441
446
  ): string {
442
- const value = section[key];
447
+ const value = section?.[key];
443
448
 
444
449
  if (typeof value !== "string") {
445
450
  if (fallback !== undefined) {
@@ -447,7 +452,7 @@ export function getDictionaryValue(
447
452
  }
448
453
 
449
454
  const normalizedKey = key.replace(/[-_]/g, "");
450
- const normalizedValue = section[normalizedKey];
455
+ const normalizedValue = section?.[normalizedKey];
451
456
 
452
457
  if (typeof normalizedValue === "string") {
453
458
  return normalizedValue;