@goplusvn/core 0.1.70 → 0.1.72

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.
Files changed (175) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/bin/goerp-init.mjs +15 -0
  3. package/package.json +2 -1
  4. package/src/auth/index.ts +5 -1
  5. package/src/auth/proxy-gate.ts +21 -2
  6. package/src/configs/entities/departments.config.ts +1 -0
  7. package/src/configs/entities/material-categories.config.ts +1 -0
  8. package/src/cron/__tests__/cron-schedule.test.ts +76 -0
  9. package/src/cron/cron-schedule.ts +128 -0
  10. package/src/cron/simple-cron-job.ts +83 -69
  11. package/src/crud/components/crud-export-button.tsx +3 -2
  12. package/src/crud/components/crud-page.tsx +56 -25
  13. package/src/crud/components/crud-row-actions.tsx +16 -4
  14. package/src/crud/components/crud-table.tsx +13 -1
  15. package/src/crud/crud-route-handlers.test.ts +352 -0
  16. package/src/crud/crud-route-handlers.ts +260 -13
  17. package/src/crud/index.ts +3 -0
  18. package/src/crud/lib/coerce.test.ts +118 -0
  19. package/src/crud/lib/coerce.ts +95 -0
  20. package/src/crud/lib/crud-utils.ts +4 -0
  21. package/src/crud/lib/entity-endpoints.test.ts +41 -0
  22. package/src/crud/lib/entity-endpoints.ts +23 -0
  23. package/src/crud/lib/errors.ts +14 -0
  24. package/src/crud/lib/import-request.ts +99 -0
  25. package/src/crud/lib/mutation-builder.test.ts +114 -0
  26. package/src/crud/lib/mutation-builder.ts +27 -34
  27. package/src/crud/lib/permissions.test.ts +57 -0
  28. package/src/crud/lib/permissions.ts +25 -13
  29. package/src/crud/lib/query-builder.ts +13 -6
  30. package/src/crud/lib/translate-config.ts +16 -2
  31. package/src/crud/pages/entity-crud-page.tsx +11 -8
  32. package/src/crud/server-service.test.ts +112 -0
  33. package/src/crud/server-service.ts +52 -27
  34. package/src/crud/server.ts +11 -1
  35. package/src/guardrails/index.ts +1 -0
  36. package/src/guardrails/rules/auth.ts +54 -1
  37. package/src/guardrails/rules/design.ts +3 -2
  38. package/src/guardrails/types.ts +17 -8
  39. package/src/providers/brand-theme.ts +20 -0
  40. package/src/rbac/pages/permission-catalog-pages.tsx +3 -1
  41. package/src/security/index.ts +2 -0
  42. package/src/security/pages/sessions-page.tsx +516 -0
  43. package/src/types/index.ts +75 -10
  44. package/src/ui/auth/sign-in-form.tsx +64 -9
  45. package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
  46. package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
  47. package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
  48. package/src/ui/data-display/data-table/data-table.tsx +68 -23
  49. package/src/ui/data-display/data-table-pagination.tsx +25 -8
  50. package/src/ui/data-display/index.tsx +1 -0
  51. package/src/ui/data-display/use-client-pagination.ts +48 -0
  52. package/src/ui/errors/error-view.tsx +164 -0
  53. package/src/ui/errors/index.ts +2 -0
  54. package/src/ui/errors/not-found-view.tsx +44 -0
  55. package/src/ui/index.tsx +1 -0
  56. package/src/ui/layout/logo.tsx +54 -19
  57. package/src/user/pages/users-client-page.tsx +12 -7
  58. package/templates/starter-app/.env.example +4 -0
  59. package/templates/starter-app/AGENTS.md +1 -1
  60. package/templates/starter-app/README.md +6 -2
  61. package/templates/starter-app/husky/pre-commit +10 -0
  62. package/templates/starter-app/package.json +27 -2
  63. package/templates/starter-app/prisma/migrations/20260807115347_platform_pages/migration.sql +87 -0
  64. package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
  65. package/templates/starter-app/prisma/schema/organization.prisma +20 -0
  66. package/templates/starter-app/prisma/schema/system.prisma +74 -0
  67. package/templates/starter-app/prisma/seed.ts +7 -5
  68. package/templates/starter-app/scripts/migration-new.mjs +89 -0
  69. package/templates/starter-app/scripts/rbac-sync.ts +49 -21
  70. package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
  71. package/templates/starter-app/src/app/[lang]/(main)/actions/page.tsx +37 -0
  72. package/templates/starter-app/src/app/[lang]/(main)/admin/system/cache/page.tsx +68 -0
  73. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
  74. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
  75. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
  76. package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
  77. package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
  78. package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
  79. package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
  80. package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
  81. package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
  82. package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
  83. package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
  84. package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -0
  85. package/templates/starter-app/src/app/[lang]/(main)/security/sessions/page.tsx +24 -0
  86. package/templates/starter-app/src/app/[lang]/(main)/system-categories/page.tsx +30 -0
  87. package/templates/starter-app/src/app/[lang]/(main)/user/profile/actions.ts +62 -0
  88. package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
  89. package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
  90. package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -0
  91. package/templates/starter-app/src/app/[lang]/[...not-found]/page.tsx +9 -0
  92. package/templates/starter-app/src/app/[lang]/error.tsx +26 -0
  93. package/templates/starter-app/src/app/[lang]/layout.tsx +2 -2
  94. package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
  95. package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
  96. package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
  97. package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
  98. package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
  99. package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
  100. package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
  101. package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
  102. package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
  103. package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
  104. package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
  105. package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
  106. package/templates/starter-app/src/app/api/branches/route.ts +26 -0
  107. package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
  108. package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
  109. package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
  110. package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
  111. package/templates/starter-app/src/app/api/departments/route.ts +26 -0
  112. package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
  113. package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
  114. package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
  115. package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
  116. package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
  117. package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
  118. package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
  119. package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
  120. package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
  121. package/templates/starter-app/src/app/api/roles/route.ts +2 -1
  122. package/templates/starter-app/src/app/api/security/sessions/[id]/route.ts +52 -0
  123. package/templates/starter-app/src/app/api/security/sessions/revoke-user/route.ts +44 -0
  124. package/templates/starter-app/src/app/api/security/sessions/route.ts +101 -0
  125. package/templates/starter-app/src/app/api/suppliers/route.ts +13 -0
  126. package/templates/starter-app/src/app/api/system-categories/route.ts +179 -0
  127. package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
  128. package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
  129. package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
  130. package/templates/starter-app/src/app/api/upload/route.ts +1 -2
  131. package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
  132. package/templates/starter-app/src/app/api/users/route.ts +141 -0
  133. package/templates/starter-app/src/app/global-error.tsx +135 -0
  134. package/templates/starter-app/src/app/globals.css +0 -1
  135. package/templates/starter-app/src/app/icon.svg +6 -0
  136. package/templates/starter-app/src/app/manifest.ts +26 -0
  137. package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +5 -4
  138. package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
  139. package/templates/starter-app/src/configs/entities/index.ts +5 -1
  140. package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
  141. package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
  142. package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
  143. package/templates/starter-app/src/configs/i18n.ts +6 -6
  144. package/templates/starter-app/src/configs/permissions/index.ts +37 -0
  145. package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +11 -0
  146. package/templates/starter-app/src/configs/permissions/menu-tree.ts +37 -0
  147. package/templates/starter-app/src/configs/permissions/system.permissions.ts +29 -0
  148. package/templates/starter-app/src/configs/tenant.ts +2 -1
  149. package/templates/starter-app/src/data/dictionary.ts +77 -4
  150. package/templates/starter-app/src/data/navigations.ts +60 -1
  151. package/templates/starter-app/src/instrumentation.ts +5 -16
  152. package/templates/starter-app/src/lib/action-guard.ts +25 -0
  153. package/templates/starter-app/src/lib/api-handler.ts +27 -19
  154. package/templates/starter-app/src/lib/auth.ts +3 -1
  155. package/templates/starter-app/src/lib/better-auth.ts +8 -0
  156. package/templates/starter-app/src/lib/branch-scope.ts +1 -1
  157. package/templates/starter-app/src/lib/crud/index.ts +5 -10
  158. package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
  159. package/templates/starter-app/src/lib/logger.ts +11 -4
  160. package/templates/starter-app/src/lib/page-guard.ts +2 -3
  161. package/templates/starter-app/src/lib/prisma.ts +16 -7
  162. package/templates/starter-app/src/lib/rbac/access.ts +2 -2
  163. package/templates/starter-app/src/lib/storage.ts +3 -1
  164. package/templates/starter-app/src/providers/index.tsx +2 -1
  165. package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
  166. package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
  167. package/templates/starter-app/src/proxy.ts +8 -3
  168. package/templates/starter-app/src/server/services/company-service.ts +49 -0
  169. package/templates/starter-app/src/server/services/notification-service.ts +6 -6
  170. package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
  171. package/templates/starter-app/src/server/services/user-service.ts +104 -0
  172. package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
  173. package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
  174. package/templates/starter-app/tsconfig.json +10 -0
  175. package/templates/starter-app/vitest.config.ts +17 -1
