@goplusvn/core 0.1.56 → 0.1.58

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,280 @@
1
+ import { mkdir, readFile, rm, stat, writeFile, readdir } from "fs/promises";
2
+ import { dirname, join, sep } from "path";
3
+
4
+ import { assertSafeKey } from "./keys";
5
+ import {
6
+ StorageNotFoundError,
7
+ isNotFoundError,
8
+ type StorageConfigInput,
9
+ type StorageDriver,
10
+ type StorageObjectInfo,
11
+ type StorageRemoteConfig,
12
+ } from "./types";
13
+
14
+ /**
15
+ * Kho tập tin dùng chung của goerp.
16
+ *
17
+ * HAI CHẾ ĐỘ, chọn theo runtime chứ không theo build:
18
+ * • `remote` — S3/MinIO, khi `STORAGE_TYPE=s3` trong `system_configs` VÀ app
19
+ * có cắm driver (`@goerp/core/storage/s3`).
20
+ * • `local` — thư mục đĩa PRIVATE (mặc định `<cwd>/storage/files`). Đây là
21
+ * mặc định để app mới chạy được ngay, chưa cần dựng MinIO.
22
+ *
23
+ * Cấu hình nằm trong DB (bảng `system_configs`) chứ không phải env: admin đổi
24
+ * endpoint trong màn hình cấu hình rồi `clearCache()` là ăn ngay, không cần
25
+ * deploy lại. App cắm phụ thuộc một lần ở composition root:
26
+ *
27
+ * configureStorage({ db, driver: createS3Driver() })
28
+ */
29
+
30
+ const CONFIG_KEYS = [
31
+ "STORAGE_TYPE",
32
+ "S3_ENDPOINT",
33
+ "S3_PUBLIC_ENDPOINT",
34
+ "S3_REGION",
35
+ "S3_BUCKET",
36
+ "S3_ACCESS_KEY",
37
+ "S3_SECRET_KEY",
38
+ "S3_REJECT_UNAUTHORIZED",
39
+ ] as const;
40
+
41
+ let configured: StorageConfigInput | null = null;
42
+ let cachedRemote: StorageRemoteConfig | null = null;
43
+ let cacheLoaded = false;
44
+
45
+ export function configureStorage(input: StorageConfigInput): void {
46
+ configured = input;
47
+ clearStorageCache();
48
+ }
49
+
50
+ /** Quên cấu hình đã đọc từ DB — gọi sau khi admin sửa S3_*. */
51
+ export function clearStorageCache(): void {
52
+ cachedRemote = null;
53
+ cacheLoaded = false;
54
+ }
55
+
56
+ function requireConfigured(): StorageConfigInput {
57
+ if (!configured) {
58
+ throw new Error(
59
+ "[storage] chưa configureStorage({ db, driver? }) — gọi 1 lần lúc khởi tạo app (cạnh configureSettingsService).",
60
+ );
61
+ }
62
+ return configured;
63
+ }
64
+
65
+ function localRoot(): string {
66
+ return configured?.localDir ?? join(process.cwd(), "storage", "files");
67
+ }
68
+
69
+ /** Giá trị trong `system_configs` có thể là JSON string hoặc chuỗi thô. */
70
+ function parseValue(raw: string | null): string {
71
+ if (raw == null) return "";
72
+ try {
73
+ const parsed = JSON.parse(raw);
74
+ return typeof parsed === "string" ? parsed : String(parsed);
75
+ } catch {
76
+ return raw;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Đọc cấu hình S3 từ DB (memo hoá). Trả `null` khi chưa bật hoặc thiếu trường
82
+ * bắt buộc — engine tự rơi về chế độ local thay vì ném lỗi giữa request.
83
+ */
84
+ export async function getRemoteConfig(): Promise<StorageRemoteConfig | null> {
85
+ if (cacheLoaded) return cachedRemote;
86
+
87
+ const { db, driver } = requireConfigured();
88
+ cacheLoaded = true;
89
+ cachedRemote = null;
90
+
91
+ // Không có driver thì đọc config cũng vô nghĩa — khỏi phải hỏi DB.
92
+ if (!driver) return null;
93
+
94
+ const rows = await db.systemConfig.findMany({
95
+ where: { key: { in: [...CONFIG_KEYS] } },
96
+ select: { key: true, value: true },
97
+ });
98
+
99
+ const map: Record<string, string> = {};
100
+ for (const row of rows) map[row.key] = parseValue(row.value);
101
+
102
+ if ((map.STORAGE_TYPE || "").toLowerCase() !== "s3") return null;
103
+
104
+ const endpoint = map.S3_ENDPOINT || "";
105
+ const bucket = map.S3_BUCKET || "";
106
+ const accessKey = map.S3_ACCESS_KEY || "";
107
+ const secretKey = map.S3_SECRET_KEY || "";
108
+ if (!endpoint || !bucket || !accessKey || !secretKey) {
109
+ console.warn(
110
+ "[storage] STORAGE_TYPE=s3 nhưng thiếu S3_ENDPOINT/S3_BUCKET/S3_ACCESS_KEY/S3_SECRET_KEY — tạm dùng đĩa local.",
111
+ );
112
+ return null;
113
+ }
114
+
115
+ cachedRemote = {
116
+ endpoint,
117
+ publicEndpoint: map.S3_PUBLIC_ENDPOINT || endpoint,
118
+ region: map.S3_REGION || "us-east-1",
119
+ bucket,
120
+ accessKey,
121
+ secretKey,
122
+ rejectUnauthorized: map.S3_REJECT_UNAUTHORIZED === "true",
123
+ };
124
+ return cachedRemote;
125
+ }
126
+
127
+ /** Có đang chạy trên kho từ xa không (false = đĩa local). */
128
+ export async function isRemoteStorageEnabled(): Promise<boolean> {
129
+ return (await getRemoteConfig()) !== null;
130
+ }
131
+
132
+ async function withRemote<T>(
133
+ fn: (driver: StorageDriver, config: StorageRemoteConfig) => Promise<T>,
134
+ ): Promise<T | null> {
135
+ const config = await getRemoteConfig();
136
+ if (!config) return null;
137
+ return fn(requireConfigured().driver as StorageDriver, config);
138
+ }
139
+
140
+ /**
141
+ * Chặn cửa hậu "im lặng rơi về local" ở app đã chạy S3: ghi thành công vào đĩa
142
+ * container = mất tập tin ở lần deploy sau, tệ hơn hẳn một lỗi ồn ào.
143
+ */
144
+ function assertLocalAllowed(): void {
145
+ if (configured?.requireRemote) {
146
+ throw new Error(
147
+ "[storage] requireRemote=true nhưng kho S3 chưa sẵn sàng — kiểm tra STORAGE_TYPE/S3_* trong system_configs. Từ chối dùng đĩa local.",
148
+ );
149
+ }
150
+ }
151
+
152
+ function localPath(key: string): string {
153
+ assertLocalAllowed();
154
+ return join(localRoot(), ...assertSafeKey(key).split("/"));
155
+ }
156
+
157
+ /** Lưu tập tin. Trả về chính `key` để nơi gọi ghép URL. */
158
+ export async function putFile(
159
+ key: string,
160
+ body: Buffer,
161
+ contentType = "application/octet-stream",
162
+ ): Promise<string> {
163
+ assertSafeKey(key);
164
+ const done = await withRemote((driver, config) =>
165
+ driver.put(config, key, body, contentType),
166
+ );
167
+ if (done === null) {
168
+ const path = localPath(key);
169
+ await mkdir(dirname(path), { recursive: true });
170
+ await writeFile(path, body);
171
+ }
172
+ return key;
173
+ }
174
+
175
+ /** Đọc tập tin. Không có → ném `StorageNotFoundError`. */
176
+ export async function getFile(key: string): Promise<Buffer> {
177
+ assertSafeKey(key);
178
+ const config = await getRemoteConfig();
179
+ if (config) {
180
+ try {
181
+ return await (requireConfigured().driver as StorageDriver).get(
182
+ config,
183
+ key,
184
+ );
185
+ } catch (error) {
186
+ if (isNotFoundError(error)) throw new StorageNotFoundError(key);
187
+ throw error;
188
+ }
189
+ }
190
+ try {
191
+ return await readFile(localPath(key));
192
+ } catch (error) {
193
+ if (isNotFoundError(error)) throw new StorageNotFoundError(key);
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ /** Xoá tập tin. Không có sẵn cũng coi như thành công (idempotent). */
199
+ export async function deleteFile(key: string): Promise<void> {
200
+ assertSafeKey(key);
201
+ const done = await withRemote((driver, config) => driver.remove(config, key));
202
+ if (done === null) {
203
+ await rm(localPath(key), { force: true });
204
+ }
205
+ }
206
+
207
+ /**
208
+ * URL có chữ ký để trình duyệt tải thẳng từ kho. Chế độ local không ký được →
209
+ * trả `null`, nơi gọi rơi về proxy `/api/files/<key>`.
210
+ */
211
+ export async function getPresignedUrl(
212
+ key: string,
213
+ expiresIn = 3600,
214
+ ): Promise<string | null> {
215
+ assertSafeKey(key);
216
+ return withRemote((driver, config) => driver.presign(config, key, expiresIn));
217
+ }
218
+
219
+ /**
220
+ * URL công khai của object. Chế độ local (hoặc MinIO nội bộ) không có URL trực
221
+ * tiếp cho trình duyệt → dùng proxy của app.
222
+ */
223
+ export async function getPublicUrl(
224
+ key: string,
225
+ proxyPrefix = "/api/files",
226
+ ): Promise<string> {
227
+ assertSafeKey(key);
228
+ const config = await getRemoteConfig();
229
+ if (!config) return `${proxyPrefix}/${key}`;
230
+ return `${config.publicEndpoint}/${config.bucket}/${key}`;
231
+ }
232
+
233
+ /** Liệt kê object theo tiền tố — dùng cho backup/dọn rác. */
234
+ export async function listObjects(
235
+ prefix: string,
236
+ ): Promise<StorageObjectInfo[]> {
237
+ const config = await getRemoteConfig();
238
+ if (config) {
239
+ return (requireConfigured().driver as StorageDriver).list(config, prefix);
240
+ }
241
+
242
+ assertLocalAllowed();
243
+ const root = localRoot();
244
+ const results: StorageObjectInfo[] = [];
245
+ const walk = async (dir: string): Promise<void> => {
246
+ let entries;
247
+ try {
248
+ entries = await readdir(dir, { withFileTypes: true });
249
+ } catch (error) {
250
+ if (isNotFoundError(error)) return;
251
+ throw error;
252
+ }
253
+ for (const entry of entries) {
254
+ const full = join(dir, entry.name);
255
+ if (entry.isDirectory()) {
256
+ await walk(full);
257
+ continue;
258
+ }
259
+ const key = full.slice(root.length + 1).split(sep).join("/");
260
+ if (!key.startsWith(prefix)) continue;
261
+ const info = await stat(full);
262
+ results.push({ key, size: info.size, lastModified: info.mtime });
263
+ }
264
+ };
265
+ await walk(root);
266
+ return results;
267
+ }
268
+
269
+ /** Gom lại thành facade cho nơi gọi thích gọi kiểu `storage.getFile(...)`. */
270
+ export const storage = {
271
+ clearCache: clearStorageCache,
272
+ getRemoteConfig,
273
+ isRemoteEnabled: isRemoteStorageEnabled,
274
+ put: putFile,
275
+ get: getFile,
276
+ delete: deleteFile,
277
+ getPresignedUrl,
278
+ getPublicUrl,
279
+ list: listObjects,
280
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Kho tập tin — kiểu dùng chung cho engine, driver và các route factory.
3
+ *
4
+ * Core KHÔNG phụ thuộc aws-sdk: driver S3/MinIO nằm ở subpath riêng
5
+ * `@goerp/core/storage/s3` và được app cắm vào qua `configureStorage`. App nào
6
+ * chỉ cần đĩa local thì không phải cài thêm gói nào.
7
+ */
8
+
9
+ /** Cấu hình S3/MinIO đọc từ bảng `system_configs`. */
10
+ export interface StorageRemoteConfig {
11
+ /** Endpoint NỘI BỘ — dùng cho upload/get/delete từ phía server. */
12
+ endpoint: string;
13
+ /**
14
+ * Endpoint CÔNG KHAI — chỉ dùng khi ký presigned URL để trình duyệt gọi
15
+ * thẳng. Khác endpoint nội bộ thì chữ ký phải ký bằng cái này, nếu không
16
+ * URL trả về trỏ vào host mà trình duyệt không thấy.
17
+ */
18
+ publicEndpoint: string;
19
+ region: string;
20
+ bucket: string;
21
+ accessKey: string;
22
+ secretKey: string;
23
+ /**
24
+ * Verify chứng chỉ TLS của endpoint. MẶC ĐỊNH `false` vì MinIO self-hosted
25
+ * thường dùng cert tự ký — bật `true` (key `S3_REJECT_UNAUTHORIZED`) khi
26
+ * endpoint có cert hợp lệ.
27
+ */
28
+ rejectUnauthorized: boolean;
29
+ }
30
+
31
+ export interface StorageObjectInfo {
32
+ key: string;
33
+ size: number;
34
+ lastModified?: Date;
35
+ }
36
+
37
+ /**
38
+ * Driver kho từ xa. Nhận config theo từng lời gọi (thay vì giữ state) để
39
+ * `clearCache()` của engine đổi config là ăn ngay — driver tự memo client theo
40
+ * dấu vân tay config.
41
+ */
42
+ export interface StorageDriver {
43
+ put(
44
+ config: StorageRemoteConfig,
45
+ key: string,
46
+ body: Buffer,
47
+ contentType: string,
48
+ ): Promise<void>;
49
+ get(config: StorageRemoteConfig, key: string): Promise<Buffer>;
50
+ remove(config: StorageRemoteConfig, key: string): Promise<void>;
51
+ presign(
52
+ config: StorageRemoteConfig,
53
+ key: string,
54
+ expiresIn: number,
55
+ ): Promise<string>;
56
+ list(
57
+ config: StorageRemoteConfig,
58
+ prefix: string,
59
+ ): Promise<StorageObjectInfo[]>;
60
+ }
61
+
62
+ /**
63
+ * Delegate Prisma tối thiểu — args để `any` CÓ CHỦ ĐÍCH (cùng bài học
64
+ * SettingsDb/TaskDb: generated types của mỗi app hẹp hơn nên khai chặt sẽ
65
+ * không assignable).
66
+ */
67
+ export interface StorageDb {
68
+ systemConfig: {
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ findMany(args: any): Promise<{ key: string; value: string | null }[]>;
71
+ };
72
+ }
73
+
74
+ export interface StorageConfigInput {
75
+ db: StorageDb;
76
+ /**
77
+ * Driver kho từ xa. Bỏ trống → luôn dùng đĩa local (`localDir`), kể cả khi
78
+ * `STORAGE_TYPE=s3` trong DB.
79
+ */
80
+ driver?: StorageDriver;
81
+ /** Thư mục fallback khi không có driver / STORAGE_TYPE != s3. */
82
+ localDir?: string;
83
+ /**
84
+ * `true` = CẤM rơi về đĩa local: thiếu/hỏng cấu hình S3 thì ném lỗi thay vì
85
+ * âm thầm ghi vào đĩa container. Bật cho app production đã chạy S3 — mất
86
+ * cấu hình mà vẫn "thành công" nghĩa là tập tin bay theo lần deploy sau.
87
+ */
88
+ requireRemote?: boolean;
89
+ }
90
+
91
+ /** Lỗi "không có key này" — engine và driver đều ném ra dạng này. */
92
+ export class StorageNotFoundError extends Error {
93
+ readonly key: string;
94
+ constructor(key: string) {
95
+ super(`Không tìm thấy tập tin: ${key}`);
96
+ this.name = "StorageNotFoundError";
97
+ this.key = key;
98
+ }
99
+ }
100
+
101
+ /** Nhận diện lỗi 404 từ mọi phía (driver S3 ném NoSuchKey, local ném ENOENT). */
102
+ export function isNotFoundError(error: unknown): boolean {
103
+ if (error instanceof StorageNotFoundError) return true;
104
+ const err = error as { name?: string; code?: string; message?: string } | null;
105
+ if (!err) return false;
106
+ if (err.name === "NoSuchKey" || err.name === "NotFound") return true;
107
+ if (err.code === "ENOENT" || err.code === "NoSuchKey") return true;
108
+ return Boolean(
109
+ err.message?.includes("NoSuchKey") || err.message?.includes("ENOENT"),
110
+ );
111
+ }
@@ -0,0 +1,100 @@
1
+ import type { NextRequest } from "next/server";
2
+
3
+ import { safeFileName } from "./keys";
4
+ import { putFile } from "./storage-service";
5
+
6
+ /**
7
+ * Route factory nhận tập tin tải lên.
8
+ *
9
+ * Trả về URL dạng `/api/files/<key>` cho MỌI chế độ kho (S3 hay đĩa local) —
10
+ * cố ý: URL đã lưu trong cột `attachments` không được đổi nghĩa khi admin bật
11
+ * hay tắt S3. Bản cũ ở app ghi thẳng vào `public/uploads` khi chưa có S3, tức
12
+ * chứng từ tài chính và CCCD nằm trong thư mục web đọc công khai không cần
13
+ * phiên; ở đây đĩa local là thư mục PRIVATE và vẫn phải đi qua proxy có quyền.
14
+ *
15
+ * // src/app/api/upload/route.ts
16
+ * export const POST = apiHandler(createUploadHandler())
17
+ */
18
+
19
+ export interface UploadHandlerOptions {
20
+ /** Mặc định 25MB. */
21
+ maxBytes?: number;
22
+ /** Đuôi cho phép (chữ thường, không dấu chấm). */
23
+ allowedExtensions?: string[];
24
+ /** Tiền tố key trong kho. Mặc định `uploads`. */
25
+ prefix?: string;
26
+ /** Tiền tố URL trả về. Mặc định `/api/files`. */
27
+ urlPrefix?: string;
28
+ onError?: (error: unknown, req: NextRequest) => Response | Promise<Response>;
29
+ }
30
+
31
+ const DEFAULT_ALLOWED = [
32
+ "pdf",
33
+ "jpg",
34
+ "jpeg",
35
+ "png",
36
+ "gif",
37
+ "webp",
38
+ "doc",
39
+ "docx",
40
+ "xls",
41
+ "xlsx",
42
+ "csv",
43
+ "txt",
44
+ ];
45
+
46
+ /** Thư mục theo ngày (YYYYMMDD) để một ngày gom về một chỗ. */
47
+ function dateDir(now: Date): string {
48
+ const y = now.getFullYear();
49
+ const m = String(now.getMonth() + 1).padStart(2, "0");
50
+ const d = String(now.getDate()).padStart(2, "0");
51
+ return `${y}${m}${d}`;
52
+ }
53
+
54
+ export function createUploadHandler(options: UploadHandlerOptions = {}) {
55
+ const maxBytes = options.maxBytes ?? 25 * 1024 * 1024;
56
+ const allowed = new Set(options.allowedExtensions ?? DEFAULT_ALLOWED);
57
+ const prefix = (options.prefix ?? "uploads").replace(/^\/|\/$/g, "");
58
+ const urlPrefix = (options.urlPrefix ?? "/api/files").replace(/\/$/, "");
59
+
60
+ return async function handler(req: NextRequest): Promise<Response> {
61
+ try {
62
+ const formData = await req.formData();
63
+ const file = formData.get("file");
64
+ if (!file || typeof file === "string") {
65
+ return Response.json({ error: "No file uploaded" }, { status: 400 });
66
+ }
67
+
68
+ if (file.size > maxBytes) {
69
+ const mb = Math.round(maxBytes / (1024 * 1024));
70
+ return Response.json(
71
+ { error: `Tập tin quá lớn (tối đa ${mb}MB)` },
72
+ { status: 413 },
73
+ );
74
+ }
75
+
76
+ const safeName = safeFileName(file.name || "file");
77
+ const ext = (safeName.split(".").pop() || "").toLowerCase();
78
+ if (!allowed.has(ext)) {
79
+ return Response.json(
80
+ { error: "Định dạng tập tin không được hỗ trợ" },
81
+ { status: 415 },
82
+ );
83
+ }
84
+
85
+ // Chèn timestamp trước phần mở rộng — cùng tên tải lên hai lần không đè nhau.
86
+ const dot = safeName.lastIndexOf(".");
87
+ const base = dot > 0 ? safeName.slice(0, dot) : safeName;
88
+ const key = `${prefix}/${dateDir(new Date())}/${base}_${Date.now()}.${ext}`;
89
+
90
+ const buffer = Buffer.from(await file.arrayBuffer());
91
+ await putFile(key, buffer, file.type || "application/octet-stream");
92
+
93
+ return Response.json({ url: `${urlPrefix}/${key}`, key });
94
+ } catch (error) {
95
+ if (options.onError) return options.onError(error, req);
96
+ console.error("[storage] upload lỗi:", error);
97
+ return Response.json({ error: "Upload failed" }, { status: 500 });
98
+ }
99
+ };
100
+ }
@@ -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;