@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.
- package/CHANGELOG.md +45 -0
- package/bin/goerp-init.mjs +15 -0
- package/package.json +2 -1
- package/src/auth/index.ts +5 -1
- package/src/auth/proxy-gate.ts +21 -2
- package/src/configs/entities/departments.config.ts +1 -0
- package/src/configs/entities/material-categories.config.ts +1 -0
- 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-export-button.tsx +3 -2
- package/src/crud/components/crud-page.tsx +56 -25
- package/src/crud/components/crud-row-actions.tsx +16 -4
- package/src/crud/components/crud-table.tsx +13 -1
- package/src/crud/crud-route-handlers.test.ts +352 -0
- package/src/crud/crud-route-handlers.ts +260 -13
- package/src/crud/index.ts +3 -0
- package/src/crud/lib/coerce.test.ts +118 -0
- package/src/crud/lib/coerce.ts +95 -0
- package/src/crud/lib/crud-utils.ts +4 -0
- package/src/crud/lib/entity-endpoints.test.ts +41 -0
- package/src/crud/lib/entity-endpoints.ts +23 -0
- package/src/crud/lib/errors.ts +14 -0
- package/src/crud/lib/import-request.ts +99 -0
- package/src/crud/lib/mutation-builder.test.ts +114 -0
- package/src/crud/lib/mutation-builder.ts +27 -34
- package/src/crud/lib/permissions.test.ts +57 -0
- package/src/crud/lib/permissions.ts +25 -13
- package/src/crud/lib/query-builder.ts +13 -6
- package/src/crud/lib/translate-config.ts +16 -2
- package/src/crud/pages/entity-crud-page.tsx +11 -8
- package/src/crud/server-service.test.ts +112 -0
- package/src/crud/server-service.ts +52 -27
- package/src/crud/server.ts +11 -1
- package/src/guardrails/index.ts +1 -0
- package/src/guardrails/rules/auth.ts +54 -1
- package/src/guardrails/rules/design.ts +3 -2
- package/src/guardrails/types.ts +17 -8
- 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/types/index.ts +75 -10
- package/src/ui/auth/sign-in-form.tsx +64 -9
- package/src/ui/data-display/__tests__/use-client-pagination.test.ts +59 -0
- package/src/ui/data-display/data-table/__tests__/data-table-row-memo.test.tsx +96 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +5 -0
- package/src/ui/data-display/data-table/data-table.tsx +68 -23
- package/src/ui/data-display/data-table-pagination.tsx +25 -8
- package/src/ui/data-display/index.tsx +1 -0
- package/src/ui/data-display/use-client-pagination.ts +48 -0
- 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/AGENTS.md +1 -1
- 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/migrations/20260807115347_platform_pages/migration.sql +87 -0
- package/templates/starter-app/prisma/migrations/20260808003811_company_profile/migration.sql +25 -0
- 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/scripts/rbac-sync.ts +49 -21
- package/templates/starter-app/src/__tests__/architecture.test.ts +11 -2
- 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 +68 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/actions.ts +33 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +195 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/page.tsx +29 -0
- package/templates/starter-app/src/app/[lang]/(main)/crud/[entity]/page.tsx +4 -2
- package/templates/starter-app/src/app/[lang]/(main)/error.tsx +24 -0
- package/templates/starter-app/src/app/[lang]/(main)/layout.tsx +2 -1
- package/templates/starter-app/src/app/[lang]/(main)/not-found.tsx +6 -0
- package/templates/starter-app/src/app/[lang]/(main)/page.tsx +13 -3
- package/templates/starter-app/src/app/[lang]/(main)/resources/page.tsx +39 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/[id]/edit/page.tsx +5 -2
- package/templates/starter-app/src/app/[lang]/(main)/roles/new/page.tsx +1 -0
- package/templates/starter-app/src/app/[lang]/(main)/roles/page.tsx +1 -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 +62 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/page.tsx +41 -0
- package/templates/starter-app/src/app/[lang]/(main)/user/profile/profile-client-page.tsx +157 -0
- package/templates/starter-app/src/app/[lang]/(main)/users/page.tsx +69 -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]/layout.tsx +2 -2
- package/templates/starter-app/src/app/[lang]/not-found.tsx +9 -0
- package/templates/starter-app/src/app/api/admin/system/audit/route.ts +4 -3
- package/templates/starter-app/src/app/api/admin/system/jobs/[name]/history/route.ts +1 -1
- package/templates/starter-app/src/app/api/admin/system/jobs/route.ts +31 -12
- package/templates/starter-app/src/app/api/admin/system/settings/all/route.ts +2 -2
- package/templates/starter-app/src/app/api/admin/system/settings/create/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/delete/route.ts +10 -3
- package/templates/starter-app/src/app/api/admin/system/settings/route.ts +14 -5
- package/templates/starter-app/src/app/api/admin/system/settings/toggle-status/route.ts +16 -7
- package/templates/starter-app/src/app/api/admin/system/settings/update/route.ts +12 -6
- package/templates/starter-app/src/app/api/admin/system/settings/update-full/route.ts +12 -6
- package/templates/starter-app/src/app/api/better-auth/[...all]/route.ts +1 -1
- package/templates/starter-app/src/app/api/branches/route.ts +26 -0
- package/templates/starter-app/src/app/api/crud/[entity]/[id]/route.ts +2 -2
- package/templates/starter-app/src/app/api/crud/[entity]/export/route.ts +16 -0
- package/templates/starter-app/src/app/api/crud/[entity]/import/route.ts +17 -0
- package/templates/starter-app/src/app/api/crud/[entity]/route.ts +2 -2
- package/templates/starter-app/src/app/api/departments/route.ts +26 -0
- package/templates/starter-app/src/app/api/error-logs/[id]/route.ts +4 -2
- package/templates/starter-app/src/app/api/error-logs/route.ts +26 -19
- package/templates/starter-app/src/app/api/files/[...key]/route.ts +1 -2
- package/templates/starter-app/src/app/api/job-titles/route.ts +46 -0
- package/templates/starter-app/src/app/api/notifications/read/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/route.ts +1 -1
- package/templates/starter-app/src/app/api/notifications/unread-count/route.ts +1 -1
- package/templates/starter-app/src/app/api/rbac/permissions-version/route.ts +4 -1
- package/templates/starter-app/src/app/api/roles/[id]/route.ts +2 -1
- package/templates/starter-app/src/app/api/roles/route.ts +2 -1
- 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 +179 -0
- package/templates/starter-app/src/app/api/system-category-groups/route.ts +149 -0
- package/templates/starter-app/src/app/api/tasks/[id]/download/route.ts +7 -3
- package/templates/starter-app/src/app/api/tasks/route.ts +5 -2
- package/templates/starter-app/src/app/api/upload/route.ts +1 -2
- package/templates/starter-app/src/app/api/users/[id]/route.ts +230 -0
- package/templates/starter-app/src/app/api/users/route.ts +141 -0
- package/templates/starter-app/src/app/global-error.tsx +135 -0
- package/templates/starter-app/src/app/globals.css +0 -1
- 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/components/layout/main-layout-wrapper.tsx +5 -4
- package/templates/starter-app/src/configs/entities/department.config.ts +7 -4
- package/templates/starter-app/src/configs/entities/index.ts +5 -1
- package/templates/starter-app/src/configs/entities/job-titles.config.ts +121 -0
- package/templates/starter-app/src/configs/entities/system-alerts.config.ts +93 -0
- package/templates/starter-app/src/configs/entities/users.config.ts +80 -0
- package/templates/starter-app/src/configs/i18n.ts +6 -6
- 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 +77 -4
- package/templates/starter-app/src/data/navigations.ts +60 -1
- package/templates/starter-app/src/instrumentation.ts +5 -16
- package/templates/starter-app/src/lib/action-guard.ts +25 -0
- package/templates/starter-app/src/lib/api-handler.ts +27 -19
- package/templates/starter-app/src/lib/auth.ts +3 -1
- package/templates/starter-app/src/lib/better-auth.ts +8 -0
- package/templates/starter-app/src/lib/branch-scope.ts +1 -1
- package/templates/starter-app/src/lib/crud/index.ts +5 -10
- package/templates/starter-app/src/lib/errors/log-server-error.ts +5 -4
- package/templates/starter-app/src/lib/logger.ts +11 -4
- package/templates/starter-app/src/lib/page-guard.ts +2 -3
- package/templates/starter-app/src/lib/prisma.ts +16 -7
- package/templates/starter-app/src/lib/rbac/access.ts +2 -2
- package/templates/starter-app/src/lib/storage.ts +3 -1
- package/templates/starter-app/src/providers/index.tsx +2 -1
- package/templates/starter-app/src/providers/mode-provider.tsx +13 -18
- package/templates/starter-app/src/providers/theme-provider.tsx +27 -12
- package/templates/starter-app/src/proxy.ts +8 -3
- package/templates/starter-app/src/server/services/company-service.ts +49 -0
- package/templates/starter-app/src/server/services/notification-service.ts +6 -6
- package/templates/starter-app/src/server/services/system-config-service.ts +21 -9
- package/templates/starter-app/src/server/services/user-service.ts +104 -0
- package/templates/starter-app/src/server/tasks/handlers/export-departments.ts +15 -9
- package/templates/starter-app/src/server/tasks/task-runner.ts +6 -7
- package/templates/starter-app/tsconfig.json +10 -0
- package/templates/starter-app/vitest.config.ts +17 -1
|
@@ -20,3 +20,77 @@ model SystemConfig {
|
|
|
20
20
|
|
|
21
21
|
@@map("system_configs")
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
// Danh mục hệ thống: cặp bảng nhóm → mục, phục vụ trang /system-categories
|
|
25
|
+
// (SystemCategoryPage của @goerp/core). Định nghĩa chép nguyên từ app chuẩn —
|
|
26
|
+
// GIỮ tên bảng/cột để dữ liệu port qua lại được.
|
|
27
|
+
model SystemCategory {
|
|
28
|
+
id String @id @map("id")
|
|
29
|
+
code String @unique @map("code")
|
|
30
|
+
name String @map("name")
|
|
31
|
+
groupCode String @map("group_code")
|
|
32
|
+
description String @map("description")
|
|
33
|
+
status String @default("active") @map("status")
|
|
34
|
+
order Int? @map("order")
|
|
35
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
36
|
+
createdBy String @map("created_by")
|
|
37
|
+
updatedAt DateTime @map("updated_at")
|
|
38
|
+
updatedBy String @map("updated_by")
|
|
39
|
+
|
|
40
|
+
@@map("system_categories")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
model SystemCategoryGroup {
|
|
44
|
+
id String @id @map("id")
|
|
45
|
+
code String @unique @map("code")
|
|
46
|
+
name String @map("name")
|
|
47
|
+
description String @map("description")
|
|
48
|
+
status String @default("active") @map("status")
|
|
49
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
50
|
+
createdBy String @map("created_by")
|
|
51
|
+
updatedAt DateTime @map("updated_at")
|
|
52
|
+
updatedBy String @map("updated_by")
|
|
53
|
+
|
|
54
|
+
@@map("system_category_groups")
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cảnh báo/thông báo hệ thống — entity CRUD generic (/crud/system-alerts).
|
|
58
|
+
// App chuẩn KHÔNG có model riêng (map tạm vào system_logs); template khai
|
|
59
|
+
// model thật khớp các field của system-alerts.config.ts.
|
|
60
|
+
model SystemAlert {
|
|
61
|
+
id String @id @default(cuid()) @map("id")
|
|
62
|
+
title String @map("title")
|
|
63
|
+
message String @map("message")
|
|
64
|
+
type String @default("info") @map("type")
|
|
65
|
+
status String @default("pending") @map("status")
|
|
66
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
67
|
+
createdBy String? @map("created_by")
|
|
68
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
69
|
+
updatedBy String? @map("updated_by")
|
|
70
|
+
|
|
71
|
+
@@index([status])
|
|
72
|
+
@@index([type])
|
|
73
|
+
@@map("system_alerts")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// Hồ sơ công ty gốc — MỘT dòng duy nhất, dùng cho biểu mẫu/hoá đơn/branding.
|
|
77
|
+
/// (Đơn giản hoá từ vinhhoa: không quan hệ branches — Branch của template
|
|
78
|
+
/// độc lập; app đa-công-ty tự thêm quan hệ sau.)
|
|
79
|
+
model Company {
|
|
80
|
+
id String @id @default(cuid()) @map("id")
|
|
81
|
+
name String @map("name")
|
|
82
|
+
taxCode String @unique @map("tax_code")
|
|
83
|
+
address String @map("address")
|
|
84
|
+
phone String @map("phone")
|
|
85
|
+
email String @map("email")
|
|
86
|
+
logoUrl String? @map("logo_url")
|
|
87
|
+
systemName String? @map("system_name")
|
|
88
|
+
status String @default("active") @map("status")
|
|
89
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
90
|
+
createdBy String @default("system") @map("created_by")
|
|
91
|
+
updatedAt DateTime @updatedAt @map("updated_at")
|
|
92
|
+
updatedBy String @default("system") @map("updated_by")
|
|
93
|
+
|
|
94
|
+
@@index([status])
|
|
95
|
+
@@map("companies")
|
|
96
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Seed dữ liệu khởi tạo — chạy được nhiều lần (toàn upsert).
|
|
3
3
|
*
|
|
4
|
-
* pnpm prisma:deploy && pnpm
|
|
4
|
+
* pnpm prisma:deploy && pnpm seed && pnpm rbac-sync
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* THỨ TỰ `seed → rbac-sync` là BẮT BUỘC: rbac-sync chỉ cấp grant mặc định cho
|
|
7
|
+
* resource nó vừa TẠO MỚI, và grant cần VAI TRÒ (do seed tạo) tồn tại sẵn.
|
|
8
|
+
* Chạy rbac-sync trước seed thì grant bị bỏ qua VĨNH VIỄN trong im lặng —
|
|
9
|
+
* lối thoát duy nhất là `pnpm rbac-sync --force-grants`.
|
|
10
|
+
*
|
|
11
|
+
* Phân công: `rbac-sync` sở hữu resource/action/grant mặc định (đọc từ
|
|
7
12
|
* Permission Registry); file này chỉ tạo những thứ registry không biết —
|
|
8
13
|
* VAI TRÒ, tài khoản quản trị, chi nhánh, cấu hình và vài dòng dữ liệu mẫu.
|
|
9
|
-
* Vì vậy hãy chạy rbac-sync TRƯỚC seed ở lần đầu... nhưng grant mặc định cần
|
|
10
|
-
* vai trò tồn tại sẵn, nên seed cũng tạo vai trò và bạn chạy rbac-sync lần nữa
|
|
11
|
-
* sau seed. Thứ tự an toàn nhất: seed → rbac-sync.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import { PrismaPg } from "@prisma/adapter-pg"
|
|
@@ -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
|
+
})
|
|
@@ -27,6 +27,7 @@ import { randomUUID } from "node:crypto"
|
|
|
27
27
|
|
|
28
28
|
import { PrismaPg } from "@prisma/adapter-pg"
|
|
29
29
|
import { PrismaClient } from "@prisma/client"
|
|
30
|
+
|
|
30
31
|
import "dotenv/config"
|
|
31
32
|
|
|
32
33
|
import { permissionRegistry } from "../src/configs/permissions"
|
|
@@ -46,29 +47,39 @@ const STANDARD_ACTIONS: Record<string, string> = {
|
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
const connectionString = process.env.DATABASE_URL
|
|
49
|
-
if (!connectionString)
|
|
50
|
+
if (!connectionString)
|
|
51
|
+
throw new Error("DATABASE_URL chưa được set (xem .env.example)")
|
|
50
52
|
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) })
|
|
51
53
|
|
|
52
54
|
async function main() {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
+
const resourceCount = permissionRegistry.reduce(
|
|
56
|
+
(n, f) => n + f.resources.length,
|
|
57
|
+
0
|
|
58
|
+
)
|
|
55
59
|
console.log(
|
|
56
|
-
`→ rbac-sync: ${permissionRegistry.length} phân hệ, ${resourceCount} resource${DRY_RUN ? " [DRY RUN]" : ""}
|
|
60
|
+
`→ rbac-sync: ${permissionRegistry.length} phân hệ, ${resourceCount} resource${DRY_RUN ? " [DRY RUN]" : ""}`
|
|
57
61
|
)
|
|
58
62
|
|
|
59
63
|
// ── 1. Action: tạo nếu thiếu ───────────────────────────────────────────
|
|
60
|
-
const declaredActions = new Map<
|
|
64
|
+
const declaredActions = new Map<
|
|
65
|
+
string,
|
|
66
|
+
{ name: string; description?: string }
|
|
67
|
+
>()
|
|
61
68
|
for (const feature of permissionRegistry) {
|
|
62
69
|
for (const a of feature.customActions ?? []) {
|
|
63
70
|
declaredActions.set(a.code, { name: a.name, description: a.description })
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
const usedActionCodes = new Set(
|
|
67
|
-
permissionRegistry.flatMap((f) =>
|
|
74
|
+
permissionRegistry.flatMap((f) =>
|
|
75
|
+
f.resources.flatMap((r) => [...r.actions])
|
|
76
|
+
)
|
|
68
77
|
)
|
|
69
78
|
|
|
70
79
|
const existingActionCodes = new Set(
|
|
71
|
-
(await prisma.action.findMany({ select: { code: true } })).map(
|
|
80
|
+
(await prisma.action.findMany({ select: { code: true } })).map(
|
|
81
|
+
(a) => a.code
|
|
82
|
+
)
|
|
72
83
|
)
|
|
73
84
|
|
|
74
85
|
for (const code of usedActionCodes) {
|
|
@@ -81,7 +92,7 @@ async function main() {
|
|
|
81
92
|
// Dừng hẳn thay vì tạo action rác: sai chính tả một action code sẽ khiến
|
|
82
93
|
// quyền không bao giờ khớp và nút bấm ẩn vĩnh viễn.
|
|
83
94
|
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
|
|
95
|
+
`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
96
|
)
|
|
86
97
|
}
|
|
87
98
|
console.log(` + action: ${code} (${decl.name})`)
|
|
@@ -113,12 +124,14 @@ async function main() {
|
|
|
113
124
|
}
|
|
114
125
|
// Lớp tùy chỉnh của admin (thêm/tắt action trên UI) được BẢO TOÀN:
|
|
115
126
|
// hiệu lực = registry ∪ customActions − disabledActions
|
|
116
|
-
const custom = Array.isArray(config.customActions)
|
|
127
|
+
const custom = Array.isArray(config.customActions)
|
|
128
|
+
? (config.customActions as string[])
|
|
129
|
+
: []
|
|
117
130
|
const disabled = Array.isArray(config.disabledActions)
|
|
118
131
|
? (config.disabledActions as string[])
|
|
119
132
|
: []
|
|
120
133
|
config.actions = [...new Set([...r.actions, ...custom])].filter(
|
|
121
|
-
(a) => !disabled.includes(a)
|
|
134
|
+
(a) => !disabled.includes(a)
|
|
122
135
|
)
|
|
123
136
|
|
|
124
137
|
if (!existing) {
|
|
@@ -147,14 +160,16 @@ async function main() {
|
|
|
147
160
|
}
|
|
148
161
|
|
|
149
162
|
// ── 3. Grant mặc định — CHỈ cho resource vừa tạo (trừ khi --force-grants) ──
|
|
150
|
-
const wantsGrants = (code: string) =>
|
|
163
|
+
const wantsGrants = (code: string) =>
|
|
164
|
+
FORCE_GRANTS || createdResourceCodes.has(code)
|
|
151
165
|
let skippedGrants = 0
|
|
152
166
|
|
|
153
167
|
const neededRoleCodes = new Set<string>()
|
|
154
168
|
for (const feature of permissionRegistry) {
|
|
155
169
|
for (const r of feature.resources) {
|
|
156
170
|
if (!wantsGrants(r.code)) continue
|
|
157
|
-
for (const roleCode of Object.keys(r.defaultGrants ?? {}))
|
|
171
|
+
for (const roleCode of Object.keys(r.defaultGrants ?? {}))
|
|
172
|
+
neededRoleCodes.add(roleCode)
|
|
158
173
|
}
|
|
159
174
|
}
|
|
160
175
|
const presentRoles = new Set(
|
|
@@ -165,7 +180,7 @@ async function main() {
|
|
|
165
180
|
select: { code: true },
|
|
166
181
|
})
|
|
167
182
|
).map((r) => r.code)
|
|
168
|
-
: []
|
|
183
|
+
: []
|
|
169
184
|
)
|
|
170
185
|
|
|
171
186
|
for (const feature of permissionRegistry) {
|
|
@@ -174,11 +189,14 @@ async function main() {
|
|
|
174
189
|
for (const [roleCode, grant] of Object.entries(r.defaultGrants ?? {})) {
|
|
175
190
|
if (!presentRoles.has(roleCode)) {
|
|
176
191
|
skippedGrants++
|
|
177
|
-
console.warn(
|
|
192
|
+
console.warn(
|
|
193
|
+
` ! vai trò "${roleCode}" chưa tồn tại — bỏ qua grant cho ${r.code}`
|
|
194
|
+
)
|
|
178
195
|
continue
|
|
179
196
|
}
|
|
180
197
|
for (const actionCode of grant === "*" ? r.actions : grant) {
|
|
181
|
-
if (VERBOSE || !FORCE_GRANTS)
|
|
198
|
+
if (VERBOSE || !FORCE_GRANTS)
|
|
199
|
+
console.log(` ✓ grant: ${roleCode} → ${r.code}:${actionCode}`)
|
|
182
200
|
if (!DRY_RUN) {
|
|
183
201
|
await prisma.rolePermission.upsert({
|
|
184
202
|
where: {
|
|
@@ -188,7 +206,12 @@ async function main() {
|
|
|
188
206
|
actionCode,
|
|
189
207
|
},
|
|
190
208
|
},
|
|
191
|
-
create: {
|
|
209
|
+
create: {
|
|
210
|
+
id: randomUUID(),
|
|
211
|
+
roleCode,
|
|
212
|
+
resourceCode: r.code,
|
|
213
|
+
actionCode,
|
|
214
|
+
},
|
|
192
215
|
update: {},
|
|
193
216
|
})
|
|
194
217
|
}
|
|
@@ -199,22 +222,27 @@ async function main() {
|
|
|
199
222
|
|
|
200
223
|
// ── 4. Drift (chỉ cảnh báo) ────────────────────────────────────────────
|
|
201
224
|
const registryCodes = new Set(
|
|
202
|
-
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code))
|
|
225
|
+
permissionRegistry.flatMap((f) => f.resources.map((r) => r.code))
|
|
203
226
|
)
|
|
204
227
|
const legacy = (
|
|
205
|
-
await prisma.resource.findMany({
|
|
228
|
+
await prisma.resource.findMany({
|
|
229
|
+
where: { status: "active" },
|
|
230
|
+
select: { code: true },
|
|
231
|
+
})
|
|
206
232
|
)
|
|
207
233
|
.map((r) => r.code)
|
|
208
234
|
.filter((code) => !registryCodes.has(code))
|
|
209
235
|
.sort()
|
|
210
236
|
|
|
211
237
|
if (legacy.length) {
|
|
212
|
-
console.log(
|
|
238
|
+
console.log(
|
|
239
|
+
`→ Drift: ${legacy.length} resource trong DB chưa khai registry`
|
|
240
|
+
)
|
|
213
241
|
if (VERBOSE) console.log(` ${legacy.join(", ")}`)
|
|
214
242
|
}
|
|
215
243
|
|
|
216
244
|
console.log(
|
|
217
|
-
`→ Xong: ${createdResourceCodes.size} resource mới, ${registryCodes.size - createdResourceCodes.size} resource cập nhật
|
|
245
|
+
`→ Xong: ${createdResourceCodes.size} resource mới, ${registryCodes.size - createdResourceCodes.size} resource cập nhật.`
|
|
218
246
|
)
|
|
219
247
|
|
|
220
248
|
// Bỏ qua grant = resource đã tạo nhưng KHÔNG ai có quyền, và lần chạy sau
|
|
@@ -222,7 +250,7 @@ async function main() {
|
|
|
222
250
|
if (skippedGrants) {
|
|
223
251
|
console.warn(
|
|
224
252
|
`\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
|
|
253
|
+
` 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
254
|
)
|
|
227
255
|
}
|
|
228
256
|
}
|
|
@@ -2,9 +2,10 @@ import { join } from "node:path"
|
|
|
2
2
|
|
|
3
3
|
import { runCoreGuardrails } from "@goerp/core/guardrails"
|
|
4
4
|
|
|
5
|
-
import { permissionRegistry } from "@/configs/permissions"
|
|
6
5
|
import { navigations } from "@/data/navigations"
|
|
7
6
|
|
|
7
|
+
import { permissionRegistry } from "@/configs/permissions"
|
|
8
|
+
|
|
8
9
|
/**
|
|
9
10
|
* HÀNG RÀO KIẾN TRÚC — mỗi thước ở đây ứng với một lỗi im lặng đã từng làm mất
|
|
10
11
|
* thời gian trong app thật: quyền khai sai thì nút biến mất mà không báo lỗi,
|
|
@@ -29,6 +30,14 @@ runCoreGuardrails({
|
|
|
29
30
|
permissionRegistry,
|
|
30
31
|
navigations,
|
|
31
32
|
|
|
33
|
+
allowlists: {
|
|
34
|
+
// 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
|
|
35
|
+
// user:update — quyền phụ thuộc dữ liệu request nên không khai được ở cổng.
|
|
36
|
+
"auth/api-inline-check-permission": [
|
|
37
|
+
"app/api/security/sessions/[id]/route.ts",
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
|
|
32
41
|
ceilings: {
|
|
33
42
|
// Năm chỗ `any` này có sẵn trong bộ khung, mỗi chỗ đã kèm một dòng
|
|
34
43
|
// eslint-disable giải thích. Chúng được KHAI ở đây để mọi `any` bạn thêm về
|
|
@@ -36,7 +45,7 @@ runCoreGuardrails({
|
|
|
36
45
|
// tương ứng trong CÙNG commit — trần dư cũng bị báo đỏ.
|
|
37
46
|
"debt/any": {
|
|
38
47
|
app: 2, // Record<string, any> cho where của Prisma (audit, error-logs)
|
|
39
|
-
lib:
|
|
48
|
+
lib: 0, // Session của app gán thẳng vào checkPermission — không cần ép kiểu
|
|
40
49
|
server: 1, // mask(config: any) trong system-config-service
|
|
41
50
|
},
|
|
42
51
|
},
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Trang hành động = catalog TRA CỨU. Action mới khai trong customActions của
|
|
2
|
+
// Permission Registry rồi chạy rbac-sync.
|
|
3
|
+
import { ActionCatalogPage } from "@goerp/core/rbac/pages"
|
|
4
|
+
import {
|
|
5
|
+
getActionsForPermissionMatrix,
|
|
6
|
+
getResourcesForPermissionMatrix,
|
|
7
|
+
} from "@goerp/core/rbac/resource-service"
|
|
8
|
+
|
|
9
|
+
import { getActionMetaMap } from "@/configs/permissions"
|
|
10
|
+
import { RESOURCE_PAGES } from "@/configs/permissions/menu-tree"
|
|
11
|
+
import { requirePageAccess } from "@/lib/page-guard"
|
|
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,68 @@
|
|
|
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
|
+
if (!checkPermission(session, "system-cache", action)) {
|
|
17
|
+
throw new Error("Forbidden")
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default async function CachePage({
|
|
22
|
+
params,
|
|
23
|
+
}: {
|
|
24
|
+
params: Promise<{ lang: string }>
|
|
25
|
+
}) {
|
|
26
|
+
const { lang } = await params
|
|
27
|
+
await requirePageAccess(lang, "system-cache")
|
|
28
|
+
|
|
29
|
+
async function fetchStats() {
|
|
30
|
+
"use server"
|
|
31
|
+
await assertCacheAccess("view")
|
|
32
|
+
return cacheManager.getStats()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function clearCache(name: string) {
|
|
36
|
+
"use server"
|
|
37
|
+
await assertCacheAccess("update")
|
|
38
|
+
return cacheManager.clearCache(name)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function clearAll() {
|
|
42
|
+
"use server"
|
|
43
|
+
await assertCacheAccess("update")
|
|
44
|
+
return cacheManager.flushAll()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function getCacheKeys(name: string) {
|
|
48
|
+
"use server"
|
|
49
|
+
await assertCacheAccess("view")
|
|
50
|
+
return cacheManager.get(name)?.keys() ?? []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function getCacheValue(name: string, key: string) {
|
|
54
|
+
"use server"
|
|
55
|
+
await assertCacheAccess("view")
|
|
56
|
+
return cacheManager.get(name)?.get(key) ?? null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<CacheManagementPage
|
|
61
|
+
fetchStats={fetchStats}
|
|
62
|
+
clearCache={clearCache}
|
|
63
|
+
clearAll={clearAll}
|
|
64
|
+
getCacheKeys={getCacheKeys}
|
|
65
|
+
getCacheValue={getCacheValue}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use server"
|
|
2
|
+
|
|
3
|
+
import { revalidatePath } from "next/cache"
|
|
4
|
+
import { updateSystemCompany } from "@/server/services/company-service"
|
|
5
|
+
|
|
6
|
+
import type { CompanyProfileInput } from "@/server/services/company-service"
|
|
7
|
+
|
|
8
|
+
import { requirePermission } from "@/lib/action-guard"
|
|
9
|
+
|
|
10
|
+
/** Hồ sơ công ty dùng chung quyền với Cấu hình hệ thống (system-setting). */
|
|
11
|
+
export async function submitCompanyProfile(data: CompanyProfileInput) {
|
|
12
|
+
let userId: string
|
|
13
|
+
try {
|
|
14
|
+
const session = await requirePermission("system-setting", "update")
|
|
15
|
+
userId = session.user?.id ?? "system"
|
|
16
|
+
} catch {
|
|
17
|
+
return {
|
|
18
|
+
success: false,
|
|
19
|
+
error: "Bạn không có quyền cập nhật hồ sơ công ty",
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await updateSystemCompany(data, userId)
|
|
25
|
+
revalidatePath("/admin/system/company")
|
|
26
|
+
// Tên/logo công ty có thể hiện ở layout + trang in — revalidate cả cây.
|
|
27
|
+
revalidatePath("/", "layout")
|
|
28
|
+
return { success: true }
|
|
29
|
+
} catch (error) {
|
|
30
|
+
console.error("Cập nhật hồ sơ công ty lỗi:", error)
|
|
31
|
+
return { success: false, error: "Đã có lỗi khi lưu hồ sơ công ty." }
|
|
32
|
+
}
|
|
33
|
+
}
|