@@ -0,0 +1,195 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle,
10
+ Form,
11
+ FormControl,
12
+ FormField,
13
+ FormItem,
14
+ FormLabel,
15
+ FormMessage,
16
+ } from "@goerp/core/ui"
17
+ import { Button } from "@goerp/core/ui/primitives/button"
18
+ import { Input } from "@goerp/core/ui/primitives/input"
19
+ import { zodResolver } from "@hookform/resolvers/zod"
20
+ import { useForm } from "react-hook-form"
21
+ import { toast } from "sonner"
22
+ import { z } from "zod"
23
+
24
+ import { submitCompanyProfile } from "./actions"
25
+
26
+ const schema = z.object({
27
+ name: z.string().min(1, "Nhập tên công ty"),
28
+ taxCode: z.string().min(1, "Nhập mã số thuế"),
29
+ address: z.string().min(1, "Nhập địa chỉ"),
30
+ phone: z.string().min(1, "Nhập số điện thoại"),
31
+ email: z.string().email("Email không hợp lệ"),
32
+ systemName: z.string().optional(),
33
+ logoUrl: z.string().optional(),
34
+ })
35
+
36
+ type FormValues = z.infer<typeof schema>
37
+
38
+ interface CompanyProfileFormProps {
39
+ initialData: {
40
+ name: string
41
+ taxCode: string
42
+ address: string
43
+ phone: string
44
+ email: string
45
+ logoUrl?: string | null
46
+ systemName?: string | null
47
+ }
48
+ }
49
+
50
+ const FIELDS: Array<{
51
+ name: keyof FormValues
52
+ label: string
53
+ placeholder?: string
54
+ }> = [
55
+ { name: "name", label: "Tên công ty (trên biểu mẫu/hoá đơn)" },
56
+ { name: "taxCode", label: "Mã số thuế" },
57
+ { name: "address", label: "Địa chỉ" },
58
+ { name: "phone", label: "Số điện thoại" },
59
+ { name: "email", label: "Email" },
60
+ {
61
+ name: "systemName",
62
+ label: "Tên hệ thống (hiển thị trên app)",
63
+ placeholder: "Bỏ trống = dùng tên công ty",
64
+ },
65
+ ]
66
+
67
+ export function CompanyProfileForm({ initialData }: CompanyProfileFormProps) {
68
+ const form = useForm<FormValues>({
69
+ resolver: zodResolver(schema),
70
+ defaultValues: {
71
+ name: initialData.name ?? "",
72
+ taxCode: initialData.taxCode ?? "",
73
+ address: initialData.address ?? "",
74
+ phone: initialData.phone ?? "",
75
+ email: initialData.email ?? "",
76
+ systemName: initialData.systemName ?? "",
77
+ logoUrl: initialData.logoUrl ?? "",
78
+ },
79
+ })
80
+ const { isSubmitting } = form.formState
81
+ const [isUploadingLogo, setIsUploadingLogo] = React.useState(false)
82
+ const logoUrl = form.watch("logoUrl")
83
+
84
+ async function handleLogoChange(e: React.ChangeEvent<HTMLInputElement>) {
85
+ const file = e.target.files?.[0]
86
+ if (!file) return
87
+ setIsUploadingLogo(true)
88
+ try {
89
+ const formData = new FormData()
90
+ formData.append("file", file)
91
+ const res = await fetch("/api/upload", { method: "POST", body: formData })
92
+ if (!res.ok) {
93
+ const err = await res.json().catch(() => ({}))
94
+ throw new Error(err.error || "Tải logo thất bại")
95
+ }
96
+ const data = await res.json()
97
+ form.setValue("logoUrl", data.url as string, { shouldDirty: true })
98
+ } catch (err) {
99
+ toast.error(err instanceof Error ? err.message : "Tải logo thất bại")
100
+ } finally {
101
+ setIsUploadingLogo(false)
102
+ e.target.value = ""
103
+ }
104
+ }
105
+
106
+ async function onSubmit(values: FormValues) {
107
+ const result = await submitCompanyProfile({
108
+ ...values,
109
+ systemName: values.systemName || null,
110
+ logoUrl: values.logoUrl || null,
111
+ })
112
+ if (result.success) toast.success("Đã lưu hồ sơ công ty")
113
+ else toast.error(result.error ?? "Lưu thất bại")
114
+ }
115
+
116
+ return (
117
+ <Card>
118
+ <CardHeader>
119
+ <CardTitle>Thông tin công ty</CardTitle>
120
+ <CardDescription>
121
+ Dùng cho biểu mẫu in, hoá đơn và phần hiển thị thương hiệu của app.
122
+ </CardDescription>
123
+ </CardHeader>
124
+ <CardContent>
125
+ <Form {...form}>
126
+ <form onSubmit={form.handleSubmit(onSubmit)} className="grid gap-4">
127
+ {/* Logo công ty — dùng cho sidebar/trang in; upload qua /api/upload,
128
+ file phục vụ lại qua /api/files. */}
129
+ <div className="space-y-2">
130
+ <span className="text-sm font-medium">Logo công ty</span>
131
+ <div className="flex items-center gap-4">
132
+ <div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-muted">
133
+ {logoUrl ? (
134
+ // eslint-disable-next-line @next/next/no-img-element -- ảnh động theo DB, không qua next/image
135
+ <img
136
+ src={logoUrl}
137
+ alt="Logo công ty"
138
+ className="h-full w-full object-contain"
139
+ />
140
+ ) : (
141
+ <span className="text-[11px] text-muted-foreground">
142
+ Chưa có
143
+ </span>
144
+ )}
145
+ </div>
146
+ <div className="flex flex-col gap-2">
147
+ <Input
148
+ type="file"
149
+ accept="image/*"
150
+ disabled={isUploadingLogo}
151
+ onChange={handleLogoChange}
152
+ className="max-w-xs"
153
+ />
154
+ <div className="flex items-center gap-3 text-xs text-muted-foreground">
155
+ {isUploadingLogo && <span>Đang tải lên...</span>}
156
+ {logoUrl && !isUploadingLogo && (
157
+ <button
158
+ type="button"
159
+ className="underline"
160
+ onClick={() =>
161
+ form.setValue("logoUrl", "", { shouldDirty: true })
162
+ }
163
+ >
164
+ Gỡ logo
165
+ </button>
166
+ )}
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ {FIELDS.map((f) => (
172
+ <FormField
173
+ key={f.name}
174
+ control={form.control}
175
+ name={f.name}
176
+ render={({ field }) => (
177
+ <FormItem>
178
+ <FormLabel>{f.label}</FormLabel>
179
+ <FormControl>
180
+ <Input placeholder={f.placeholder} {...field} />
181
+ </FormControl>
182
+ <FormMessage />
183
+ </FormItem>
184
+ )}
185
+ />
186
+ ))}
187
+ <Button type="submit" disabled={isSubmitting} className="w-fit">
188
+ {isSubmitting ? "Đang lưu..." : "Lưu hồ sơ"}
189
+ </Button>
190
+ </form>
191
+ </Form>
192
+ </CardContent>
193
+ </Card>
194
+ )
195
+ }
@@ -0,0 +1,29 @@
1
+ import { getSystemCompany } from "@/server/services/company-service"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ import { requirePageAccess } from "@/lib/page-guard"
6
+
7
+ import { CompanyProfileForm } from "./company-profile-form"
8
+
9
+ export const metadata: Metadata = {
10
+ title: "Hồ sơ công ty",
11
+ description: "Thông tin công ty gốc dùng cho biểu mẫu, hoá đơn và branding",
12
+ }
13
+
14
+ export default async function CompanyProfilePage({
15
+ params,
16
+ }: {
17
+ params: Promise<{ lang: string }>
18
+ }) {
19
+ const { lang } = await params
20
+ await requirePageAccess(lang, "system-setting")
21
+ const company = await getSystemCompany()
22
+
23
+ return (
24
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 p-4 md:p-6">
25
+ <h1 className="text-2xl font-bold tracking-tight">Hồ sơ công ty</h1>
26
+ <CompanyProfileForm initialData={company} />
27
+ </div>
28
+ )
29
+ }
@@ -2,8 +2,9 @@ import { EntityCrudPage } from "@goerp/core/crud"
2
2
 
