@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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Sinh migration MỚI từ diff (migrations hiện có → prisma/schema) mà không đụng
|
|
4
|
+
* DB thật — thay cho `prisma migrate dev` (bị cấm ở đây vì nó có thể RESET DB).
|
|
5
|
+
*
|
|
6
|
+
* Prisma 7.9 đã BỎ cờ `--shadow-database-url` khỏi `migrate diff`, nên quy
|
|
7
|
+
* trình đúng là: tạo DB shadow tạm cạnh DB chính → deploy toàn bộ migrations
|
|
8
|
+
* vào shadow → diff shadow ↔ schema → ghi file migration → xoá shadow.
|
|
9
|
+
*
|
|
10
|
+
* Dùng: pnpm migration:new <ten_migration> (vd: pnpm migration:new add_devices)
|
|
11
|
+
* Yêu cầu: DATABASE_URL trong .env; tài khoản DB có quyền CREATE DATABASE
|
|
12
|
+
* (không có thì tự trỏ SHADOW_DATABASE_URL sang một DB trống bạn tạo sẵn).
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from "node:child_process"
|
|
15
|
+
import fs from "node:fs"
|
|
16
|
+
import path from "node:path"
|
|
17
|
+
import process from "node:process"
|
|
18
|
+
|
|
19
|
+
import "dotenv/config"
|
|
20
|
+
import pg from "pg"
|
|
21
|
+
|
|
22
|
+
const name = process.argv[2]
|
|
23
|
+
if (!name || !/^[a-z0-9_]+$/.test(name)) {
|
|
24
|
+
console.error("Cách dùng: pnpm migration:new <ten_snake_case>")
|
|
25
|
+
process.exit(1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const dbUrl = process.env.DATABASE_URL
|
|
29
|
+
if (!dbUrl) {
|
|
30
|
+
console.error("Thiếu DATABASE_URL (xem .env)")
|
|
31
|
+
process.exit(1)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const url = new URL(dbUrl)
|
|
35
|
+
const mainDb = url.pathname.slice(1)
|
|
36
|
+
const shadowDb = `${mainDb}_shadow_migrate`
|
|
37
|
+
const shadowUrl = process.env.SHADOW_DATABASE_URL ?? (() => {
|
|
38
|
+
const u = new URL(dbUrl)
|
|
39
|
+
u.pathname = `/${shadowDb}`
|
|
40
|
+
return u.toString()
|
|
41
|
+
})()
|
|
42
|
+
|
|
43
|
+
const adminUrl = (() => {
|
|
44
|
+
const u = new URL(dbUrl)
|
|
45
|
+
u.pathname = "/postgres"
|
|
46
|
+
return u.toString()
|
|
47
|
+
})()
|
|
48
|
+
|
|
49
|
+
const ownShadow = !process.env.SHADOW_DATABASE_URL
|
|
50
|
+
|
|
51
|
+
async function withShadow(fn) {
|
|
52
|
+
if (!ownShadow) return fn()
|
|
53
|
+
const admin = new pg.Client({ connectionString: adminUrl })
|
|
54
|
+
await admin.connect()
|
|
55
|
+
await admin.query(`DROP DATABASE IF EXISTS "${shadowDb}"`)
|
|
56
|
+
await admin.query(`CREATE DATABASE "${shadowDb}"`)
|
|
57
|
+
try {
|
|
58
|
+
return await fn()
|
|
59
|
+
} finally {
|
|
60
|
+
await admin.query(`DROP DATABASE IF EXISTS "${shadowDb}"`)
|
|
61
|
+
await admin.end()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const run = (cmd, env = {}) =>
|
|
66
|
+
execSync(cmd, { stdio: ["inherit", "pipe", "inherit"], env: { ...process.env, ...env } })
|
|
67
|
+
|
|
68
|
+
await withShadow(async () => {
|
|
69
|
+
console.log(`→ deploy migrations hiện có vào shadow (${shadowDb})…`)
|
|
70
|
+
run("pnpm exec prisma migrate deploy", { DATABASE_URL: shadowUrl })
|
|
71
|
+
|
|
72
|
+
console.log("→ diff shadow ↔ prisma/schema…")
|
|
73
|
+
const sql = run(
|
|
74
|
+
"pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema --script",
|
|
75
|
+
{ DATABASE_URL: shadowUrl },
|
|
76
|
+
).toString()
|
|
77
|
+
|
|
78
|
+
if (!sql.trim() || /This is an empty migration/.test(sql)) {
|
|
79
|
+
console.log("Schema không đổi so với migrations — không có gì để sinh.")
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ts = new Date().toISOString().replace(/\D/g, "").slice(0, 14)
|
|
84
|
+
const dir = path.join("prisma", "migrations", `${ts}_${name}`)
|
|
85
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
86
|
+
fs.writeFileSync(path.join(dir, "migration.sql"), sql)
|
|
87
|
+
console.log(`✓ ${dir}/migration.sql`)
|
|
88
|
+
console.log("Xem lại SQL rồi chạy: pnpm prisma:deploy")
|
|
89
|
+
})
|
|
@@ -29,6 +29,14 @@ runCoreGuardrails({
|
|
|
29
29
|
permissionRegistry,
|
|
30
30
|
navigations,
|
|
31
31
|
|
|
32
|
+
allowlists: {
|
|
33
|
+
// Thu hồi PHIÊN CỦA CHÍNH MÌNH không cần quyền, phiên NGƯỜI KHÁC cần
|
|
34
|
+
// user:update — quyền phụ thuộc dữ liệu request nên không khai được ở cổng.
|
|
35
|
+
"auth/api-inline-check-permission": [
|
|
36
|
+
"app/api/security/sessions/[id]/route.ts",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
|
|
32
40
|
ceilings: {
|
|
33
41
|
// Năm chỗ `any` này có sẵn trong bộ khung, mỗi chỗ đã kèm một dòng
|
|
34
42
|
// eslint-disable giải thích. Chúng được KHAI ở đây để mọi `any` bạn thêm về
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
2
|
+
import {
|
|
3
|
+
getActionsForPermissionMatrix,
|
|
4
|
+
getResourcesForPermissionMatrix,
|
|
5
|
+
} from "@goerp/core/rbac/resource-service"
|
|
6
|
+
// Trang hành động = catalog TRA CỨU. Action mới khai trong customActions của
|
|
7
|
+
// Permission Registry rồi chạy rbac-sync.
|
|
8
|
+
import { ActionCatalogPage } from "@goerp/core/rbac/pages"
|
|
9
|
+
|
|
10
|
+
import { getActionMetaMap } from "@/configs/permissions"
|
|
11
|
+
import { RESOURCE_PAGES } from "@/configs/permissions/menu-tree"
|
|
12
|
+
import { db } from "@/lib/prisma"
|
|
13
|
+
|
|
14
|
+
export const metadata = { title: "Hành động" }
|
|
15
|
+
|
|
16
|
+
export default async function ActionsPage({
|
|
17
|
+
params,
|
|
18
|
+
}: {
|
|
19
|
+
params: Promise<{ lang: string }>
|
|
20
|
+
}) {
|
|
21
|
+
const { lang } = await params
|
|
22
|
+
await requirePageAccess(lang, "action")
|
|
23
|
+
|
|
24
|
+
const [resources, actions] = await Promise.all([
|
|
25
|
+
getResourcesForPermissionMatrix(db),
|
|
26
|
+
getActionsForPermissionMatrix(db),
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<ActionCatalogPage
|
|
31
|
+
resources={resources}
|
|
32
|
+
actions={actions}
|
|
33
|
+
actionMeta={getActionMetaMap()}
|
|
34
|
+
resourcePages={RESOURCE_PAGES}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { checkPermission } from "@goerp/core/auth"
|
|
2
|
+
import { cacheManager } from "@goerp/core/infrastructure/cache"
|
|
3
|
+
import { CacheManagementPage } from "@goerp/core/ui/management"
|
|
4
|
+
|
|
5
|
+
import { getSession } from "@/lib/auth"
|
|
6
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
7
|
+
|
|
8
|
+
export const metadata = { title: "Bộ nhớ đệm" }
|
|
9
|
+
|
|
10
|
+
// Server action là endpoint POST độc lập (gác render KHÔNG bảo vệ chúng) —
|
|
11
|
+
// mỗi action phải tự kiểm tra quyền khi được gọi. PHẢI ở MODULE SCOPE: khai
|
|
12
|
+
// trong component thì inline "use server" action đóng closure lên nó → Next
|
|
13
|
+
// serialize closure → "Functions cannot be passed directly to Client Components".
|
|
14
|
+
async function assertCacheAccess(action: "view" | "update") {
|
|
15
|
+
const session = await getSession()
|
|
16
|
+
const s = session as Parameters<typeof checkPermission>[0]
|
|
17
|
+
if (!checkPermission(s, "system-cache", action)) {
|
|
18
|
+
throw new Error("Forbidden")
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function CachePage({
|
|
23
|
+
params,
|
|
24
|
+
}: {
|
|
25
|
+
params: Promise<{ lang: string }>
|
|
26
|
+
}) {
|
|
27
|
+
const { lang } = await params
|
|
28
|
+
await requirePageAccess(lang, "system-cache")
|
|
29
|
+
|
|
30
|
+
async function fetchStats() {
|
|
31
|
+
"use server"
|
|
32
|
+
await assertCacheAccess("view")
|
|
33
|
+
return cacheManager.getStats()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function clearCache(name: string) {
|
|
37
|
+
"use server"
|
|
38
|
+
await assertCacheAccess("update")
|
|
39
|
+
return cacheManager.clearCache(name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function clearAll() {
|
|
43
|
+
"use server"
|
|
44
|
+
await assertCacheAccess("update")
|
|
45
|
+
return cacheManager.flushAll()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function getCacheKeys(name: string) {
|
|
49
|
+
"use server"
|
|
50
|
+
await assertCacheAccess("view")
|
|
51
|
+
return cacheManager.get(name)?.keys() ?? []
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function getCacheValue(name: string, key: string) {
|
|
55
|
+
"use server"
|
|
56
|
+
await assertCacheAccess("view")
|
|
57
|
+
return cacheManager.get(name)?.get(key) ?? null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<CacheManagementPage
|
|
62
|
+
fetchStats={fetchStats}
|
|
63
|
+
clearCache={clearCache}
|
|
64
|
+
clearAll={clearAll}
|
|
65
|
+
getCacheKeys={getCacheKeys}
|
|
66
|
+
getCacheValue={getCacheValue}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use server"
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache"
|
|
4
|
+
|
|
5
|
+
import { requirePermission } from "@/lib/action-guard"
|
|
6
|
+
import {
|
|
7
|
+
updateSystemCompany,
|
|
8
|
+
type CompanyProfileInput,
|
|
9
|
+
} from "@/server/services/company-service"
|
|
10
|
+
|
|
11
|
+
/** Hồ sơ công ty dùng chung quyền với Cấu hình hệ thống (system-setting). */
|
|
12
|
+
export async function submitCompanyProfile(data: CompanyProfileInput) {
|
|
13
|
+
let userId: string
|
|
14
|
+
try {
|
|
15
|
+
const session = await requirePermission("system-setting", "update")
|
|
16
|
+
userId = session.user?.id ?? "system"
|
|
17
|
+
} catch {
|
|
18
|
+
return { success: false, error: "Bạn không có quyền cập nhật hồ sơ công ty" }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await updateSystemCompany(data, userId)
|
|
23
|
+
revalidatePath("/admin/system/company")
|
|
24
|
+
// Tên/logo công ty có thể hiện ở layout + trang in — revalidate cả cây.
|
|
25
|
+
revalidatePath("/", "layout")
|
|
26
|
+
return { success: true }
|
|
27
|
+
} catch (error) {
|
|
28
|
+
console.error("Cập nhật hồ sơ công ty lỗi:", error)
|
|
29
|
+
return { success: false, error: "Đã có lỗi khi lưu hồ sơ công ty." }
|
|
30
|
+
}
|
|
31
|
+
}
|
package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { useForm } from "react-hook-form"
|
|
5
|
+
import { zodResolver } from "@hookform/resolvers/zod"
|
|
6
|
+
import { toast } from "sonner"
|
|
7
|
+
import { z } from "zod"
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
Card,
|
|
11
|
+
CardContent,
|
|
12
|
+
CardDescription,
|
|
13
|
+
CardHeader,
|
|
14
|
+
CardTitle,
|
|
15
|
+
Form,
|
|
16
|
+
FormControl,
|
|
17
|
+
FormField,
|
|
18
|
+
FormItem,
|
|
19
|
+
FormLabel,
|
|
20
|
+
FormMessage,
|
|
21
|
+
} from "@goerp/core/ui"
|
|
22
|
+
import { Button } from "@goerp/core/ui/primitives/button"
|
|
23
|
+
import { Input } from "@goerp/core/ui/primitives/input"
|
|
24
|
+
|
|
25
|
+
import { submitCompanyProfile } from "./actions"
|
|
26
|
+
|
|
27
|
+
const schema = z.object({
|
|
28
|
+
name: z.string().min(1, "Nhập tên công ty"),
|
|
29
|
+
taxCode: z.string().min(1, "Nhập mã số thuế"),
|
|
30
|
+
address: z.string().min(1, "Nhập địa chỉ"),
|
|
31
|
+
phone: z.string().min(1, "Nhập số điện thoại"),
|
|
32
|
+
email: z.string().email("Email không hợp lệ"),
|
|
33
|
+
systemName: z.string().optional(),
|
|
34
|
+
logoUrl: z.string().optional(),
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
type FormValues = z.infer<typeof schema>
|
|
38
|
+
|
|
39
|
+
interface CompanyProfileFormProps {
|
|
40
|
+
initialData: {
|
|
41
|
+
name: string
|
|
42
|
+
taxCode: string
|
|
43
|
+
address: string
|
|
44
|
+
phone: string
|
|
45
|
+
email: string
|
|
46
|
+
logoUrl?: string | null
|
|
47
|
+
systemName?: string | null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const FIELDS: Array<{
|
|
52
|
+
name: keyof FormValues
|
|
53
|
+
label: string
|
|
54
|
+
placeholder?: string
|
|
55
|
+
}> = [
|
|
56
|
+
{ name: "name", label: "Tên công ty (trên biểu mẫu/hoá đơn)" },
|
|
57
|
+
{ name: "taxCode", label: "Mã số thuế" },
|
|
58
|
+
{ name: "address", label: "Địa chỉ" },
|
|
59
|
+
{ name: "phone", label: "Số điện thoại" },
|
|
60
|
+
{ name: "email", label: "Email" },
|
|
61
|
+
{ name: "systemName", label: "Tên hệ thống (hiển thị trên app)", placeholder: "Bỏ trống = dùng tên công ty" },
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
export function CompanyProfileForm({ initialData }: CompanyProfileFormProps) {
|
|
65
|
+
const form = useForm<FormValues>({
|
|
66
|
+
resolver: zodResolver(schema),
|
|
67
|
+
defaultValues: {
|
|
68
|
+
name: initialData.name ?? "",
|
|
69
|
+
taxCode: initialData.taxCode ?? "",
|
|
70
|
+
address: initialData.address ?? "",
|
|
71
|
+
phone: initialData.phone ?? "",
|
|
72
|
+
email: initialData.email ?? "",
|
|
73
|
+
systemName: initialData.systemName ?? "",
|
|
74
|
+
logoUrl: initialData.logoUrl ?? "",
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
const { isSubmitting } = form.formState
|
|
78
|
+
const [isUploadingLogo, setIsUploadingLogo] = React.useState(false)
|
|
79
|
+
const logoUrl = form.watch("logoUrl")
|
|
80
|
+
|
|
81
|
+
async function handleLogoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
82
|
+
const file = e.target.files?.[0]
|
|
83
|
+
if (!file) return
|
|
84
|
+
setIsUploadingLogo(true)
|
|
85
|
+
try {
|
|
86
|
+
const formData = new FormData()
|
|
87
|
+
formData.append("file", file)
|
|
88
|
+
const res = await fetch("/api/upload", { method: "POST", body: formData })
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
const err = await res.json().catch(() => ({}))
|
|
91
|
+
throw new Error(err.error || "Tải logo thất bại")
|
|
92
|
+
}
|
|
93
|
+
const data = await res.json()
|
|
94
|
+
form.setValue("logoUrl", data.url as string, { shouldDirty: true })
|
|
95
|
+
} catch (err) {
|
|
96
|
+
toast.error(err instanceof Error ? err.message : "Tải logo thất bại")
|
|
97
|
+
} finally {
|
|
98
|
+
setIsUploadingLogo(false)
|
|
99
|
+
e.target.value = ""
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function onSubmit(values: FormValues) {
|
|
104
|
+
const result = await submitCompanyProfile({
|
|
105
|
+
...values,
|
|
106
|
+
systemName: values.systemName || null,
|
|
107
|
+
logoUrl: values.logoUrl || null,
|
|
108
|
+
})
|
|
109
|
+
if (result.success) toast.success("Đã lưu hồ sơ công ty")
|
|
110
|
+
else toast.error(result.error ?? "Lưu thất bại")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<Card>
|
|
115
|
+
<CardHeader>
|
|
116
|
+
<CardTitle>Thông tin công ty</CardTitle>
|
|
117
|
+
<CardDescription>
|
|
118
|
+
Dùng cho biểu mẫu in, hoá đơn và phần hiển thị thương hiệu của app.
|
|
119
|
+
</CardDescription>
|
|
120
|
+
</CardHeader>
|
|
121
|
+
<CardContent>
|
|
122
|
+
<Form {...form}>
|
|
123
|
+
<form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
|
|
124
|
+
{/* Logo công ty — dùng cho sidebar/trang in; upload qua /api/upload,
|
|
125
|
+
file phục vụ lại qua /api/files. */}
|
|
126
|
+
<div className="space-y-2">
|
|
127
|
+
<span className="text-sm font-medium">Logo công ty</span>
|
|
128
|
+
<div className="flex items-center gap-4">
|
|
129
|
+
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted">
|
|
130
|
+
{logoUrl ? (
|
|
131
|
+
// eslint-disable-next-line @next/next/no-img-element -- ảnh động theo DB, không qua next/image
|
|
132
|
+
<img src={logoUrl} alt="Logo công ty" className="h-full w-full object-contain" />
|
|
133
|
+
) : (
|
|
134
|
+
<span className="text-[11px] text-muted-foreground">Chưa có</span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
<div className="flex flex-col gap-2">
|
|
138
|
+
<Input
|
|
139
|
+
type="file"
|
|
140
|
+
accept="image/*"
|
|
141
|
+
disabled={isUploadingLogo}
|
|
142
|
+
onChange={handleLogoChange}
|
|
143
|
+
className="max-w-xs"
|
|
144
|
+
/>
|
|
145
|
+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
146
|
+
{isUploadingLogo && <span>Đang tải lên...</span>}
|
|
147
|
+
{logoUrl && !isUploadingLogo && (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
className="underline"
|
|
151
|
+
onClick={() => form.setValue("logoUrl", "", { shouldDirty: true })}
|
|
152
|
+
>
|
|
153
|
+
Gỡ logo
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
{FIELDS.map((f) => (
|
|
161
|
+
<FormField
|
|
162
|
+
key={f.name}
|
|
163
|
+
control={form.control}
|
|
164
|
+
name={f.name}
|
|
165
|
+
render={({ field }) => (
|
|
166
|
+
<FormItem>
|
|
167
|
+
<FormLabel>{f.label}</FormLabel>
|
|
168
|
+
<FormControl>
|
|
169
|
+
<Input placeholder={f.placeholder} {...field} />
|
|
170
|
+
</FormControl>
|
|
171
|
+
<FormMessage />
|
|
172
|
+
</FormItem>
|
|
173
|
+
)}
|
|
174
|
+
/>
|
|
175
|
+
))}
|
|
176
|
+
<Button type="submit" disabled={isSubmitting} className="w-fit">
|
|
177
|
+
{isSubmitting ? "Đang lưu..." : "Lưu hồ sơ"}
|
|
178
|
+
</Button>
|
|
179
|
+
</form>
|
|
180
|
+
</Form>
|
|
181
|
+
</CardContent>
|
|
182
|
+
</Card>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
|
|
3
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
4
|
+
import { getSystemCompany } from "@/server/services/company-service"
|
|
5
|
+
|
|
6
|
+
import { CompanyProfileForm } from "./company-profile-form"
|
|
7
|
+
|
|
8
|
+
export const metadata: Metadata = {
|
|
9
|
+
title: "Hồ sơ công ty",
|
|
10
|
+
description: "Thông tin công ty gốc dùng cho biểu mẫu, hoá đơn và branding",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default async function CompanyProfilePage({
|
|
14
|
+
params,
|
|
15
|
+
}: {
|
|
16
|
+
params: Promise<{ lang: string }>
|
|
17
|
+
}) {
|
|
18
|
+
const { lang } = await params
|
|
19
|
+
await requirePageAccess(lang, "system-setting")
|
|
20
|
+
const company = await getSystemCompany()
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 md:p-6">
|
|
24
|
+
<h1 className="text-2xl font-bold tracking-tight">Hồ sơ công ty</h1>
|
|
25
|
+
<CompanyProfileForm initialData={company} />
|
|
26
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { ErrorView } from "@goerp/core/ui"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Boundary cho MỌI trang trong vỏ app: sidebar/tab/header vẫn còn, trang hỏng
|
|
7
|
+
* chỉ chiếm phần nội dung — sự cố một trang không thành "cả hệ thống hỏng".
|
|
8
|
+
*/
|
|
9
|
+
export default function MainError({
|
|
10
|
+
error,
|
|
11
|
+
reset,
|
|
12
|
+
}: {
|
|
13
|
+
error: Error & { digest?: string }
|
|
14
|
+
reset: () => void
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<ErrorView
|
|
18
|
+
error={error}
|
|
19
|
+
reset={reset}
|
|
20
|
+
title="Không tải được trang"
|
|
21
|
+
description="Hệ thống gặp sự cố khi hiển thị trang này. Dữ liệu và quyền truy cập của bạn không thay đổi. Bấm Thử lại, hoặc chuyển sang phân hệ khác; nếu vẫn lỗi, gửi mã lỗi bên dưới cho quản trị viên."
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { StatBar } from "@goerp/core/ui"
|
|
2
2
|
import { ArrowRight, Building2, ShieldCheck, Sparkles } from "lucide-react"
|
|
3
3
|
import Link from "next/link"
|
|
4
|
+
import { redirect } from "next/navigation"
|
|
4
5
|
|
|
6
|
+
import { i18n } from "@/configs/i18n"
|
|
7
|
+
import { getSession } from "@/lib/auth"
|
|
5
8
|
import { db } from "@/lib/prisma"
|
|
6
9
|
|
|
7
10
|
export const dynamic = "force-dynamic"
|
|
@@ -9,6 +12,13 @@ export const dynamic = "force-dynamic"
|
|
|
9
12
|
// Sample ERP dashboard. Server component: query counts, hand StatBar plain data.
|
|
10
13
|
// Replace the KPIs + quick actions with your domain's once you add entities.
|
|
11
14
|
export default async function HomePage() {
|
|
15
|
+
// Proxy chỉ kiểm tra SỰ TỒN TẠI cookie (Edge không xác minh chữ ký được) —
|
|
16
|
+
// page nào tự query dữ liệu PHẢI tự xác minh phiên thật. Thiếu dòng này thì
|
|
17
|
+
// một cookie rác (hoặc cookie của app GoERP khác cùng localhost) đủ để xem
|
|
18
|
+
// dashboard: pilot spartronics 2026-08-07 dính đúng lỗi đó.
|
|
19
|
+
const session = await getSession()
|
|
20
|
+
if (!session?.user) redirect(`/${i18n.defaultLocale}/sign-in`)
|
|
21
|
+
|
|
12
22
|
const [departmentCount, activeDepartments, userCount] = await Promise.all([
|
|
13
23
|
db.department.count(),
|
|
14
24
|
db.department.count({ where: { status: "active" } }),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
2
|
+
import {
|
|
3
|
+
getActionsForPermissionMatrix,
|
|
4
|
+
getResourcesForPermissionMatrix,
|
|
5
|
+
} from "@goerp/core/rbac/resource-service"
|
|
6
|
+
// Trang tài nguyên = catalog TRA CỨU (đọc từ DB đã rbac-sync). Khai báo/chỉnh
|
|
7
|
+
// sửa nằm trong Permission Registry (src/configs/permissions) + rbac-sync,
|
|
8
|
+
// không sửa trực tiếp ở đây.
|
|
9
|
+
import { ResourceCatalogPage } from "@goerp/core/rbac/pages"
|
|
10
|
+
|
|
11
|
+
import { getActionMetaMap, getLookupPolicyMap } from "@/configs/permissions"
|
|
12
|
+
import { MENU_TREE } from "@/configs/permissions/menu-tree"
|
|
13
|
+
import { db } from "@/lib/prisma"
|
|
14
|
+
|
|
15
|
+
export const metadata = { title: "Tài nguyên" }
|
|
16
|
+
|
|
17
|
+
export default async function ResourcesPage({
|
|
18
|
+
params,
|
|
19
|
+
}: {
|
|
20
|
+
params: Promise<{ lang: string }>
|
|
21
|
+
}) {
|
|
22
|
+
const { lang } = await params
|
|
23
|
+
await requirePageAccess(lang, "resource")
|
|
24
|
+
|
|
25
|
+
const [resources, actions] = await Promise.all([
|
|
26
|
+
getResourcesForPermissionMatrix(db),
|
|
27
|
+
getActionsForPermissionMatrix(db),
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<ResourceCatalogPage
|
|
32
|
+
resources={resources}
|
|
33
|
+
actions={actions}
|
|
34
|
+
menuTree={MENU_TREE}
|
|
35
|
+
actionMeta={getActionMetaMap()}
|
|
36
|
+
lookupPolicies={getLookupPolicyMap()}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SessionsPage } from "@goerp/core/security"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: "Quản lý phiên đăng nhập" }
|
|
8
|
+
export const dynamic = "force-dynamic"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Trang QUẢN TRỊ — quản lý phiên đăng nhập (Better Auth) của MỌI user.
|
|
12
|
+
* Màn hình sẵn của core; app chỉ gác quyền `user:view` (cùng resource với
|
|
13
|
+
* trang Người dùng) và cung cấp 3 endpoint /api/security/sessions.
|
|
14
|
+
*/
|
|
15
|
+
export default async function SecuritySessionsPage({
|
|
16
|
+
params,
|
|
17
|
+
}: {
|
|
18
|
+
params: Promise<{ lang: string }>
|
|
19
|
+
}) {
|
|
20
|
+
const { lang } = await params
|
|
21
|
+
await requirePageAccess(lang, "user")
|
|
22
|
+
|
|
23
|
+
return <SessionsPage />
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { SystemCategoryPage } from "@goerp/core/system/pages/system-category-page"
|
|
2
|
+
import { getSystemCategoryGroupsData } from "@goerp/core/system/services/system-category-service"
|
|
3
|
+
|
|
4
|
+
import type { Metadata } from "next"
|
|
5
|
+
|
|
6
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
7
|
+
import { db } from "@/lib/prisma"
|
|
8
|
+
|
|
9
|
+
export const metadata: Metadata = { title: "Danh mục hệ thống" }
|
|
10
|
+
export const dynamic = "force-dynamic"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Danh mục hệ thống — màn hình sẵn của core (sidebar nhóm + danh sách mục).
|
|
14
|
+
* Trang chỉ nạp danh sách nhóm ban đầu; mọi thao tác CRUD sau đó component
|
|
15
|
+
* core tự gọi /api/system-categories + /api/system-category-groups.
|
|
16
|
+
*/
|
|
17
|
+
export default async function SystemCategoriesPage({
|
|
18
|
+
params,
|
|
19
|
+
}: {
|
|
20
|
+
params: Promise<{ lang: string }>
|
|
21
|
+
}) {
|
|
22
|
+
const { lang } = await params
|
|
23
|
+
await requirePageAccess(lang, "system-category")
|
|
24
|
+
|
|
25
|
+
const { items: initialGroups } = await getSystemCategoryGroupsData(db, {
|
|
26
|
+
pageSize: 100, // nạp hết nhóm cho sidebar
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
return <SystemCategoryPage initialGroups={initialGroups} />
|
|
30
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use server"
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers"
|
|
4
|
+
import { z } from "zod"
|
|
5
|
+
|
|
6
|
+
import { getAuth } from "@/lib/better-auth"
|
|
7
|
+
|
|
8
|
+
const schema = z
|
|
9
|
+
.object({
|
|
10
|
+
currentPassword: z.string().min(1, "Nhập mật khẩu hiện tại"),
|
|
11
|
+
newPassword: z.string().min(6, "Mật khẩu mới tối thiểu 6 ký tự"),
|
|
12
|
+
confirmPassword: z.string().min(1, "Nhập lại mật khẩu mới"),
|
|
13
|
+
})
|
|
14
|
+
.refine((d) => d.newPassword === d.confirmPassword, {
|
|
15
|
+
message: "Mật khẩu nhập lại không khớp",
|
|
16
|
+
path: ["confirmPassword"],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export interface ChangePasswordState {
|
|
20
|
+
success?: boolean
|
|
21
|
+
message?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Đổi mật khẩu của CHÍNH người đang đăng nhập — đi qua Better Auth
|
|
26
|
+
* `changePassword` (tự verify mật khẩu cũ + hash bằng bcrypt đã cấu hình +
|
|
27
|
+
* thu hồi các phiên khác), không tự chế so bcrypt ở app.
|
|
28
|
+
*/
|
|
29
|
+
export async function changePassword(
|
|
30
|
+
_prev: ChangePasswordState,
|
|
31
|
+
formData: FormData,
|
|
32
|
+
): Promise<ChangePasswordState> {
|
|
33
|
+
// Không getSession() trần ở đây (guardrail server-action-bare-session):
|
|
34
|
+
// auth.api.changePassword tự validate phiên từ headers — không phiên là ném.
|
|
35
|
+
const parsed = schema.safeParse({
|
|
36
|
+
currentPassword: formData.get("currentPassword"),
|
|
37
|
+
newPassword: formData.get("newPassword"),
|
|
38
|
+
confirmPassword: formData.get("confirmPassword"),
|
|
39
|
+
})
|
|
40
|
+
if (!parsed.success) {
|
|
41
|
+
return { message: parsed.error.issues[0]?.message ?? "Dữ liệu không hợp lệ" }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
await getAuth().api.changePassword({
|
|
46
|
+
headers: await headers(),
|
|
47
|
+
body: {
|
|
48
|
+
currentPassword: parsed.data.currentPassword,
|
|
49
|
+
newPassword: parsed.data.newPassword,
|
|
50
|
+
revokeOtherSessions: true,
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
return { success: true, message: "Đã đổi mật khẩu." }
|
|
54
|
+
} catch {
|
|
55
|
+
return {
|
|
56
|
+
message:
|
|
57
|
+
"Không đổi được mật khẩu — kiểm tra mật khẩu hiện tại, hoặc đăng nhập lại nếu phiên đã hết hạn.",
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|