@goplusvn/core 0.1.69 → 0.1.71
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/CHANGELOG.md +54 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +1 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/cron/__tests__/cron-schedule.test.ts +76 -0
- package/src/cron/cron-schedule.ts +128 -0
- package/src/cron/simple-cron-job.ts +83 -69
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/types.ts +17 -8
- package/src/print/print-styles.tsx +4 -1
- package/src/providers/brand-theme.ts +20 -0
- package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
- package/src/security/index.ts +2 -0
- package/src/security/pages/sessions-page.tsx +516 -0
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/errors/error-view.tsx +164 -0
- package/src/ui/errors/index.ts +2 -0
- package/src/ui/errors/not-found-view.tsx +44 -0
- package/src/ui/index.tsx +1 -0
- package/src/ui/layout/logo.tsx +54 -19
- package/src/user/pages/users-client-page.tsx +12 -7
- package/templates/starter-app/.env.example +4 -0
- package/templates/starter-app/README.md +6 -2
- package/templates/starter-app/husky/pre-commit +10 -0
- package/templates/starter-app/package.json +27 -2
- package/templates/starter-app/prisma/schema/organization.prisma +20 -0
- package/templates/starter-app/prisma/schema/system.prisma +74 -0
- package/templates/starter-app/prisma/seed.ts +7 -5
- package/templates/starter-app/scripts/migration-new.mjs +89 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +8 -0
- package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +69 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +31 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +184 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +28 -0
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +149 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +68 -0
- package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
- package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
- package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
- package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
- package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
- package/templates/starter-app/src/app/api/system-categories/route.ts +154 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +131 -0
- package/templates/starter-app/src/app/api/users/[id]/route.ts +222 -0
- package/templates/starter-app/src/app/api/users/route.ts +139 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/icon.svg +6 -0
- package/templates/starter-app/src/app/manifest.ts +26 -0
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +119 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +91 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +67 -0
- package/templates/starter-app/src/configs/permissions/index.ts +37 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
- package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
- package/templates/starter-app/src/configs/tenant.ts +2 -1
- package/templates/starter-app/src/data/dictionary.ts +76 -4
- package/templates/starter-app/src/data/navigations.ts +49 -0
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +26 -0
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/crud/index.ts +2 -0
- package/templates/starter-app/src/lib/prisma.ts +3 -2
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -13
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +47 -0
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/tsconfig.json +2 -0
|
@@ -1,8 +1,80 @@
|
|
|
1
1
|
import type { DictionaryType } from "@goerp/core/hooks"
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* i18n dictionary
|
|
5
|
-
*
|
|
6
|
-
* `
|
|
4
|
+
* i18n dictionary của shell + CRUD engine.
|
|
5
|
+
*
|
|
6
|
+
* Block `crud` là BẮT BUỘC có: engine CRUD của core tra nhãn qua các key
|
|
7
|
+
* `crud.common.*` / `crud.messages.*` — thiếu thì UI hiện nguyên raw key
|
|
8
|
+
* (vd chip trạng thái hiện "crud.common.options.active" thay vì "Hoạt động").
|
|
9
|
+
* Sidebar thì fall back về title trong navigations nên `navigation` để trống
|
|
10
|
+
* được; localize dần bằng cách thêm `{ navigation: {...}, label: {...} }`.
|
|
7
11
|
*/
|
|
8
|
-
export const dictionary: DictionaryType = {
|
|
12
|
+
export const dictionary: DictionaryType = {
|
|
13
|
+
crud: {
|
|
14
|
+
common: {
|
|
15
|
+
create: "Tạo",
|
|
16
|
+
edit: "Sửa",
|
|
17
|
+
delete: "Xóa",
|
|
18
|
+
save: "Lưu",
|
|
19
|
+
saving: "Đang lưu...",
|
|
20
|
+
cancel: "Hủy",
|
|
21
|
+
submit: "Gửi",
|
|
22
|
+
update: "Cập nhật",
|
|
23
|
+
search: "Tìm kiếm...",
|
|
24
|
+
filter: "Lọc",
|
|
25
|
+
filters: "Bộ lọc",
|
|
26
|
+
export: "Xuất",
|
|
27
|
+
import: "Nhập",
|
|
28
|
+
reset: "Đặt lại",
|
|
29
|
+
clear: "Xóa",
|
|
30
|
+
clearAll: "Xóa tất cả",
|
|
31
|
+
actions: "Hành động",
|
|
32
|
+
noData: "Không có dữ liệu",
|
|
33
|
+
loading: "Đang tải...",
|
|
34
|
+
success: "Thành công",
|
|
35
|
+
error: "Lỗi",
|
|
36
|
+
confirm: "Xác nhận",
|
|
37
|
+
close: "Đóng",
|
|
38
|
+
detail: "Chi tiết",
|
|
39
|
+
selectAll: "Chọn tất cả",
|
|
40
|
+
selected: "đã chọn",
|
|
41
|
+
item: "mục",
|
|
42
|
+
items: "mục",
|
|
43
|
+
add: "Thêm",
|
|
44
|
+
creating: "Đang tạo...",
|
|
45
|
+
updating: "Đang cập nhật...",
|
|
46
|
+
deleting: "Đang xóa...",
|
|
47
|
+
savedSuccessfully: "Đã lưu thành công!",
|
|
48
|
+
createdSuccessfully: "Đã tạo thành công!",
|
|
49
|
+
updatedSuccessfully: "Đã cập nhật thành công!",
|
|
50
|
+
deleteSelected: "Xóa đã chọn",
|
|
51
|
+
addNew: "Thêm mới",
|
|
52
|
+
confirmDeleteTitle: "Xóa {{entity}}?",
|
|
53
|
+
confirmBulkDeleteTitle: "Xóa {{count}} {{entities}}?",
|
|
54
|
+
confirmDeleteDescription:
|
|
55
|
+
'Bạn có chắc chắn muốn xóa "{{name}}"? Hành động này không thể hoàn tác.',
|
|
56
|
+
confirmBulkDeleteDescription:
|
|
57
|
+
"Bạn có chắc chắn muốn xóa {{count}} {{entities}}? Hành động này không thể hoàn tác.",
|
|
58
|
+
cannotBeUndone: "Hành động này không thể hoàn tác.",
|
|
59
|
+
options: {
|
|
60
|
+
active: "Hoạt động",
|
|
61
|
+
inactive: "Tạm ngưng",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
messages: {
|
|
65
|
+
created: "{{entity}} đã được tạo thành công",
|
|
66
|
+
updated: "{{entity}} đã được cập nhật thành công",
|
|
67
|
+
deleted: "{{entity}} đã được xóa thành công",
|
|
68
|
+
bulkDeleted: "{{count}} {{entities}} đã được xóa thành công",
|
|
69
|
+
deleteConfirm: "Bạn có chắc chắn muốn xóa {{entity}} này không?",
|
|
70
|
+
bulkDeleteConfirm: "Bạn có chắc chắn muốn xóa {{count}} {{entities}} không?",
|
|
71
|
+
deleteFailed: "Không thể xóa {{entity}}",
|
|
72
|
+
saveFailed: "Không thể lưu {{entity}}",
|
|
73
|
+
loadFailed: "Không thể tải dữ liệu. Vui lòng thử lại.",
|
|
74
|
+
exportSuccess: 'File "{{filename}}" đã được tải xuống thành công',
|
|
75
|
+
exportFailed: "Xuất dữ liệu thất bại. Vui lòng thử lại.",
|
|
76
|
+
importSuccess: "{{count}} {{entities}} đã được nhập thành công",
|
|
77
|
+
importFailed: "Nhập dữ liệu thất bại. Vui lòng thử lại.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
@@ -25,19 +25,68 @@ export const navigations: NavigationType[] = [
|
|
|
25
25
|
href: "/crud/departments",
|
|
26
26
|
resource: "department",
|
|
27
27
|
},
|
|
28
|
+
{
|
|
29
|
+
title: "Chức danh",
|
|
30
|
+
iconName: "Briefcase",
|
|
31
|
+
href: "/crud/job-titles",
|
|
32
|
+
resource: "job-title",
|
|
33
|
+
},
|
|
28
34
|
],
|
|
29
35
|
},
|
|
30
36
|
{
|
|
31
37
|
title: "Hệ thống",
|
|
32
38
|
iconName: "ShieldCheck",
|
|
33
39
|
items: [
|
|
40
|
+
{ title: "Người dùng", iconName: "Users", href: "/users", resource: "user" },
|
|
41
|
+
{
|
|
42
|
+
title: "Phiên đăng nhập",
|
|
43
|
+
iconName: "KeyRound",
|
|
44
|
+
href: "/security/sessions",
|
|
45
|
+
resource: "user",
|
|
46
|
+
},
|
|
34
47
|
{ title: "Vai trò", iconName: "ShieldCheck", href: "/roles", resource: "role" },
|
|
48
|
+
{
|
|
49
|
+
title: "Tài nguyên",
|
|
50
|
+
iconName: "Boxes",
|
|
51
|
+
href: "/resources",
|
|
52
|
+
resource: "resource",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
title: "Hành động",
|
|
56
|
+
iconName: "MousePointerClick",
|
|
57
|
+
href: "/actions",
|
|
58
|
+
resource: "action",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: "Hồ sơ công ty",
|
|
62
|
+
iconName: "Building",
|
|
63
|
+
href: "/admin/system/company",
|
|
64
|
+
resource: "system-setting",
|
|
65
|
+
},
|
|
35
66
|
{
|
|
36
67
|
title: "Cấu hình",
|
|
37
68
|
iconName: "Settings2",
|
|
38
69
|
href: "/admin/system/settings",
|
|
39
70
|
resource: "system-setting",
|
|
40
71
|
},
|
|
72
|
+
{
|
|
73
|
+
title: "Danh mục hệ thống",
|
|
74
|
+
iconName: "FolderTree",
|
|
75
|
+
href: "/system-categories",
|
|
76
|
+
resource: "system-category",
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: "Cảnh báo hệ thống",
|
|
80
|
+
iconName: "Bell",
|
|
81
|
+
href: "/crud/system-alerts",
|
|
82
|
+
resource: "system-alert",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
title: "Bộ nhớ đệm",
|
|
86
|
+
iconName: "Database",
|
|
87
|
+
href: "/admin/system/cache",
|
|
88
|
+
resource: "system-cache",
|
|
89
|
+
},
|
|
41
90
|
{
|
|
42
91
|
title: "Nhật ký hoạt động",
|
|
43
92
|
iconName: "Scroll",
|
|
@@ -42,23 +42,12 @@ export async function register() {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
* "0 1 * * *"
|
|
47
|
-
*
|
|
48
|
-
* chạy đúng giờ mong muốn.
|
|
45
|
+
* Từ core 0.1.71, biểu thức cron 5 trường chạy ĐÚNG GIỜ ĐỊA PHƯƠNG của tiến
|
|
46
|
+
* trình ("0 1 * * *" = 1 giờ sáng thật — Dockerfile đã set TZ=Asia/Ho_Chi_Minh)
|
|
47
|
+
* nên job hằng ngày cứ khai lịch thẳng, KHÔNG cần mẹo gác giờ nữa.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* cronManager.addJob({
|
|
54
|
-
* name: "daily-report",
|
|
55
|
-
* cronTime: "0 * * * *",
|
|
56
|
-
* start: true,
|
|
57
|
-
* onTick: async () => {
|
|
58
|
-
* if (!isLocalHour(1)) return
|
|
59
|
-
* await sendDailyReport()
|
|
60
|
-
* },
|
|
61
|
-
* })
|
|
49
|
+
* Hàm dưới chỉ còn cho ca đặc thù: job muốn gác theo MỘT múi giờ KHÁC múi giờ
|
|
50
|
+
* tiến trình (vd server chạy UTC nhưng nghiệp vụ theo giờ VN).
|
|
62
51
|
*/
|
|
63
52
|
export function isLocalHour(hour: number, timeZone = "Asia/Ho_Chi_Minh"): boolean {
|
|
64
53
|
return (
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { checkPermission } from "@goerp/core/auth"
|
|
2
|
+
|
|
3
|
+
import type { Session } from "@/types/session"
|
|
4
|
+
|
|
5
|
+
import { getSession } from "@/lib/auth"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Cổng cho SERVER ACTION — tương đương apiHandler({resource, action}) của tầng
|
|
9
|
+
* API. Action là endpoint POST thật sự (mọi client đã đăng nhập gọi được) nên
|
|
10
|
+
* PHẢI gác quyền; guardrail `auth/server-action-bare-session` cấm gọi
|
|
11
|
+
* getSession() trần trong file "use server" để ép đi qua đây.
|
|
12
|
+
*
|
|
13
|
+
* const session = await requirePermission("system-setting", "update")
|
|
14
|
+
*/
|
|
15
|
+
export async function requirePermission(
|
|
16
|
+
resource: string,
|
|
17
|
+
action = "view",
|
|
18
|
+
): Promise<Session> {
|
|
19
|
+
const session = await getSession()
|
|
20
|
+
if (!session?.user) throw new Error("Unauthorized")
|
|
21
|
+
const s = session as Parameters<typeof checkPermission>[0]
|
|
22
|
+
if (!checkPermission(s, resource, action)) {
|
|
23
|
+
throw new Error(`Forbidden: ${resource}:${action}`)
|
|
24
|
+
}
|
|
25
|
+
return session as Session
|
|
26
|
+
}
|
|
@@ -6,6 +6,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma"
|
|
|
6
6
|
import { APIError } from "better-auth/api"
|
|
7
7
|
import { customSession } from "better-auth/plugins"
|
|
8
8
|
|
|
9
|
+
import { tenant } from "@/configs/tenant"
|
|
9
10
|
import { db } from "@/lib/prisma"
|
|
10
11
|
import { loadUserAccess } from "@/lib/rbac/access"
|
|
11
12
|
|
|
@@ -28,6 +29,13 @@ function buildAuth() {
|
|
|
28
29
|
secret: process.env.BETTER_AUTH_SECRET,
|
|
29
30
|
database: prismaAdapter(db, { provider: "postgresql" }),
|
|
30
31
|
|
|
32
|
+
// Cookie mang tên riêng của app (`<tenant.id>.session_token`): cookie theo
|
|
33
|
+
// HOST không phân biệt port, nên dev nhiều app GoERP trên cùng localhost mà
|
|
34
|
+
// dùng tên mặc định `better-auth.*` là app này mở khoá proxy app kia.
|
|
35
|
+
// proxy.ts phải truyền CÙNG prefix vào getSessionCookie — đổi một nơi thì
|
|
36
|
+
// đổi cả hai.
|
|
37
|
+
advanced: { cookiePrefix: tenant.id },
|
|
38
|
+
|
|
31
39
|
rateLimit: {
|
|
32
40
|
enabled: true,
|
|
33
41
|
window: 60,
|
|
@@ -5,6 +5,8 @@ import { db } from "@/lib/prisma"
|
|
|
5
5
|
// Engine CRUD generic ở CORE — app chỉ tiêm prisma + map entity(số nhiều)→model Prisma.
|
|
6
6
|
const MODEL_MAP: Record<string, string> = {
|
|
7
7
|
departments: "department",
|
|
8
|
+
"job-titles": "jobTitle",
|
|
9
|
+
"system-alerts": "systemAlert",
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export const crudService = createServerCrudService({
|
|
@@ -14,8 +14,9 @@ import { configureSettingsService } from "@goerp/core/system/services/settings-s
|
|
|
14
14
|
* 3. bọc branch-guard (lưới an toàn phạm vi chi nhánh cho thao tác ĐỌC);
|
|
15
15
|
* 4. gọi configureSettingsService(db) để core đọc SystemConfig thật.
|
|
16
16
|
*
|
|
17
|
-
* Các seam còn lại
|
|
18
|
-
* src/server/
|
|
17
|
+
* Các seam còn lại cấu hình ở chỗ chúng sống: tasks → src/server/tasks/,
|
|
18
|
+
* notifications → src/server/services/notification-service.ts, cron →
|
|
19
|
+
* src/lib/cron/db-cron-manager.ts (khởi động từ src/instrumentation.ts).
|
|
19
20
|
*/
|
|
20
21
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }
|
|
21
22
|
|
|
@@ -1,31 +1,26 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useEffect } from "react"
|
|
4
|
+
import { useIsDarkMode } from "@goerp/core/hooks"
|
|
4
5
|
|
|
5
6
|
import type { ReactNode } from "react"
|
|
6
7
|
|
|
8
|
+
const MODES = ["light", "dark"]
|
|
9
|
+
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* Áp chế độ Sáng/Tối/Hệ thống mà người dùng chọn trong Customizer (bánh răng).
|
|
12
|
+
* `useIsDarkMode()` của core đã resolve settings.mode (lưu cookie) + theo dõi
|
|
13
|
+
* prefers-color-scheme khi chọn "Hệ thống" — ở đây chỉ việc gắn class lên <html>.
|
|
11
14
|
*/
|
|
12
|
-
const MODES = ["light", "dark"] as const
|
|
13
|
-
|
|
14
15
|
export function ModeProvider({ children }: { children: ReactNode }) {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const isDarkMode = useIsDarkMode()
|
|
17
|
+
const mode = isDarkMode ? "dark" : "light"
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
apply(query.matches)
|
|
25
|
-
const onChange = (e: MediaQueryListEvent) => apply(e.matches)
|
|
26
|
-
query.addEventListener("change", onChange)
|
|
27
|
-
return () => query.removeEventListener("change", onChange)
|
|
28
|
-
}, [])
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const root = document.documentElement
|
|
21
|
+
root.classList.remove(...MODES)
|
|
22
|
+
root.classList.add(mode)
|
|
23
|
+
}, [mode])
|
|
29
24
|
|
|
30
25
|
return <>{children}</>
|
|
31
26
|
}
|
|
@@ -1,21 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
"use client"
|
|
2
2
|
|
|
3
|
-
import { useEffect } from
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
import { useSettings } from "@goerp/core/hooks"
|
|
5
|
+
import {
|
|
6
|
+
applyBrandThemeTokens,
|
|
7
|
+
THEME_PRESETS,
|
|
8
|
+
} from "@goerp/core/providers/brand-theme"
|
|
4
9
|
|
|
5
|
-
import type { ReactNode } from
|
|
6
|
-
|
|
7
|
-
const DEFAULT_THEME = 'blue'
|
|
8
|
-
const DEFAULT_RADIUS = 0.5
|
|
10
|
+
import type { ReactNode } from "react"
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Áp màu thương hiệu người dùng chọn trong Customizer vào CSS token
|
|
14
|
+
* (--primary, --ring, --sidebar-background…). Bảng màu calibrate sẵn nằm ở
|
|
15
|
+
* core (`THEME_PRESETS`); app muốn nắn tay từng hue thì thay bằng bảng riêng
|
|
16
|
+
* (xem vinhhoa src/providers/theme-provider.tsx).
|
|
17
|
+
*
|
|
18
|
+
* Radius ("Bo góc") + density ("Mật độ") do SettingsProvider của core tự áp.
|
|
19
|
+
*/
|
|
10
20
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
21
|
+
const { settings } = useSettings()
|
|
22
|
+
|
|
11
23
|
useEffect(() => {
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
const theme = THEME_PRESETS[settings.theme] ?? THEME_PRESETS.blue
|
|
25
|
+
const isDark =
|
|
26
|
+
settings.mode === "dark" ||
|
|
27
|
+
(settings.mode === "system" &&
|
|
28
|
+
typeof window !== "undefined" &&
|
|
29
|
+
!!window.matchMedia?.("(prefers-color-scheme: dark)").matches)
|
|
30
|
+
|
|
31
|
+
applyBrandThemeTokens(document.documentElement, theme, isDark)
|
|
32
|
+
}, [settings.theme, settings.mode])
|
|
19
33
|
|
|
20
34
|
return <>{children}</>
|
|
21
35
|
}
|
|
@@ -2,6 +2,7 @@ import { createAuthProxy } from "@goerp/core/auth/proxy-gate"
|
|
|
2
2
|
import { getSessionCookie } from "better-auth/cookies"
|
|
3
3
|
|
|
4
4
|
import { i18n } from "@/configs/i18n"
|
|
5
|
+
import { tenant } from "@/configs/tenant"
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Cổng xác thực DEFAULT-DENY, dùng thẳng proxy-gate của core.
|
|
@@ -19,14 +20,18 @@ const BYPASS =
|
|
|
19
20
|
(process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1")
|
|
20
21
|
|
|
21
22
|
export const proxy = createAuthProxy({
|
|
22
|
-
// Thêm prefix vào đây khi có endpoint tự xác thực
|
|
23
|
-
|
|
23
|
+
// Thêm prefix vào đây khi có endpoint tự xác thực — webhook, token khách,
|
|
24
|
+
// và cả đường NGOÀI /api (máy móc không có cookie kiểu /pub, /iclock của
|
|
25
|
+
// máy chấm công). Mỗi entry phải ghi rõ nó tự bảo vệ bằng gì; guardrail
|
|
26
|
+
// `auth/route-outside-api-declared` sẽ bắt route ngoài /api chưa khai ở đây.
|
|
27
|
+
publicPrefixes: ["/api/better-auth", "/api/public"],
|
|
24
28
|
publicPages: [...i18n.locales.map((l) => `/${l}/sign-in`), "/sign-in"],
|
|
25
29
|
signInPath: `/${i18n.defaultLocale}/sign-in`,
|
|
26
30
|
homePath: `/${i18n.defaultLocale}`,
|
|
31
|
+
// cookiePrefix PHẢI khớp advanced.cookiePrefix trong src/lib/better-auth.ts.
|
|
27
32
|
getToken: BYPASS
|
|
28
33
|
? async () => ({ dev: true })
|
|
29
|
-
: async (req) => getSessionCookie(req),
|
|
34
|
+
: async (req) => getSessionCookie(req, { cookiePrefix: tenant.id }),
|
|
30
35
|
})
|
|
31
36
|
|
|
32
37
|
export default proxy
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cache } from "react"
|
|
2
|
+
|
|
3
|
+
import { tenant } from "@/configs/tenant"
|
|
4
|
+
import { db } from "@/lib/prisma"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Công ty "hệ thống" = dòng ĐẦU TIÊN của bảng companies. Chưa có dòng nào thì
|
|
8
|
+
* trả mặc định từ tenant config để biểu mẫu không trống — lưu lần đầu sẽ tạo.
|
|
9
|
+
*/
|
|
10
|
+
export const getSystemCompany = cache(async function getSystemCompany() {
|
|
11
|
+
const company = await db.company.findFirst({ orderBy: { createdAt: "asc" } })
|
|
12
|
+
if (company) return company
|
|
13
|
+
return {
|
|
14
|
+
id: null as string | null,
|
|
15
|
+
name: tenant.branding?.companyName ?? tenant.name,
|
|
16
|
+
taxCode: "",
|
|
17
|
+
address: "",
|
|
18
|
+
phone: "",
|
|
19
|
+
email: "",
|
|
20
|
+
logoUrl: null as string | null,
|
|
21
|
+
systemName: tenant.name,
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
export interface CompanyProfileInput {
|
|
26
|
+
name: string
|
|
27
|
+
taxCode: string
|
|
28
|
+
address: string
|
|
29
|
+
phone: string
|
|
30
|
+
email: string
|
|
31
|
+
logoUrl?: string | null
|
|
32
|
+
systemName?: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function updateSystemCompany(
|
|
36
|
+
data: CompanyProfileInput,
|
|
37
|
+
updatedBy: string,
|
|
38
|
+
) {
|
|
39
|
+
const existing = await db.company.findFirst({ orderBy: { createdAt: "asc" } })
|
|
40
|
+
if (existing) {
|
|
41
|
+
return db.company.update({
|
|
42
|
+
where: { id: existing.id },
|
|
43
|
+
data: { ...data, updatedBy },
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
return db.company.create({ data: { ...data, createdBy: updatedBy, updatedBy } })
|
|
47
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { db } from "@/lib/prisma"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Danh sách người dùng cho trang /users và GET /api/users.
|
|
5
|
+
*
|
|
6
|
+
* CỐ Ý không dùng `getUsersData` của @goerp/core/user: bản đó select các cột
|
|
7
|
+
* vinhhoa-specific (`userType`, quan hệ `profiles`/`supplier`) không tồn tại
|
|
8
|
+
* trong schema template — Prisma sẽ ném PrismaClientValidationError ngay
|
|
9
|
+
* request đầu tiên. Query ở đây chỉ đụng User/UserRole/UserBranch của template
|
|
10
|
+
* và trả đúng shape mà UsersTable/UsersCardView/UnifiedProfileDialog của core
|
|
11
|
+
* đọc: `status` ("active"/"inactive"), `roleNames`/`roleCodes`,
|
|
12
|
+
* `branchIds`/`branchNames`, `defaultBranchId`.
|
|
13
|
+
* (`getUserStats`/`getActiveRoles` của core thì an toàn — chỗ đếm theo
|
|
14
|
+
* `userType` đã có `.catch(() => 0)`.)
|
|
15
|
+
*/
|
|
16
|
+
export interface UsersListParams {
|
|
17
|
+
search?: string
|
|
18
|
+
/** Lọc theo mã vai trò (Role.code). */
|
|
19
|
+
roleCode?: string
|
|
20
|
+
/** "active" | "inactive" — map sang cột isActive. */
|
|
21
|
+
status?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import type { Prisma } from "@prisma/client"
|
|
25
|
+
|
|
26
|
+
type Where = Prisma.UserWhereInput
|
|
27
|
+
|
|
28
|
+
export async function getUsersList(params: UsersListParams = {}) {
|
|
29
|
+
const { search, roleCode, status } = params
|
|
30
|
+
|
|
31
|
+
const where: Where = {}
|
|
32
|
+
if (search) {
|
|
33
|
+
where.OR = [
|
|
34
|
+
{ name: { contains: search, mode: "insensitive" } },
|
|
35
|
+
{ email: { contains: search, mode: "insensitive" } },
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
if (status === "active" || status === "inactive") {
|
|
39
|
+
where.isActive = status === "active"
|
|
40
|
+
}
|
|
41
|
+
if (roleCode) {
|
|
42
|
+
where.userRoles = { some: { roleCode } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Trả TOÀN BỘ danh sách đã lọc: UsersClientPage của core tự phân trang phía
|
|
46
|
+
// client theo ?page&pageSize (danh sách người dùng nội bộ đủ nhỏ).
|
|
47
|
+
const [users, total] = await Promise.all([
|
|
48
|
+
db.user.findMany({
|
|
49
|
+
where,
|
|
50
|
+
orderBy: { createdAt: "desc" },
|
|
51
|
+
select: {
|
|
52
|
+
id: true,
|
|
53
|
+
name: true,
|
|
54
|
+
email: true,
|
|
55
|
+
image: true,
|
|
56
|
+
isActive: true,
|
|
57
|
+
lastLoginAt: true,
|
|
58
|
+
createdAt: true,
|
|
59
|
+
updatedAt: true,
|
|
60
|
+
userRoles: {
|
|
61
|
+
select: { role: { select: { code: true, name: true } } },
|
|
62
|
+
},
|
|
63
|
+
userBranches: {
|
|
64
|
+
select: {
|
|
65
|
+
isDefault: true,
|
|
66
|
+
branch: { select: { id: true, name: true } },
|
|
67
|
+
},
|
|
68
|
+
orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
db.user.count({ where }),
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
const data = users.map((user) => {
|
|
76
|
+
const branches = user.userBranches
|
|
77
|
+
.filter((ub) => ub.branch)
|
|
78
|
+
.map((ub) => ({
|
|
79
|
+
id: ub.branch.id,
|
|
80
|
+
name: ub.branch.name,
|
|
81
|
+
isDefault: ub.isDefault,
|
|
82
|
+
}))
|
|
83
|
+
return {
|
|
84
|
+
id: user.id,
|
|
85
|
+
name: user.name,
|
|
86
|
+
email: user.email,
|
|
87
|
+
image: user.image,
|
|
88
|
+
isActive: user.isActive,
|
|
89
|
+
status: user.isActive ? "active" : "inactive",
|
|
90
|
+
lastLoginAt: user.lastLoginAt,
|
|
91
|
+
createdAt: user.createdAt,
|
|
92
|
+
updatedAt: user.updatedAt,
|
|
93
|
+
roleNames: user.userRoles.map((ur) => ur.role.name),
|
|
94
|
+
roleCodes: user.userRoles.map((ur) => ur.role.code),
|
|
95
|
+
branches,
|
|
96
|
+
branchIds: branches.map((b) => b.id),
|
|
97
|
+
branchNames: branches.map((b) => b.name),
|
|
98
|
+
defaultBranchId:
|
|
99
|
+
branches.find((b) => b.isDefault)?.id ?? branches[0]?.id ?? null,
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
return { data, total }
|
|
104
|
+
}
|