@goplusvn/core 0.1.59 → 0.1.60
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/PLATFORM.md +18 -0
- package/bin/goerp-init.mjs +141 -0
- package/package.json +4 -4
- package/src/cron/db-cron-manager.ts +3 -3
- package/src/cron/index.ts +2 -2
- package/src/{infrastructure/cron/cron-manager.ts → cron/simple-cron-job.ts} +1 -1
- package/src/infrastructure/__tests__/architecture-verification.spec.ts +13 -97
- package/src/infrastructure/index.ts +4 -7
- package/src/ui/management/index.ts +3 -2
- package/templates/starter-app/.dockerignore +41 -0
- package/templates/starter-app/.env.example +25 -0
- package/templates/starter-app/AGENTS.md +52 -0
- package/templates/starter-app/Dockerfile +74 -0
- package/templates/starter-app/README.md +141 -0
- package/templates/starter-app/gitignore +9 -0
- package/templates/starter-app/next.config.mjs +50 -0
- package/templates/starter-app/package.json +55 -0
- package/templates/starter-app/postcss.config.mjs +5 -0
- package/templates/starter-app/prisma/migrations/20260801000000_init/migration.sql +504 -0
- package/templates/starter-app/prisma/migrations/20260801091814_goerp_audit_logs_0001_init/migration.sql +33 -0
- package/templates/starter-app/prisma/migrations/20260801091815_goerp_background_tasks_0001_init/migration.sql +23 -0
- package/templates/starter-app/prisma/migrations/20260801091816_goerp_error_logs_0001_init/migration.sql +32 -0
- package/templates/starter-app/prisma/migrations/20260801091817_goerp_notifications_0001_init/migration.sql +59 -0
- package/templates/starter-app/prisma/migrations/20260801091818_goerp_system_jobs_0001_init/migration.sql +47 -0
- package/templates/starter-app/prisma/schema/auth.prisma +87 -0
- package/templates/starter-app/prisma/schema/domain.prisma +24 -0
- package/templates/starter-app/prisma/schema/goerp-audit-logs.prisma +23 -0
- package/templates/starter-app/prisma/schema/goerp-background-tasks.prisma +25 -0
- package/templates/starter-app/prisma/schema/goerp-error-logs.prisma +31 -0
- package/templates/starter-app/prisma/schema/goerp-notifications.prisma +49 -0
- package/templates/starter-app/prisma/schema/goerp-system-jobs.prisma +45 -0
- package/templates/starter-app/prisma/schema/organization.prisma +31 -0
- package/templates/starter-app/prisma/schema/rbac.prisma +100 -0
- package/templates/starter-app/prisma/schema/schema.prisma +8 -0
- package/templates/starter-app/prisma/schema/system.prisma +22 -0
- package/templates/starter-app/prisma/seed.ts +127 -0
- package/templates/starter-app/prisma.config.ts +20 -0
- package/templates/starter-app/public/.gitkeep +2 -0
- package/templates/starter-app/scripts/rbac-sync.ts +235 -0
- package/templates/starter-app/src/__tests__/architecture.test.ts +151 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/audit/page.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/error-logs/page.tsx +21 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/jobs/page.tsx +18 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/settings/page.tsx +22 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/notifications/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +130 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +60 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +48 -0
- package/templates/starter-app/src/app/[lang]/(main)/tasks/page.tsx +13 -0
- package/templates/starter-app/src/app/[lang]/(plain)/layout.tsx +10 -0
- package/templates/starter-app/src/app/[lang]/(plain)/sign-in/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/layout.tsx +21 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +60 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +39 -0
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +96 -0
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +17 -0
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +19 -0
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +20 -0
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +49 -0
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +28 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +24 -0
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +23 -0
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +13 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +14 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +23 -0
- package/templates/starter-app/src/app/api/error-logs/route.ts +124 -0
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +25 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +29 -0
- package/templates/starter-app/src/app/api/notifications/route.ts +26 -0
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +14 -0
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +11 -0
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +14 -0
- package/templates/starter-app/src/app/api/roles/route.ts +18 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +52 -0
- package/templates/starter-app/src/app/api/tasks/route.ts +37 -0
- package/templates/starter-app/src/app/api/upload/route.ts +15 -0
- package/templates/starter-app/src/app/globals.css +15 -0
- package/templates/starter-app/src/app/layout.tsx +16 -0
- package/templates/starter-app/src/app/page.tsx +8 -0
- package/templates/starter-app/src/components/layout/main-layout-wrapper.tsx +30 -0
- package/templates/starter-app/src/configs/entities/department.config.ts +24 -0
- package/templates/starter-app/src/configs/entities/index.ts +13 -0
- package/templates/starter-app/src/configs/i18n.ts +12 -0
- package/templates/starter-app/src/configs/permissions/index.ts +45 -0
- package/templates/starter-app/src/configs/permissions/master-data.permissions.ts +31 -0
- package/templates/starter-app/src/configs/permissions/system.permissions.ts +131 -0
- package/templates/starter-app/src/configs/permissions/types.ts +63 -0
- package/templates/starter-app/src/configs/tenant.ts +18 -0
- package/templates/starter-app/src/data/dictionary.ts +8 -0
- package/templates/starter-app/src/data/navigations.ts +61 -0
- package/templates/starter-app/src/instrumentation.ts +105 -0
- package/templates/starter-app/src/lib/api-handler.ts +157 -0
- package/templates/starter-app/src/lib/auth-client.ts +57 -0
- package/templates/starter-app/src/lib/auth.ts +62 -0
- package/templates/starter-app/src/lib/better-auth.ts +107 -0
- package/templates/starter-app/src/lib/branch-scope.ts +53 -0
- package/templates/starter-app/src/lib/cron/db-cron-manager.ts +42 -0
- package/templates/starter-app/src/lib/crud/index.ts +13 -0
- package/templates/starter-app/src/lib/errors/app-error.ts +2 -0
- package/templates/starter-app/src/lib/errors/error-handler.ts +5 -0
- package/templates/starter-app/src/lib/errors/log-server-error.ts +15 -0
- package/templates/starter-app/src/lib/errors/server-error.ts +11 -0
- package/templates/starter-app/src/lib/logger.ts +30 -0
- package/templates/starter-app/src/lib/page-guard.ts +35 -0
- package/templates/starter-app/src/lib/prisma.ts +80 -0
- package/templates/starter-app/src/lib/rbac/access.ts +87 -0
- package/templates/starter-app/src/lib/storage.ts +28 -0
- package/templates/starter-app/src/providers/index.tsx +54 -0
- package/templates/starter-app/src/providers/mode-provider.tsx +31 -0
- package/templates/starter-app/src/providers/theme-provider.tsx +21 -0
- package/templates/starter-app/src/proxy.ts +45 -0
- package/templates/starter-app/src/server/services/notification-service.ts +31 -0
- package/templates/starter-app/src/server/services/system-config-service.ts +163 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +58 -0
- package/templates/starter-app/src/server/tasks/index.ts +16 -0
- package/templates/starter-app/src/server/tasks/task-runner.ts +40 -0
- package/templates/starter-app/src/types/session.ts +29 -0
- package/templates/starter-app/tsconfig.json +47 -0
- package/templates/starter-app/vitest.config.ts +17 -0
- package/src/infrastructure/cron/index.ts +0 -6
- package/src/infrastructure/event-bus/event-bus.ts +0 -145
- package/src/infrastructure/event-bus/index.ts +0 -2
- package/src/infrastructure/event-bus/types.ts +0 -22
- package/src/infrastructure/lock/decorators.ts +0 -67
- package/src/infrastructure/lock/index.ts +0 -2
- package/src/infrastructure/lock/lock-manager.ts +0 -33
- package/src/plugin/apps-registry.ts +0 -97
- package/src/plugin/index.ts +0 -5
- package/src/plugin/types.ts +0 -41
- package/src/ui/management/audit-log-page.tsx +0 -14
- package/src/ui/management/job-management.tsx +0 -308
- package/src/workflow/activity-timeline.tsx +0 -412
- package/src/workflow/approval-workflow.tsx +0 -31
- package/src/workflow/index.ts +0 -2
- /package/src/{infrastructure/cron → cron}/types.ts +0 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rbac-sync — đồng bộ Permission Registry (src/configs/permissions) xuống DB.
|
|
3
|
+
* Chạy sau mỗi lần deploy có thêm/sửa resource. KHÔNG cần script vá thủ công.
|
|
4
|
+
*
|
|
5
|
+
* Idempotent và AN TOÀN trên production — chạy lại bao nhiêu lần cũng vậy:
|
|
6
|
+
* - Action: tạo nếu thiếu; không bao giờ sửa/xoá action sẵn có.
|
|
7
|
+
* - Resource: cập nhật name/group/mô tả/icon/thứ tự theo registry (registry
|
|
8
|
+
* sở hữu danh mục quyền); không bao giờ xoá.
|
|
9
|
+
* - Grant mặc định: CHỈ seed khi resource được TẠO MỚI ở lần chạy này —
|
|
10
|
+
* không bao giờ đè chỉnh sửa của quản trị viên.
|
|
11
|
+
* - Drift: liệt kê resource có trong DB mà chưa khai registry (chỉ cảnh báo).
|
|
12
|
+
*
|
|
13
|
+
* Dùng:
|
|
14
|
+
* pnpm rbac-sync # sync thật
|
|
15
|
+
* pnpm rbac-sync --dry-run # chỉ in kế hoạch
|
|
16
|
+
* pnpm rbac-sync --verbose # in cả danh sách drift
|
|
17
|
+
* pnpm rbac-sync --force-grants # áp lại grant mặc định cho MỌI resource
|
|
18
|
+
*
|
|
19
|
+
* `--force-grants` là lối thoát cho trường hợp resource đã tạo trước khi vai trò
|
|
20
|
+
* tồn tại (chạy rbac-sync trước `pnpm seed`): resource có đủ mà không ai được
|
|
21
|
+
* quyền gì, và lần chạy sau không còn "tạo mới" nên chẳng grant lại. Cờ này chỉ
|
|
22
|
+
* THÊM quyền (upsert), không bao giờ thu hồi — nhưng nó cũng dựng lại quyền mà
|
|
23
|
+
* quản trị viên đã cố tình gỡ, nên đừng đưa vào quy trình deploy.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { randomUUID } from "node:crypto"
|
|
27
|
+
|
|
28
|
+
import { PrismaPg } from "@prisma/adapter-pg"
|
|
29
|
+
import { PrismaClient } from "@prisma/client"
|
|
30
|
+
import "dotenv/config"
|
|
31
|
+
|
|
32
|
+
import { permissionRegistry } from "../src/configs/permissions"
|
|
33
|
+
|
|
34
|
+
const DRY_RUN = process.argv.includes("--dry-run")
|
|
35
|
+
const VERBOSE = process.argv.includes("--verbose")
|
|
36
|
+
const FORCE_GRANTS = process.argv.includes("--force-grants")
|
|
37
|
+
|
|
38
|
+
/** Action ai cũng hiểu — khỏi bắt mỗi app khai lại trong customActions. */
|
|
39
|
+
const STANDARD_ACTIONS: Record<string, string> = {
|
|
40
|
+
view: "Xem",
|
|
41
|
+
create: "Thêm",
|
|
42
|
+
update: "Sửa",
|
|
43
|
+
delete: "Xóa",
|
|
44
|
+
export: "Xuất dữ liệu",
|
|
45
|
+
import: "Nhập dữ liệu",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const connectionString = process.env.DATABASE_URL
|
|
49
|
+
if (!connectionString) throw new Error("DATABASE_URL chưa được set (xem .env.example)")
|
|
50
|
+
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) })
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
const now = new Date()
|
|
54
|
+
const resourceCount = permissionRegistry.reduce((n, f) => n + f.resources.length, 0)
|
|
55
|
+
console.log(
|
|
56
|
+
`→ rbac-sync: ${permissionRegistry.length} phân hệ, ${resourceCount} resource${DRY_RUN ? " [DRY RUN]" : ""}`,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// ── 1. Action: tạo nếu thiếu ───────────────────────────────────────────
|
|
60
|
+
const declaredActions = new Map<string, { name: string; description?: string }>()
|
|
61
|
+
for (const feature of permissionRegistry) {
|
|
62
|
+
for (const a of feature.customActions ?? []) {
|
|
63
|
+
declaredActions.set(a.code, { name: a.name, description: a.description })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const usedActionCodes = new Set(
|
|
67
|
+
permissionRegistry.flatMap((f) => f.resources.flatMap((r) => [...r.actions])),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const existingActionCodes = new Set(
|
|
71
|
+
(await prisma.action.findMany({ select: { code: true } })).map((a) => a.code),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for (const code of usedActionCodes) {
|
|
75
|
+
if (existingActionCodes.has(code)) continue
|
|
76
|
+
const decl = declaredActions.get(code) ?? {
|
|
77
|
+
name: STANDARD_ACTIONS[code],
|
|
78
|
+
description: undefined,
|
|
79
|
+
}
|
|
80
|
+
if (!decl.name) {
|
|
81
|
+
// Dừng hẳn thay vì tạo action rác: sai chính tả một action code sẽ khiến
|
|
82
|
+
// quyền không bao giờ khớp và nút bấm ẩn vĩnh viễn.
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Action "${code}" được dùng nhưng không nằm trong bộ chuẩn và chưa khai ở customActions của phân hệ nào.`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
console.log(` + action: ${code} (${decl.name})`)
|
|
88
|
+
if (!DRY_RUN) {
|
|
89
|
+
await prisma.action.create({
|
|
90
|
+
data: { code, name: decl.name, description: decl.description ?? "" },
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── 2. Resource: upsert metadata + config.actions ──────────────────────
|
|
96
|
+
// config.actions = danh sách action HIỆN trên ma trận phân quyền của core.
|
|
97
|
+
// Registry sở hữu khóa `actions`; các khóa khác admin thêm vào được giữ nguyên.
|
|
98
|
+
const createdResourceCodes = new Set<string>()
|
|
99
|
+
for (const feature of permissionRegistry) {
|
|
100
|
+
for (const r of feature.resources) {
|
|
101
|
+
const existing = await prisma.resource.findUnique({
|
|
102
|
+
where: { code: r.code },
|
|
103
|
+
select: { code: true, config: true },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
let config: Record<string, unknown> = {}
|
|
107
|
+
if (existing?.config) {
|
|
108
|
+
try {
|
|
109
|
+
config = JSON.parse(existing.config)
|
|
110
|
+
} catch {
|
|
111
|
+
console.warn(` ! config của ${r.code} không phải JSON — ghi đè`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Lớp tùy chỉnh của admin (thêm/tắt action trên UI) được BẢO TOÀN:
|
|
115
|
+
// hiệu lực = registry ∪ customActions − disabledActions
|
|
116
|
+
const custom = Array.isArray(config.customActions) ? (config.customActions as string[]) : []
|
|
117
|
+
const disabled = Array.isArray(config.disabledActions)
|
|
118
|
+
? (config.disabledActions as string[])
|
|
119
|
+
: []
|
|
120
|
+
config.actions = [...new Set([...r.actions, ...custom])].filter(
|
|
121
|
+
(a) => !disabled.includes(a),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if (!existing) {
|
|
125
|
+
createdResourceCodes.add(r.code)
|
|
126
|
+
console.log(` + resource: ${r.code} (${r.name}) [${feature.feature}]`)
|
|
127
|
+
} else if (VERBOSE) {
|
|
128
|
+
console.log(` = resource: ${r.code} — cập nhật metadata`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!DRY_RUN) {
|
|
132
|
+
const data = {
|
|
133
|
+
name: r.name,
|
|
134
|
+
group: r.group,
|
|
135
|
+
description: r.description ?? feature.description ?? null,
|
|
136
|
+
icon: r.icon ?? null,
|
|
137
|
+
order: r.order ?? null,
|
|
138
|
+
config: JSON.stringify(config),
|
|
139
|
+
}
|
|
140
|
+
await prisma.resource.upsert({
|
|
141
|
+
where: { code: r.code },
|
|
142
|
+
create: { code: r.code, ...data },
|
|
143
|
+
update: data,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── 3. Grant mặc định — CHỈ cho resource vừa tạo (trừ khi --force-grants) ──
|
|
150
|
+
const wantsGrants = (code: string) => FORCE_GRANTS || createdResourceCodes.has(code)
|
|
151
|
+
let skippedGrants = 0
|
|
152
|
+
|
|
153
|
+
const neededRoleCodes = new Set<string>()
|
|
154
|
+
for (const feature of permissionRegistry) {
|
|
155
|
+
for (const r of feature.resources) {
|
|
156
|
+
if (!wantsGrants(r.code)) continue
|
|
157
|
+
for (const roleCode of Object.keys(r.defaultGrants ?? {})) neededRoleCodes.add(roleCode)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const presentRoles = new Set(
|
|
161
|
+
neededRoleCodes.size
|
|
162
|
+
? (
|
|
163
|
+
await prisma.role.findMany({
|
|
164
|
+
where: { code: { in: [...neededRoleCodes] } },
|
|
165
|
+
select: { code: true },
|
|
166
|
+
})
|
|
167
|
+
).map((r) => r.code)
|
|
168
|
+
: [],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
for (const feature of permissionRegistry) {
|
|
172
|
+
for (const r of feature.resources) {
|
|
173
|
+
if (!wantsGrants(r.code)) continue
|
|
174
|
+
for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
|
|
175
|
+
if (!presentRoles.has(roleCode)) {
|
|
176
|
+
skippedGrants++
|
|
177
|
+
console.warn(` ! vai trò "${roleCode}" chưa tồn tại — bỏ qua grant cho ${r.code}`)
|
|
178
|
+
continue
|
|
179
|
+
}
|
|
180
|
+
for (const actionCode of grant === "*" ? r.actions : grant) {
|
|
181
|
+
if (VERBOSE || !FORCE_GRANTS) console.log(` ✓ grant: ${roleCode} → ${r.code}:${actionCode}`)
|
|
182
|
+
if (!DRY_RUN) {
|
|
183
|
+
await prisma.rolePermission.upsert({
|
|
184
|
+
where: {
|
|
185
|
+
roleCode_resourceCode_actionCode: {
|
|
186
|
+
roleCode,
|
|
187
|
+
resourceCode: r.code,
|
|
188
|
+
actionCode,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
create: { id: randomUUID(), roleCode, resourceCode: r.code, actionCode },
|
|
192
|
+
update: {},
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── 4. Drift (chỉ cảnh báo) ────────────────────────────────────────────
|
|
201
|
+
const registryCodes = new Set(
|
|
202
|
+
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
|
|
203
|
+
)
|
|
204
|
+
const legacy = (
|
|
205
|
+
await prisma.resource.findMany({ where: { status: "active" }, select: { code: true } })
|
|
206
|
+
)
|
|
207
|
+
.map((r) => r.code)
|
|
208
|
+
.filter((code) => !registryCodes.has(code))
|
|
209
|
+
.sort()
|
|
210
|
+
|
|
211
|
+
if (legacy.length) {
|
|
212
|
+
console.log(`→ Drift: ${legacy.length} resource trong DB chưa khai registry`)
|
|
213
|
+
if (VERBOSE) console.log(` ${legacy.join(", ")}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log(
|
|
217
|
+
`→ Xong: ${createdResourceCodes.size} resource mới, ${registryCodes.size - createdResourceCodes.size} resource cập nhật.`,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
// Bỏ qua grant = resource đã tạo nhưng KHÔNG ai có quyền, và lần chạy sau
|
|
221
|
+
// resource không còn "mới" nên sẽ im lặng bỏ qua mãi. Nói thẳng cách chữa.
|
|
222
|
+
if (skippedGrants) {
|
|
223
|
+
console.warn(
|
|
224
|
+
`\n⚠ ${skippedGrants} grant bị bỏ vì vai trò chưa tồn tại. Chạy \`pnpm seed\` để tạo vai trò,` +
|
|
225
|
+
` rồi \`pnpm rbac-sync --force-grants\` để áp lại — nếu không sẽ không ai vào được màn hình nào.`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
main()
|
|
231
|
+
.catch((e) => {
|
|
232
|
+
console.error(e)
|
|
233
|
+
process.exit(1)
|
|
234
|
+
})
|
|
235
|
+
.finally(() => prisma.$disconnect())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { join, relative } from "node:path"
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
|
|
6
|
+
import { permissionRegistry } from "@/configs/permissions"
|
|
7
|
+
import { navigations } from "@/data/navigations"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RATCHET — hàng rào kiến trúc. Mỗi kiểm tra ở đây tương ứng một lỗi im lặng
|
|
11
|
+
* đã từng làm mất thời gian trong app thật: quyền khai sai thì nút biến mất
|
|
12
|
+
* không báo lỗi, route quên gác thì thành lỗ hổng, hai Prisma client thì rò
|
|
13
|
+
* connection pool. Thêm quy tắc mới khi bạn gặp lỗi loại đó; đừng nới lỏng.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SRC = join(__dirname, "..")
|
|
17
|
+
|
|
18
|
+
function walk(dir: string, out: string[] = []): string[] {
|
|
19
|
+
for (const entry of readdirSync(dir)) {
|
|
20
|
+
const full = join(dir, entry)
|
|
21
|
+
if (statSync(full).isDirectory()) {
|
|
22
|
+
if (entry === "node_modules" || entry === "__tests__") continue
|
|
23
|
+
walk(full, out)
|
|
24
|
+
} else if (/\.(ts|tsx)$/.test(entry)) {
|
|
25
|
+
out.push(full)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const allFiles = walk(SRC)
|
|
32
|
+
const read = (f: string) => readFileSync(f, "utf8")
|
|
33
|
+
const rel = (f: string) => relative(SRC, f)
|
|
34
|
+
|
|
35
|
+
/** Bộ action ai cũng hiểu — trùng danh sách trong scripts/rbac-sync.ts. */
|
|
36
|
+
const STANDARD_ACTIONS = ["view", "create", "update", "delete", "export", "import"]
|
|
37
|
+
|
|
38
|
+
describe("permission registry", () => {
|
|
39
|
+
const declaredCustom = new Set(
|
|
40
|
+
permissionRegistry.flatMap((f) => (f.customActions ?? []).map((a) => a.code)),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
it("mọi action được dùng đều là action chuẩn hoặc đã khai customActions", () => {
|
|
44
|
+
const unknown: string[] = []
|
|
45
|
+
for (const feature of permissionRegistry) {
|
|
46
|
+
for (const r of feature.resources) {
|
|
47
|
+
for (const a of r.actions) {
|
|
48
|
+
if (!STANDARD_ACTIONS.includes(a) && !declaredCustom.has(a)) {
|
|
49
|
+
unknown.push(`${r.code}:${a}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// rbac-sync ném lỗi khi gặp trường hợp này — bắt sớm ở test rẻ hơn nhiều
|
|
55
|
+
// so với phát hiện lúc deploy.
|
|
56
|
+
expect(unknown).toEqual([])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("defaultGrants chỉ trỏ tới action đã khai trên chính resource đó", () => {
|
|
60
|
+
const bad: string[] = []
|
|
61
|
+
for (const feature of permissionRegistry) {
|
|
62
|
+
for (const r of feature.resources) {
|
|
63
|
+
for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
|
|
64
|
+
if (grant === "*") continue
|
|
65
|
+
for (const a of grant) {
|
|
66
|
+
if (!r.actions.includes(a)) bad.push(`${roleCode} → ${r.code}:${a}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
expect(bad).toEqual([])
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("mã resource dùng kebab-case (khớp chuỗi trong checkPermission và URL)", () => {
|
|
75
|
+
const bad = permissionRegistry
|
|
76
|
+
.flatMap((f) => f.resources.map((r) => r.code))
|
|
77
|
+
.filter((code) => !/^[a-z][a-z0-9-]*$/.test(code))
|
|
78
|
+
expect(bad).toEqual([])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("mọi mục navigation có `resource` đều đã khai trong registry", () => {
|
|
82
|
+
const declared = new Set(
|
|
83
|
+
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
|
|
84
|
+
)
|
|
85
|
+
const missing: string[] = []
|
|
86
|
+
for (const group of navigations) {
|
|
87
|
+
for (const item of group.items ?? []) {
|
|
88
|
+
const resource = (item as { resource?: string }).resource
|
|
89
|
+
if (resource && !declared.has(resource)) missing.push(resource)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Mục nav trỏ tới resource chưa khai sẽ ẨN VĨNH VIỄN với mọi người dùng
|
|
93
|
+
// (kể cả admin) mà không có thông báo nào.
|
|
94
|
+
expect(missing).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe("ranh giới kiến trúc", () => {
|
|
99
|
+
it("không còn dấu vết NextAuth (app đã sang Better Auth)", () => {
|
|
100
|
+
const offenders = allFiles.filter((f) => /from ["']next-auth/.test(read(f)))
|
|
101
|
+
expect(offenders.map(rel)).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("chỉ src/lib/prisma.ts được tạo PrismaClient", () => {
|
|
105
|
+
const offenders = allFiles.filter(
|
|
106
|
+
(f) => /new PrismaClient\(/.test(read(f)) && rel(f) !== "lib/prisma.ts",
|
|
107
|
+
)
|
|
108
|
+
// Client thứ hai = pool kết nối thứ hai + bỏ qua audit extension.
|
|
109
|
+
expect(offenders.map(rel)).toEqual([])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("route API đều đi qua getSession hoặc handler có gác quyền của core", () => {
|
|
113
|
+
const routes = allFiles.filter((f) => /app\/api\/.*route\.ts$/.test(rel(f)))
|
|
114
|
+
expect(routes.length).toBeGreaterThan(0)
|
|
115
|
+
|
|
116
|
+
// better-auth tự lo xác thực cho chính nó (đăng nhập thì làm gì có phiên).
|
|
117
|
+
const exempt = ["app/api/better-auth"]
|
|
118
|
+
const offenders = routes.filter((f) => {
|
|
119
|
+
if (exempt.some((p) => rel(f).startsWith(p))) return false
|
|
120
|
+
const src = read(f)
|
|
121
|
+
// apiHandler( = cổng của app — chấp cả dạng có generic
|
|
122
|
+
// `apiHandler<{ key: string[] }>(` ở route catch-all; create*Handlers( =
|
|
123
|
+
// factory sẵn của core (rbac/crud) vốn đã tự gác session + quyền bên trong.
|
|
124
|
+
return !/apiHandler[<(]|create\w*Handlers\(|getSession/.test(src)
|
|
125
|
+
})
|
|
126
|
+
expect(offenders.map(rel)).toEqual([])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it("resource mà route gác đều đã khai trong registry", () => {
|
|
130
|
+
const declared = new Set(
|
|
131
|
+
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code)),
|
|
132
|
+
)
|
|
133
|
+
const missing: string[] = []
|
|
134
|
+
for (const file of allFiles.filter((f) => /app\/api\/.*route\.ts$/.test(rel(f)))) {
|
|
135
|
+
for (const [, code] of read(file).matchAll(/resource:\s*["']([^"']+)["']/g)) {
|
|
136
|
+
if (!declared.has(code)) missing.push(`${rel(file)} → ${code}`)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// assertResourceDeclared cũng bắt lỗi này, nhưng chỉ khi route được load.
|
|
140
|
+
// Test quét tĩnh nên thấy cả route chưa ai gọi tới.
|
|
141
|
+
expect(missing).toEqual([])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("cấu hình quyền là dữ liệu thuần — không kéo prisma/next/react vào", () => {
|
|
145
|
+
const offenders = allFiles
|
|
146
|
+
.filter((f) => rel(f).startsWith("configs/permissions"))
|
|
147
|
+
.filter((f) => /from ["'](@\/lib\/prisma|next|react|@prisma)/.test(read(f)))
|
|
148
|
+
// scripts/rbac-sync.ts và test đọc thẳng các file này qua tsx.
|
|
149
|
+
expect(offenders.map(rel)).toEqual([])
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SystemAuditPage } from "@goerp/core/system/pages/system-audit-page"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: "Nhật ký hoạt động" }
|
|
8
|
+
|
|
9
|
+
export default async function AuditRoute({
|
|
10
|
+
params,
|
|
11
|
+
}: {
|
|
12
|
+
params: Promise<{ lang: string }>
|
|
13
|
+
}) {
|
|
14
|
+
const { lang } = await params
|
|
15
|
+
await requirePageAccess(lang, "audit-log")
|
|
16
|
+
|
|
17
|
+
// Bảng nhật ký tự cuộn bên trong — cho nó chiều cao cố định thay vì để cả
|
|
18
|
+
// trang dài ra theo số dòng.
|
|
19
|
+
return (
|
|
20
|
+
<div className="h-[calc(100vh-120px)]">
|
|
21
|
+
<SystemAuditPage />
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ErrorLogsPage } from "@goerp/core/system/pages/error-logs-page"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: "Nhật ký lỗi" }
|
|
8
|
+
|
|
9
|
+
/** `?search=<errorId>` để deep-link thẳng tới một lỗi từ thông báo/dashboard. */
|
|
10
|
+
export default async function ErrorLogsRoute({
|
|
11
|
+
params,
|
|
12
|
+
searchParams,
|
|
13
|
+
}: {
|
|
14
|
+
params: Promise<{ lang: string }>
|
|
15
|
+
searchParams: Promise<{ search?: string }>
|
|
16
|
+
}) {
|
|
17
|
+
const [{ lang }, sp] = await Promise.all([params, searchParams])
|
|
18
|
+
await requirePageAccess(lang, "error-log")
|
|
19
|
+
|
|
20
|
+
return <ErrorLogsPage initialSearch={sp.search} />
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SystemJobsPage } from "@goerp/core/system/pages/system-jobs-page"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: "Tác vụ định kỳ" }
|
|
8
|
+
|
|
9
|
+
export default async function JobsRoute({
|
|
10
|
+
params,
|
|
11
|
+
}: {
|
|
12
|
+
params: Promise<{ lang: string }>
|
|
13
|
+
}) {
|
|
14
|
+
const { lang } = await params
|
|
15
|
+
await requirePageAccess(lang, "system-job")
|
|
16
|
+
|
|
17
|
+
return <SystemJobsPage />
|
|
18
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SystemSettingsPage } from "@goerp/core/system/pages/system-settings-page"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = { title: "Cấu hình hệ thống" }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Trang cài đặt = màn hình sẵn của core + các route dưới
|
|
11
|
+
* /api/admin/system/settings/* mà app cung cấp. Không có mã giao diện riêng.
|
|
12
|
+
*/
|
|
13
|
+
export default async function SettingsPage({
|
|
14
|
+
params,
|
|
15
|
+
}: {
|
|
16
|
+
params: Promise<{ lang: string }>
|
|
17
|
+
}) {
|
|
18
|
+
const { lang } = await params
|
|
19
|
+
await requirePageAccess(lang, "system-setting")
|
|
20
|
+
|
|
21
|
+
return <SystemSettingsPage lang={lang} />
|
|
22
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { EntityCrudPage } from "@goerp/core/crud"
|
|
2
|
+
|
|
3
|
+
import type { LocaleType } from "@goerp/core/types"
|
|
4
|
+
|
|
5
|
+
import { getEntityConfig } from "@/configs/entities"
|
|
6
|
+
import { dictionary } from "@/data/dictionary"
|
|
7
|
+
import { getSession } from "@/lib/auth"
|
|
8
|
+
|
|
9
|
+
export const dynamic = "force-dynamic"
|
|
10
|
+
|
|
11
|
+
// Generic CRUD page — one file serves EVERY entity in the registry. The core
|
|
12
|
+
// engine renders table + form + filters + import/export from the EntityConfig.
|
|
13
|
+
// Add an entity: register it in src/configs/entities and link /crud/<key> in nav.
|
|
14
|
+
export default async function CrudEntityPage({
|
|
15
|
+
params,
|
|
16
|
+
}: {
|
|
17
|
+
params: Promise<{ lang: string; entity: string }>
|
|
18
|
+
}) {
|
|
19
|
+
const { lang, entity } = await params
|
|
20
|
+
const config = getEntityConfig(entity)
|
|
21
|
+
|
|
22
|
+
if (!config) {
|
|
23
|
+
return (
|
|
24
|
+
<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
|
+
</div>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const session = await getSession()
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<EntityCrudPage
|
|
34
|
+
entity={entity}
|
|
35
|
+
lang={lang as LocaleType}
|
|
36
|
+
config={config}
|
|
37
|
+
session={session!}
|
|
38
|
+
dictionary={dictionary}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { MainLayoutWrapper } from "@/components/layout/main-layout-wrapper"
|
|
4
|
+
import { dictionary } from "@/data/dictionary"
|
|
5
|
+
import { navigations } from "@/data/navigations"
|
|
6
|
+
|
|
7
|
+
export default function MainAreaLayout({ children }: { children: ReactNode }) {
|
|
8
|
+
return (
|
|
9
|
+
<MainLayoutWrapper dictionary={dictionary} navigation={navigations}>
|
|
10
|
+
{children}
|
|
11
|
+
</MainLayoutWrapper>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { NotificationsInbox } from "@goerp/core/notification/ui"
|
|
2
|
+
|
|
3
|
+
import type { Metadata } from "next"
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = { title: "Thông báo" }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hộp thư của CHÍNH người đang đăng nhập — không cần quyền RBAC, API đã tự
|
|
9
|
+
* lọc theo `session.user.id`. Chuông trên thanh tiêu đề trỏ về trang này.
|
|
10
|
+
*/
|
|
11
|
+
export default function NotificationsPage() {
|
|
12
|
+
return <NotificationsInbox />
|
|
13
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { StatBar } from "@goerp/core/ui"
|
|
2
|
+
import { ArrowRight, Building2, ShieldCheck, Sparkles } from "lucide-react"
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
|
|
5
|
+
import { db } from "@/lib/prisma"
|
|
6
|
+
|
|
7
|
+
export const dynamic = "force-dynamic"
|
|
8
|
+
|
|
9
|
+
// Sample ERP dashboard. Server component: query counts, hand StatBar plain data.
|
|
10
|
+
// Replace the KPIs + quick actions with your domain's once you add entities.
|
|
11
|
+
export default async function HomePage() {
|
|
12
|
+
const [departmentCount, activeDepartments, userCount] = await Promise.all([
|
|
13
|
+
db.department.count(),
|
|
14
|
+
db.department.count({ where: { status: "active" } }),
|
|
15
|
+
db.user.count(),
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
|
20
|
+
<div className="space-y-1">
|
|
21
|
+
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
22
|
+
Bảng điều khiển
|
|
23
|
+
</h1>
|
|
24
|
+
<p className="text-sm text-muted-foreground">
|
|
25
|
+
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.
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<StatBar
|
|
32
|
+
items={[
|
|
33
|
+
{
|
|
34
|
+
id: "departments",
|
|
35
|
+
label: "Phòng ban",
|
|
36
|
+
value: departmentCount,
|
|
37
|
+
iconName: "Building2",
|
|
38
|
+
colorTheme: "dark",
|
|
39
|
+
isHighlighted: true,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "active",
|
|
43
|
+
label: "Đang hoạt động",
|
|
44
|
+
value: activeDepartments,
|
|
45
|
+
iconName: "CheckCircle2",
|
|
46
|
+
colorTheme: "emerald",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "users",
|
|
50
|
+
label: "Người dùng",
|
|
51
|
+
value: userCount,
|
|
52
|
+
iconName: "Users",
|
|
53
|
+
colorTheme: "blue",
|
|
54
|
+
},
|
|
55
|
+
]}
|
|
56
|
+
/>
|
|
57
|
+
|
|
58
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
59
|
+
<QuickCard
|
|
60
|
+
href="/crud/departments"
|
|
61
|
+
icon={<Building2 className="size-5" />}
|
|
62
|
+
title="Phòng ban"
|
|
63
|
+
desc="Xem, thêm, sửa cơ cấu tổ chức — bảng/biểu mẫu/lọc/nhập-xuất tự sinh từ EntityConfig."
|
|
64
|
+
/>
|
|
65
|
+
<InfoCard
|
|
66
|
+
icon={<Sparkles className="size-5" />}
|
|
67
|
+
title="Thêm entity mới"
|
|
68
|
+
desc="Khai báo EntityConfig, đăng ký trong src/configs/entities, thêm 1 dòng nav /crud/<key>. Không viết engine."
|
|
69
|
+
/>
|
|
70
|
+
<InfoCard
|
|
71
|
+
icon={<ShieldCheck className="size-5" />}
|
|
72
|
+
title="Phân quyền"
|
|
73
|
+
desc="Đăng nhập thật qua bảng User (admin/admin123). Role 'admin' = full quyền; RBAC chi tiết thêm bảng RolePermission."
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function QuickCard({
|
|
81
|
+
href,
|
|
82
|
+
icon,
|
|
83
|
+
title,
|
|
84
|
+
desc,
|
|
85
|
+
}: {
|
|
86
|
+
href: string
|
|
87
|
+
icon: React.ReactNode
|
|
88
|
+
title: string
|
|
89
|
+
desc: string
|
|
90
|
+
}) {
|
|
91
|
+
return (
|
|
92
|
+
<Link
|
|
93
|
+
href={href}
|
|
94
|
+
className="group flex flex-col gap-3 rounded-lg border border-border bg-card p-5 transition-colors hover:border-primary/40 hover:bg-accent"
|
|
95
|
+
>
|
|
96
|
+
<div className="flex items-center justify-between">
|
|
97
|
+
<span className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
98
|
+
{icon}
|
|
99
|
+
</span>
|
|
100
|
+
<ArrowRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<div className="font-semibold text-foreground">{title}</div>
|
|
104
|
+
<p className="mt-1 text-sm text-muted-foreground">{desc}</p>
|
|
105
|
+
</div>
|
|
106
|
+
</Link>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function InfoCard({
|
|
111
|
+
icon,
|
|
112
|
+
title,
|
|
113
|
+
desc,
|
|
114
|
+
}: {
|
|
115
|
+
icon: React.ReactNode
|
|
116
|
+
title: string
|
|
117
|
+
desc: string
|
|
118
|
+
}) {
|
|
119
|
+
return (
|
|
120
|
+
<div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-5">
|
|
121
|
+
<span className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
|
122
|
+
{icon}
|
|
123
|
+
</span>
|
|
124
|
+
<div>
|
|
125
|
+
<div className="font-semibold text-foreground">{title}</div>
|
|
126
|
+
<p className="mt-1 text-sm text-muted-foreground">{desc}</p>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|