3
3
  import type { LocaleType } from "@goerp/core/types"
4
4
 
5
- import { getEntityConfig } from "@/configs/entities"
6
5
  import { dictionary } from "@/data/dictionary"
6
+
7
+ import { getEntityConfig } from "@/configs/entities"
7
8
  import { getSession } from "@/lib/auth"
8
9
 
9
10
  export const dynamic = "force-dynamic"
@@ -22,7 +23,8 @@ export default async function CrudEntityPage({
22
23
  if (!config) {
23
24
  return (
24
25
  <div className="p-6 text-sm text-muted-foreground">
25
- Không tìm thấy entity: <code className="rounded bg-muted px-1">{entity}</code>
26
+ Không tìm thấy entity:{" "}
27
+ <code className="rounded bg-muted px-1">{entity}</code>
26
28
  </div>
27
29
  )
28
30
  }
@@ -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,9 +1,10 @@
1
1
  import type { ReactNode } from "react"
2
2
 
3
- import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
4
3
  import { dictionary } from "@/data/dictionary"
5
4
  import { navigations } from "@/data/navigations"
6
5
 
6
+ import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
7
+
7
8
  export default function MainAreaLayout({ children }: { children: ReactNode }) {
8
9
  return (
9
10
  <MainLayoutWrapper dictionary={dictionary} navigation={navigations}>
@@ -0,0 +1,6 @@
1
+ import { NotFoundView } from "@goerp/core/ui"
2
+
3
+ /** `notFound()` trong vỏ app — bản ghi bị xoá, id sai. Vỏ app vẫn còn. */
4
+ export default function MainNotFound() {
5
+ return <NotFoundView />
6
+ }
@@ -1,7 +1,10 @@
1
+ import Link from "next/link"
2
+ import { redirect } from "next/navigation"
1
3
  import { StatBar } from "@goerp/core/ui"
2
4
  import { ArrowRight, Building2, ShieldCheck, Sparkles } from "lucide-react"
3
- import Link from "next/link"
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" } }),
@@ -23,8 +33,8 @@ export default async function HomePage() {
23
33
  </h1>
24
34
  <p className="text-sm text-muted-foreground">
25
35
  App mẫu chạy trên{" "}
26
- <code className="rounded bg-muted px-1">@goerp/core</code> — auth, RBAC,
27
- CRUD engine và design system đều từ core.
36
+ <code className="rounded bg-muted px-1">@goerp/core</code> — auth,
37
+ RBAC, CRUD engine và design system đều từ core.
28
38
  </p>
29
39
  </div>
30
40
 
@@ -0,0 +1,39 @@
1
+ // Trang tài nguyên = catalog TRA CỨU (đọc từ DB đã rbac-sync). Khai báo/chỉnh
2
+ // sửa nằm trong Permission Registry (src/configs/permissions) + rbac-sync,
3
+ // không sửa trực tiếp ở đây.
4
+ import { ResourceCatalogPage } from "@goerp/core/rbac/pages"
5
+ import {
6
+ getActionsForPermissionMatrix,
7
+ getResourcesForPermissionMatrix,
8
+ } from "@goerp/core/rbac/resource-service"
9
+
10
+ import { getActionMetaMap, getLookupPolicyMap } from "@/configs/permissions"
11
+ import { MENU_TREE } from "@/configs/permissions/menu-tree"
12
+ import { requirePageAccess } from "@/lib/page-guard"
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
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import type { Metadata } from "next"
10
10
 
11
11
  import { dictionary } from "@/data/dictionary"
12
+
12
13
  import { requirePageAccess } from "@/lib/page-guard"
13
14
  import { db } from "@/lib/prisma"
14
15
 
@@ -29,7 +30,9 @@ export default async function EditRolePage({
29
30
  getActionsForPermissionMatrix(db),
30
31
  db.role.findUnique({
31
32
  where: { id },
32
- include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
33
+ include: {
34
+ rolePermissions: { select: { resourceCode: true, actionCode: true } },
35
+ },
33
36
  }),
34
37
  ])
35
38
 
@@ -52,7 +55,7 @@ export default async function EditRolePage({
52
55
  // Ma trận quyền của core đọc dạng "action:resource" — đảo thứ tự là
53
56
  // không ô nào tick, mà cũng chẳng có lỗi nào hiện ra.
54
57
  permissions: role.rolePermissions.map(
55
- (rp) => `${rp.actionCode}:${rp.resourceCode}`,
58
+ (rp) => `${rp.actionCode}:${rp.resourceCode}`
56
59
  ),
57
60
  }}
58
61
  />
@@ -8,6 +8,7 @@ import {
8
8
  import type { Metadata } from "next"
9
9
 
10
10
  import { dictionary } from "@/data/dictionary"
11
+
11
12
  import { requirePageAccess } from "@/lib/page-guard"
12
13
  import { db } from "@/lib/prisma"
13
14
 
@@ -9,6 +9,7 @@ import { getRolesData } from "@goerp/core/rbac/role-service"
9
9
  import type { Metadata } from "next"
10
10
 
11
11
  import { dictionary } from "@/data/dictionary"
12
+
12
13
  import { requirePageAccess } from "@/lib/page-guard"
13
14
  import { db } from "@/lib/prisma"
14
15
 
@@ -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,62 @@
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 {
42
+ message: parsed.error.issues[0]?.message ?? "Dữ liệu không hợp lệ",
43
+ }
44
+ }
45
+
46
+ try {
47
+ await getAuth().api.changePassword({
48
+ headers: await headers(),
49
+ body: {
50
+ currentPassword: parsed.data.currentPassword,
51
+ newPassword: parsed.data.newPassword,
52
+ revokeOtherSessions: true,
53
+ },
54
+ })
55
+ return { success: true, message: "Đã đổi mật khẩu." }
56
+ } catch {
57
+ return {
58
+ message:
59
+ "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.",
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,41 @@
1
+ import { redirect } from "next/navigation"
2
+
3
+ import type { Metadata } from "next"
4
+
5
+ import { i18n } from "@/configs/i18n"
6
+ import { getSession } from "@/lib/auth"
7
+ import { db } from "@/lib/prisma"
8
+
9
+ import { ProfileClientPage } from "./profile-client-page"
10
+
11
+ export const metadata: Metadata = { title: "Hồ sơ cá nhân" }
12
+
13
+ /**
14
+ * Hồ sơ NGƯỜI ĐANG ĐĂNG NHẬP — đích của mục "Hồ sơ" trong dropdown user của
15
+ * core MainLayout (/user/profile). Không cần quyền gì ngoài phiên hợp lệ:
16
+ * ai cũng được xem thông tin của chính mình.
17
+ */
18
+ export default async function UserProfilePage() {
19
+ const session = await getSession()
20
+ if (!session?.user?.id) redirect(`/${i18n.defaultLocale}/sign-in`)
21
+
22
+ const user = await db.user.findUnique({
23
+ where: { id: session.user.id },
24
+ select: {
25
+ id: true,
26
+ name: true,
27
+ email: true,
28
+ image: true,
29
+ createdAt: true,
30
+ userRoles: {
31
+ select: { roleCode: true, role: { select: { name: true } } },
32
+ },
33
+ userBranches: {
34
+ select: { isDefault: true, branch: { select: { name: true } } },
35
+ },
36
+ },
37
+ })
38
+ if (!user) redirect(`/${i18n.defaultLocale}`)
39
+
40
+ return <ProfileClientPage user={user} />
41
+ }