@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
|
@@ -7,16 +7,15 @@
|
|
|
7
7
|
// lives here so every app stops re-implementing it.
|
|
8
8
|
//
|
|
9
9
|
// Usage (app side):
|
|
10
|
-
// import { createServerCrudService
|
|
10
|
+
// import { createServerCrudService } from "@goerp/core/crud/server";
|
|
11
11
|
// import { prisma } from "@/lib/prisma";
|
|
12
|
-
// const
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// getModelName: (e) => getModelName(e, MODEL_MAP),
|
|
16
|
-
// });
|
|
12
|
+
// export const crudService = createServerCrudService({ prisma });
|
|
13
|
+
// Tên model Prisma khai ngay trong EntityConfig (`modelName: "feeSchedule"`),
|
|
14
|
+
// không cần MODEL_MAP riêng; thiếu modelName thì fallback convention getModelName.
|
|
17
15
|
|
|
18
16
|
import { buildListQuery } from "./lib/query-builder";
|
|
19
17
|
import { prepareMutationData } from "./lib/mutation-builder";
|
|
18
|
+
import { CrudRequestError } from "./lib/errors";
|
|
20
19
|
import type { CrudQueryParams, CrudResponse, EntityConfig } from "../types";
|
|
21
20
|
import { serializeDecimalFields } from "../utils/serialize";
|
|
22
21
|
|
|
@@ -24,14 +23,16 @@ type PrismaLike = Record<string, any>;
|
|
|
24
23
|
|
|
25
24
|
export interface ServerCrudService {
|
|
26
25
|
list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
|
|
27
|
-
/** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id.
|
|
26
|
+
/** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id.
|
|
27
|
+
* Mọi method nhận id dạng chuỗi thô từ URL; entity `idKind: "int"` được ép
|
|
28
|
+
* Number bên trong (sai định dạng → {@link CrudRequestError} 400). */
|
|
28
29
|
getById(entity: string, id: string, config?: EntityConfig): Promise<any>;
|
|
29
30
|
/** `tx` (tùy chọn) để app chạy create CÙNG transaction với sinh số phiếu
|
|
30
31
|
* advisory-lock (document-number yêu cầu cùng tx với insert). */
|
|
31
32
|
create(entity: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
|
|
32
33
|
update(entity: string, id: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
|
|
33
|
-
delete(entity: string, id: string): Promise<any>;
|
|
34
|
-
deleteMany(entity: string, ids: string[]): Promise<any>;
|
|
34
|
+
delete(entity: string, id: string, config?: EntityConfig): Promise<any>;
|
|
35
|
+
deleteMany(entity: string, ids: string[], config?: EntityConfig): Promise<any>;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export interface ServerCrudLogger {
|
|
@@ -79,13 +80,31 @@ const DEFAULT_SYSTEM_FIELDS = [
|
|
|
79
80
|
|
|
80
81
|
const MAX_PAGE_SIZE = 200;
|
|
81
82
|
|
|
82
|
-
// Default plural→model resolver: strip trailing "s", camelCase kebab.
|
|
83
|
-
//
|
|
83
|
+
// Default plural→model resolver: strip trailing "s", camelCase kebab. Ưu tiên
|
|
84
|
+
// khai `modelName` ngay trong EntityConfig; map ở đây chỉ còn cho chỗ gọi cũ
|
|
85
|
+
// chưa có config trong tay.
|
|
84
86
|
export function getModelName(entity: string, map?: Record<string, string>): string {
|
|
85
87
|
if (map && map[entity]) return map[entity];
|
|
86
88
|
return entity.replace(/s$/, "").replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// Ép id từ URL theo idKind của entity — "abc" vào cột Int phải trả 400 rõ
|
|
92
|
+
// ràng thay vì để Prisma nổ 500.
|
|
93
|
+
function coerceIdParam(
|
|
94
|
+
entity: string,
|
|
95
|
+
config: EntityConfig | undefined,
|
|
96
|
+
id: string,
|
|
97
|
+
): string | number {
|
|
98
|
+
if (config?.idKind !== "int") return id;
|
|
99
|
+
const num = Number(id);
|
|
100
|
+
if (!Number.isInteger(num)) {
|
|
101
|
+
throw new CrudRequestError(
|
|
102
|
+
`Id không hợp lệ cho "${entity}": "${id}" (idKind=int cần số nguyên)`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
return num;
|
|
106
|
+
}
|
|
107
|
+
|
|
89
108
|
// Opt-in audit-name resolver. Overwrites createdByName/updatedByName from a User
|
|
90
109
|
// model. `nameField` handles schema drift (vinhhoa: "name", wu: "fullName").
|
|
91
110
|
export function createAuditUserNameResolver(opts: {
|
|
@@ -130,18 +149,18 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
130
149
|
|
|
131
150
|
const systemFields = deps.systemFields ?? DEFAULT_SYSTEM_FIELDS;
|
|
132
151
|
|
|
133
|
-
|
|
134
|
-
|
|
152
|
+
// config.modelName thắng convention/MODEL_MAP — một registry duy nhất ở
|
|
153
|
+
// entity config, app không phải nuôi map song song.
|
|
154
|
+
const model = (entity: string, config?: EntityConfig, client: PrismaLike = prisma) => {
|
|
155
|
+
const name = config?.modelName ?? resolveModel(entity);
|
|
135
156
|
const m = client[name];
|
|
136
157
|
if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
|
|
137
158
|
return m;
|
|
138
159
|
};
|
|
139
160
|
|
|
140
|
-
|
|
141
|
-
|
|
142
161
|
return {
|
|
143
162
|
async list(entity, config, params) {
|
|
144
|
-
const prismaModel = model(entity);
|
|
163
|
+
const prismaModel = model(entity, config);
|
|
145
164
|
|
|
146
165
|
const {
|
|
147
166
|
where,
|
|
@@ -174,12 +193,13 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
174
193
|
},
|
|
175
194
|
|
|
176
195
|
async getById(entity, id, config) {
|
|
177
|
-
const prismaModel = model(entity);
|
|
196
|
+
const prismaModel = model(entity, config);
|
|
178
197
|
// Áp cùng include như list — detail dialog hiện label relation thay raw FK id.
|
|
179
198
|
const include: any = {};
|
|
180
199
|
if (config?.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
181
200
|
const includeOption = Object.keys(include).length ? { include } : {};
|
|
182
|
-
const
|
|
201
|
+
const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
|
|
202
|
+
const result = await prismaModel.findUnique({ where, ...includeOption });
|
|
183
203
|
if (!result) return null;
|
|
184
204
|
const serialized = serializeDecimalFields(result);
|
|
185
205
|
const [resolved] = await resolveAuditNames([serialized]);
|
|
@@ -187,7 +207,7 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
187
207
|
},
|
|
188
208
|
|
|
189
209
|
async create(entity, data, config, tx) {
|
|
190
|
-
const prismaModel = model(entity, tx ?? prisma);
|
|
210
|
+
const prismaModel = model(entity, config, tx ?? prisma);
|
|
191
211
|
try {
|
|
192
212
|
const prismaData = prepareMutationData({
|
|
193
213
|
data,
|
|
@@ -206,7 +226,8 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
206
226
|
},
|
|
207
227
|
|
|
208
228
|
async update(entity, id, data, config, tx) {
|
|
209
|
-
const prismaModel = model(entity, tx ?? prisma);
|
|
229
|
+
const prismaModel = model(entity, config, tx ?? prisma);
|
|
230
|
+
const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
|
|
210
231
|
try {
|
|
211
232
|
const prismaData = prepareMutationData({
|
|
212
233
|
data,
|
|
@@ -216,27 +237,31 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
216
237
|
relationFieldSkip: deps.relationFieldSkip,
|
|
217
238
|
logger: log,
|
|
218
239
|
});
|
|
219
|
-
return serializeDecimalFields(await prismaModel.update({ where
|
|
240
|
+
return serializeDecimalFields(await prismaModel.update({ where, data: prismaData }));
|
|
220
241
|
} catch (error) {
|
|
221
242
|
log.error(`Error updating ${entity}:`, error);
|
|
222
243
|
throw error;
|
|
223
244
|
}
|
|
224
245
|
},
|
|
225
246
|
|
|
226
|
-
async delete(entity, id) {
|
|
227
|
-
const prismaModel = model(entity);
|
|
247
|
+
async delete(entity, id, config) {
|
|
248
|
+
const prismaModel = model(entity, config);
|
|
249
|
+
const where = { [config?.idField ?? "id"]: coerceIdParam(entity, config, id) };
|
|
228
250
|
try {
|
|
229
|
-
return await prismaModel.delete({ where
|
|
251
|
+
return await prismaModel.delete({ where });
|
|
230
252
|
} catch (error) {
|
|
231
253
|
log.error(`Error deleting ${entity}:`, error);
|
|
232
254
|
throw error;
|
|
233
255
|
}
|
|
234
256
|
},
|
|
235
257
|
|
|
236
|
-
async deleteMany(entity, ids) {
|
|
237
|
-
const prismaModel = model(entity);
|
|
258
|
+
async deleteMany(entity, ids, config) {
|
|
259
|
+
const prismaModel = model(entity, config);
|
|
260
|
+
// Ép cả mảng — một phần tử sai định dạng là 400 cả request, không xóa nửa vời.
|
|
261
|
+
const idField = config?.idField ?? "id";
|
|
262
|
+
const where = { [idField]: { in: ids.map((id) => coerceIdParam(entity, config, id)) } };
|
|
238
263
|
try {
|
|
239
|
-
return await prismaModel.deleteMany({ where
|
|
264
|
+
return await prismaModel.deleteMany({ where });
|
|
240
265
|
} catch (error) {
|
|
241
266
|
log.error(`Error deleting many ${entity}:`, error);
|
|
242
267
|
throw error;
|
package/src/crud/server.ts
CHANGED
|
@@ -22,6 +22,16 @@ export type {
|
|
|
22
22
|
export {
|
|
23
23
|
createCrudCollectionHandlers,
|
|
24
24
|
createCrudItemHandlers,
|
|
25
|
+
createCrudExportHandler,
|
|
26
|
+
createCrudImportHandler,
|
|
25
27
|
} from './crud-route-handlers'
|
|
26
|
-
export type {
|
|
28
|
+
export type {
|
|
29
|
+
CrudHandlerDeps,
|
|
30
|
+
CrudExportHandlerDeps,
|
|
31
|
+
CrudImportHandlerDeps,
|
|
32
|
+
} from './crud-route-handlers'
|
|
33
|
+
export { readImportRows, coerceBooleanCell } from './lib/import-request'
|
|
27
34
|
export { compileFilterTree, conditionForOperator, type FilterTreeLeaf, type FilterTreeNode } from "./lib/filter-tree";
|
|
35
|
+
export { CrudRequestError } from "./lib/errors";
|
|
36
|
+
export { coerceFieldValue } from "./lib/coerce";
|
|
37
|
+
export { getEntityEndpoints } from "./lib/entity-endpoints";
|
package/src/guardrails/index.ts
CHANGED
|
@@ -42,6 +42,26 @@ function publicPrefixes(ctx: GuardrailContext): string[] {
|
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* MỌI tiền tố trong danh sách công khai của proxy.ts — kể cả ngoài /api
|
|
47
|
+
* (/pub, /iclock, webhook…). Chỉ đọc bên trong mảng `publicPrefixes:`/
|
|
48
|
+
* `publicApiPrefixes:` chứ không vơ mọi chuỗi (signInPath/homePath không phải
|
|
49
|
+
* tuyên bố công khai).
|
|
50
|
+
*/
|
|
51
|
+
function declaredPublicPrefixes(ctx: GuardrailContext): string[] {
|
|
52
|
+
if (!ctx.existsInSrc("proxy.ts")) return [];
|
|
53
|
+
const code = ctx.readCodeRel("proxy.ts");
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
for (const m of code.matchAll(
|
|
56
|
+
/public(?:Api)?Prefixes\s*:\s*\[([^\]]*)\]/g,
|
|
57
|
+
)) {
|
|
58
|
+
for (const s of m[1].matchAll(/["'](\/[^"']*)["']/g)) {
|
|
59
|
+
out.push(s[1].replace(/\/$/, ""));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
function gateSources(ctx: GuardrailContext, code: string): string[] {
|
|
46
66
|
const targets = [...code.matchAll(/export \* from ["']@\/([^"']+)["']/g)].map(
|
|
47
67
|
(m) => m[1],
|
|
@@ -77,7 +97,8 @@ export const authRules: GuardrailRule[] = [
|
|
|
77
97
|
// Hai lối dựng đều hợp lệ: factory của core, hoặc bản tự dựng của app.
|
|
78
98
|
// Ràng cứng vào một lối là bắt app phải giống hệt vinhhoa về CÁCH VIẾT,
|
|
79
99
|
// trong khi thứ cần ràng là TÍNH CHẤT: /api chặn trước, công khai phải khai.
|
|
80
|
-
const viaCore =
|
|
100
|
+
const viaCore =
|
|
101
|
+
/createAuthProxy\s*\(/.test(code) && /public(?:Api)?Prefixes/.test(code);
|
|
81
102
|
const handRolled =
|
|
82
103
|
/getSessionCookie/.test(code) &&
|
|
83
104
|
/pathname\.startsWith\(["']\/api["']\)/.test(code) &&
|
|
@@ -151,6 +172,38 @@ export const authRules: GuardrailRule[] = [
|
|
|
151
172
|
},
|
|
152
173
|
},
|
|
153
174
|
|
|
175
|
+
{
|
|
176
|
+
id: "auth/route-outside-api-declared",
|
|
177
|
+
title: "route.ts NGOÀI app/api phải khai tiền tố trong danh sách công khai của proxy, hoặc tự gác",
|
|
178
|
+
why:
|
|
179
|
+
"Endpoint server ngoài /api (route thiết bị /pub, /iclock của spartronics; " +
|
|
180
|
+
"webhook đặt ngoài /api…) từng VÔ HÌNH với mọi thước: rule api-route-gated " +
|
|
181
|
+
"chỉ quét app/api/**, còn proxy mặc-định-chặn thì các route này lại cần được " +
|
|
182
|
+
"MỞ — một bề mặt công khai không ai review thấy.",
|
|
183
|
+
fix: "Thêm tiền tố vào `publicPrefixes` trong src/proxy.ts kèm ghi rõ nó tự xác thực bằng gì (allowlist serial, HMAC…), hoặc bọc cổng như route API thường.",
|
|
184
|
+
run(ctx) {
|
|
185
|
+
const routes = ctx.files
|
|
186
|
+
.map(ctx.rel)
|
|
187
|
+
.filter(
|
|
188
|
+
(rel) =>
|
|
189
|
+
rel.startsWith("app/") &&
|
|
190
|
+
rel.endsWith("/route.ts") &&
|
|
191
|
+
!rel.startsWith("app/api/"),
|
|
192
|
+
);
|
|
193
|
+
if (routes.length === 0) return null;
|
|
194
|
+
const publics = declaredPublicPrefixes(ctx);
|
|
195
|
+
const allowed = new Set(
|
|
196
|
+
ctx.options.allowlists["auth/route-outside-api-declared"] ?? [],
|
|
197
|
+
);
|
|
198
|
+
return routes.filter((rel) => {
|
|
199
|
+
if (allowed.has(rel)) return false;
|
|
200
|
+
const url = routeUrl(rel);
|
|
201
|
+
if (publics.some((p) => url === p || url.startsWith(`${p}/`))) return false;
|
|
202
|
+
return !gateSources(ctx, ctx.readCodeRel(rel)).some((src) => GATED.test(src));
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
|
|
154
207
|
forbidPattern({
|
|
155
208
|
id: "auth/server-action-bare-session",
|
|
156
209
|
title: "file 'use server' không gọi getSession trần — dùng requirePermission",
|
|
@@ -72,12 +72,13 @@ export const designRules: GuardrailRule[] = [
|
|
|
72
72
|
|
|
73
73
|
{
|
|
74
74
|
id: "design/list-table-uses-kit",
|
|
75
|
-
title:
|
|
75
|
+
title:
|
|
76
|
+
"bảng mới trong app/ dùng DataTable (@goerp/core/ui — import { DataTable }), không tự dựng <Table>",
|
|
76
77
|
why:
|
|
77
78
|
"Bảng tự dựng lại thiếu một trong: header dính, phân trang, trạng thái " +
|
|
78
79
|
"rỗng, hàng bấm được, footer tổng. Mỗi bảng thiếu một thứ khác nhau, và " +
|
|
79
80
|
"người dùng học lại thao tác ở mỗi trang.",
|
|
80
|
-
fix: "Dùng `
|
|
81
|
+
fix: "Dùng `DataTable` (@goerp/core/ui — `import { DataTable }`) + cột tanstack trong `*-columns.tsx`. Thật sự phải tự dựng thì thêm vào allowlist kèm lý do.",
|
|
81
82
|
run(ctx) {
|
|
82
83
|
const allowed = new Set(ctx.options.allowlists["design/list-table-uses-kit"] ?? []);
|
|
83
84
|
const handRolled = ctx.files
|
package/src/guardrails/types.ts
CHANGED
|
@@ -81,9 +81,9 @@ export interface GuardrailOptions {
|
|
|
81
81
|
* Registry quyền của app (`src/configs/permissions`). Thiếu thì nhóm rule
|
|
82
82
|
* `rbac/*` tự bỏ qua.
|
|
83
83
|
*/
|
|
84
|
-
permissionRegistry?: PermissionFeatureLike[];
|
|
84
|
+
permissionRegistry?: readonly PermissionFeatureLike[];
|
|
85
85
|
/** Cây menu (`src/data/navigations`). Thiếu thì rule nav tự bỏ qua. */
|
|
86
|
-
navigations?: NavigationGroupLike[];
|
|
86
|
+
navigations?: readonly NavigationGroupLike[];
|
|
87
87
|
/**
|
|
88
88
|
* Bộ action ai cũng hiểu. PHẢI trùng danh sách trong `scripts/rbac-sync.ts`
|
|
89
89
|
* của app — lệch nhau thì test xanh mà seed ném lỗi lúc deploy.
|
|
@@ -158,17 +158,26 @@ export type ResolvedGuardrailOptions = GuardrailOptions &
|
|
|
158
158
|
* guardrails không kéo theo phụ thuộc ngược lên tầng config.
|
|
159
159
|
*/
|
|
160
160
|
export interface PermissionFeatureLike {
|
|
161
|
-
resources:
|
|
161
|
+
resources: ReadonlyArray<{
|
|
162
162
|
code: string;
|
|
163
|
-
actions: string[];
|
|
164
|
-
defaultGrants?: Record<string, string[] | "*"
|
|
163
|
+
actions: readonly string[];
|
|
164
|
+
defaultGrants?: Readonly<Record<string, readonly string[] | "*">>;
|
|
165
165
|
}>;
|
|
166
|
-
customActions?:
|
|
166
|
+
customActions?: ReadonlyArray<{ code: string }>;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* CHỈ khai trường rule thật sự đọc, KHÔNG index signature: một interface cụ
|
|
171
|
+
* thể (NavigationType của app) gán vào đây được theo structural typing, còn
|
|
172
|
+
* `[key: string]: unknown` thì bắt app phải có index signature — chính là lỗi
|
|
173
|
+
* TS mà template dính ngay sau init (pilot spartronics 2026-08-07).
|
|
174
|
+
*/
|
|
175
|
+
export interface NavigationItemLike {
|
|
176
|
+
resource?: string;
|
|
167
177
|
}
|
|
168
178
|
|
|
169
179
|
export interface NavigationGroupLike {
|
|
170
|
-
items?:
|
|
171
|
-
[key: string]: unknown;
|
|
180
|
+
items?: readonly NavigationItemLike[];
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
/** Kết quả chạy một rule — dùng cho `goerp doctor` (không cần vitest). */
|
|
@@ -20,6 +20,26 @@ export interface BrandThemeColor {
|
|
|
20
20
|
sidebarDark: string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Bảng màu thương hiệu CALIBRATE SẴN, khớp 1-1 với swatch trong
|
|
25
|
+
* `configs/themes.ts` — app mới dùng thẳng (theme-provider của starter-app),
|
|
26
|
+
* app muốn nắn tay từng hue (như vinhhoa) thì giữ bảng riêng và bỏ qua cái này.
|
|
27
|
+
*/
|
|
28
|
+
export const THEME_PRESETS: Record<string, BrandThemeColor> = {
|
|
29
|
+
zinc: { primary: "235 12% 27%", primaryDark: "235 10% 62%", fg: "0 0% 98%", sidebar: "235 18% 15%", sidebarDark: "235 16% 9%" },
|
|
30
|
+
slate: { primary: "213 30% 42%", primaryDark: "213 26% 64%", fg: "0 0% 100%", sidebar: "214 36% 19%", sidebarDark: "214 32% 12%" },
|
|
31
|
+
stone: { primary: "26 20% 40%", primaryDark: "28 16% 62%", fg: "0 0% 98%", sidebar: "26 28% 18%", sidebarDark: "26 24% 11%" },
|
|
32
|
+
gray: { primary: "218 9% 48%", primaryDark: "218 9% 68%", fg: "0 0% 100%", sidebar: "218 16% 19%", sidebarDark: "218 14% 12%" },
|
|
33
|
+
neutral: { primary: "0 0% 42%", primaryDark: "0 0% 66%", fg: "0 0% 100%", sidebar: "0 0% 16%", sidebarDark: "0 0% 9%" },
|
|
34
|
+
red: { primary: "4 78% 50%", primaryDark: "6 84% 62%", fg: "0 0% 100%", sidebar: "356 62% 28%", sidebarDark: "356 58% 16%" },
|
|
35
|
+
rose: { primary: "338 80% 53%", primaryDark: "340 80% 63%", fg: "0 0% 100%", sidebar: "338 56% 28%", sidebarDark: "338 54% 16%" },
|
|
36
|
+
orange: { primary: "26 88% 47%", primaryDark: "30 92% 58%", fg: "0 0% 100%", sidebar: "20 75% 28%", sidebarDark: "22 72% 15%" },
|
|
37
|
+
green: { primary: "151 66% 38%", primaryDark: "150 58% 48%", fg: "0 0% 100%", sidebar: "156 70% 16%", sidebarDark: "158 68% 9%" },
|
|
38
|
+
blue: { primary: "233 74% 44%", primaryDark: "230 62% 60%", fg: "0 0% 100%", sidebar: "233.9 88.8% 17.5%", sidebarDark: "234 50% 10%" },
|
|
39
|
+
yellow: { primary: "42 92% 46%", primaryDark: "46 96% 56%", fg: "40 60% 12%", sidebar: "40 72% 25%", sidebarDark: "42 70% 14%" },
|
|
40
|
+
violet: { primary: "262 72% 52%", primaryDark: "263 72% 64%", fg: "0 0% 100%", sidebar: "264 56% 25%", sidebarDark: "265 52% 15%" },
|
|
41
|
+
};
|
|
42
|
+
|
|
23
43
|
/** Dịch lightness của "H S% L%" (cho hover/active). */
|
|
24
44
|
export function shiftLightness(triple: string, delta: number): string {
|
|
25
45
|
const parts = triple.trim().split(/\s+/);
|
|
@@ -567,7 +567,9 @@ export function ResourceCatalogPage({
|
|
|
567
567
|
?.description ||
|
|
568
568
|
actionName(code)
|
|
569
569
|
}
|
|
570
|
-
|
|
570
|
+
// Check màu đã tự nói lên trạng thái — không cần thêm khung
|
|
571
|
+
// viền/nền màu (nhiễu khi cả dãy chip đứng cạnh nhau).
|
|
572
|
+
className="inline-flex items-center gap-1 px-1 py-0.5 text-[11px] font-medium text-foreground"
|
|
571
573
|
>
|
|
572
574
|
<span
|
|
573
575
|
className={cn(
|