@goplusvn/core 0.1.43 → 0.1.45
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 +27 -0
- package/package.json +2 -2
- package/src/auth/proxy-gate.ts +10 -2
- package/src/crud/crud-route-handlers.ts +26 -4
- package/src/crud/lib/permissions.ts +4 -1
- package/src/crud/server-service.ts +62 -14
- package/src/errors/error-handler.ts +17 -1
- package/src/errors/server-error.ts +18 -2
- package/src/system/services/settings-service.ts +51 -2
- package/src/user/components/unified-profile-dialog.tsx +118 -24
- package/src/rbac/permissions.ts +0 -134
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.44 — UnifiedProfileDialog: chi nhánh mặc định + ngày sinh DatePicker + fix Select loại người dùng
|
|
4
|
+
|
|
5
|
+
**Ngày sinh dùng `DatePicker` (thay `<input type="date">`):** ô ngày sinh cũ khó
|
|
6
|
+
nhập tay và không đồng bộ style; nay dùng `DatePicker` (gõ được `dd/MM/yyyy` +
|
|
7
|
+
lịch popover). Cầu nối 2 chiều `ymdToDate`/`dateToYmd` giữ nguyên định dạng lưu
|
|
8
|
+
`"yyyy-MM-dd"`, neo giờ 00:00 địa phương để tránh lệch ngày do timezone.
|
|
9
|
+
|
|
10
|
+
**Chi nhánh mặc định (`UserBranch.isDefault`):**
|
|
11
|
+
|
|
12
|
+
- Tab "Công việc" của dialog người dùng: mỗi chi nhánh đã chọn có nút "⭐ Đặt
|
|
13
|
+
mặc định"; chi nhánh mặc định hiện badge "Mặc định". Chọn chi nhánh đầu tiên
|
|
14
|
+
tự động thành mặc định; bỏ chọn chi nhánh đang là mặc định thì tự thăng chi
|
|
15
|
+
nhánh còn lại kế tiếp.
|
|
16
|
+
- Payload gửi thêm `defaultBranchId` (đã lọc để luôn nằm trong `branchIds`, fallback
|
|
17
|
+
`branchIds[0]`). App đọc field này thay cho hard-code `isDefault: index === 0`.
|
|
18
|
+
- Đọc lại `data.defaultBranchId` khi mở dialog ở chế độ sửa (fallback `branchIds[0]`).
|
|
19
|
+
|
|
20
|
+
**Fix Select "Loại người dùng" icon + chữ rớt 2 dòng:** base `SelectTrigger` của
|
|
21
|
+
core có `[&_span]:line-clamp-none` — selector này (`.trigger span`, độ ưu tiên
|
|
22
|
+
`0,1,1`) biên dịch kèm `display:block`, THẮNG class `inline-flex` (`0,1,0`) trên
|
|
23
|
+
span icon+chữ bên trong, nên (cộng với Preflight `svg{display:block}`) icon bị
|
|
24
|
+
đẩy xuống dòng riêng. Nới chiều ngang KHÔNG chữa vì đây là lỗi xếp dọc. Fix:
|
|
25
|
+
trigger dùng `[&_span]:!inline-flex [&_span]:!items-center [&_span]:!gap-2`
|
|
26
|
+
(`!important` thắng tuyệt đối `line-clamp-none` bất kể thứ tự stylesheet) +
|
|
27
|
+
`[&_svg]:shrink-0`, `whitespace-nowrap`, `style={{width:210}}`. Kiểm chứng bằng
|
|
28
|
+
repro Radix+Tailwind: span trong đổi `display` từ `block` → `flex`.
|
|
29
|
+
|
|
3
30
|
## 0.1.34 — Tab navigation: gom nhóm module, tiêu đề tab chi tiết, nút làm tươi
|
|
4
31
|
|
|
5
32
|
**PageTabs:**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goplusvn/core",
|
|
3
3
|
"description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.45",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -158,7 +158,7 @@
|
|
|
158
158
|
"swr": "^2.3.6",
|
|
159
159
|
"tailwind-merge": "2.5.2",
|
|
160
160
|
"vaul": "1.1.2",
|
|
161
|
-
"xlsx": "0.
|
|
161
|
+
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
|
162
162
|
"zod": "3.23.8"
|
|
163
163
|
},
|
|
164
164
|
"scripts": {
|
package/src/auth/proxy-gate.ts
CHANGED
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
// export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.[^/]+$).*)"] };
|
|
12
12
|
|
|
13
13
|
import { NextResponse } from "next/server";
|
|
14
|
-
import { getToken } from "next-auth/jwt";
|
|
15
14
|
import type { NextRequest } from "next/server";
|
|
16
15
|
|
|
17
16
|
export interface AuthProxyOptions {
|
|
@@ -35,7 +34,16 @@ export function createAuthProxy(options: AuthProxyOptions = {}) {
|
|
|
35
34
|
const publicPages = options.publicPages ?? ["/sign-in"];
|
|
36
35
|
const signInPath = options.signInPath ?? "/sign-in";
|
|
37
36
|
const homePath = options.homePath ?? "/";
|
|
38
|
-
|
|
37
|
+
// next-auth chỉ được LAZY-load khi app không truyền getToken riêng — app đã
|
|
38
|
+
// sang Better Auth (không cài next-auth) sẽ không dính module-not-found lúc
|
|
39
|
+
// import proxy-gate (trước đây import top-level, next-auth lại chỉ nằm ở
|
|
40
|
+
// devDependencies của core).
|
|
41
|
+
const readToken =
|
|
42
|
+
options.getToken ??
|
|
43
|
+
(async (req: NextRequest) => {
|
|
44
|
+
const { getToken } = await import("next-auth/jwt");
|
|
45
|
+
return getToken({ req });
|
|
46
|
+
});
|
|
39
47
|
|
|
40
48
|
return async function proxy(request: NextRequest) {
|
|
41
49
|
const { pathname, search } = request.nextUrl;
|
|
@@ -22,11 +22,25 @@ type MaybePromise<T> = T | Promise<T>;
|
|
|
22
22
|
export interface CrudHandlerDeps {
|
|
23
23
|
getSession: () => MaybePromise<any | null>;
|
|
24
24
|
getEntityConfig: (entity: string) => EntityConfig | undefined;
|
|
25
|
-
service
|
|
25
|
+
/** Một service dùng chung, HOẶC resolver theo entity — app có service
|
|
26
|
+
* chuyên biệt (auto-gen số phiếu, stock-count…) trả service riêng cho
|
|
27
|
+
* entity đó thay vì viết factory if-chain ngoài core (seam 2026-07). */
|
|
28
|
+
service:
|
|
29
|
+
| ServerCrudService
|
|
30
|
+
| ((entity: string) => MaybePromise<ServerCrudService>);
|
|
26
31
|
/** Optional error mapper (e.g. app's serverError). Defaults to a 500 JSON. */
|
|
27
32
|
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
function makeServiceResolver(
|
|
36
|
+
service: CrudHandlerDeps["service"],
|
|
37
|
+
): (entity: string) => Promise<ServerCrudService> {
|
|
38
|
+
if (typeof service === "function") {
|
|
39
|
+
return async (entity) => await service(entity);
|
|
40
|
+
}
|
|
41
|
+
return async () => service;
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
const json = (data: unknown, status = 200) =>
|
|
31
45
|
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
|
32
46
|
|
|
@@ -40,7 +54,8 @@ async function resolvePerms(session: any, config: EntityConfig, entity: string)
|
|
|
40
54
|
|
|
41
55
|
// GET/POST for the collection route `/api/crud/[entity]`.
|
|
42
56
|
export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
43
|
-
const { getSession, getEntityConfig,
|
|
57
|
+
const { getSession, getEntityConfig, onError } = deps;
|
|
58
|
+
const resolveService = makeServiceResolver(deps.service);
|
|
44
59
|
const fail = (e: unknown, req: Request) =>
|
|
45
60
|
onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
46
61
|
|
|
@@ -64,6 +79,7 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
64
79
|
: undefined,
|
|
65
80
|
filters: sp.get("filters") ? JSON.parse(sp.get("filters")!) : undefined,
|
|
66
81
|
};
|
|
82
|
+
const service = await resolveService(entity);
|
|
67
83
|
const data = await service.list(entity, config, params as any);
|
|
68
84
|
return json(data);
|
|
69
85
|
} catch (e) {
|
|
@@ -82,6 +98,7 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
82
98
|
if (!perms.create) return forbidden();
|
|
83
99
|
|
|
84
100
|
const body = await req.json();
|
|
101
|
+
const service = await resolveService(entity);
|
|
85
102
|
if (Array.isArray(body)) {
|
|
86
103
|
const results = [];
|
|
87
104
|
for (const item of body) results.push(await service.create(entity, item, config));
|
|
@@ -98,7 +115,8 @@ export function createCrudCollectionHandlers(deps: CrudHandlerDeps) {
|
|
|
98
115
|
|
|
99
116
|
// GET/PUT/PATCH/DELETE for the item route `/api/crud/[entity]/[id]`.
|
|
100
117
|
export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
101
|
-
const { getSession, getEntityConfig,
|
|
118
|
+
const { getSession, getEntityConfig, onError } = deps;
|
|
119
|
+
const resolveService = makeServiceResolver(deps.service);
|
|
102
120
|
const fail = (e: unknown, req: Request) =>
|
|
103
121
|
onError ? onError(e, req) : json({ error: "Internal error" }, 500);
|
|
104
122
|
|
|
@@ -113,7 +131,9 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
113
131
|
if (!config) return unknownEntity();
|
|
114
132
|
const perms = await resolvePerms(session, config, entity);
|
|
115
133
|
if (!perms.read) return forbidden();
|
|
116
|
-
const
|
|
134
|
+
const service = await resolveService(entity);
|
|
135
|
+
// Truyền config để detail áp cùng include như list (label thay raw FK id).
|
|
136
|
+
const item = await service.getById(entity, id, config);
|
|
117
137
|
if (!item) return json({ error: "Not found" }, 404);
|
|
118
138
|
return json(item);
|
|
119
139
|
} catch (e) {
|
|
@@ -131,6 +151,7 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
131
151
|
const perms = await resolvePerms(session, config, entity);
|
|
132
152
|
if (!perms.update) return forbidden();
|
|
133
153
|
const body = await req.json();
|
|
154
|
+
const service = await resolveService(entity);
|
|
134
155
|
return json(await service.update(entity, id, body, config));
|
|
135
156
|
} catch (e) {
|
|
136
157
|
return fail(e, req);
|
|
@@ -146,6 +167,7 @@ export function createCrudItemHandlers(deps: CrudHandlerDeps) {
|
|
|
146
167
|
if (!config) return unknownEntity();
|
|
147
168
|
const perms = await resolvePerms(session, config, entity);
|
|
148
169
|
if (!perms.delete) return forbidden();
|
|
170
|
+
const service = await resolveService(entity);
|
|
149
171
|
await service.delete(entity, id);
|
|
150
172
|
return new Response(null, { status: 204 });
|
|
151
173
|
} catch (e) {
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { CrudPermissions, EntityConfig } from "../../types";
|
|
2
2
|
import type { Session } from "../../types";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Nguồn permission DUY NHẤT đã harden (BYPASS_AUTH chỉ hiệu lực ngoài
|
|
5
|
+
// production, admin-role check chạy trước empty-permissions guard) — bản
|
|
6
|
+
// divergent rbac/permissions.ts cũ đã xóa 2026-07 vì thiếu guard NODE_ENV.
|
|
7
|
+
import { getCrudPermissionsFromSession } from "../../auth";
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Get CRUD permissions for a user based on their session
|
|
@@ -22,9 +22,12 @@ type PrismaLike = Record<string, any>;
|
|
|
22
22
|
|
|
23
23
|
export interface ServerCrudService {
|
|
24
24
|
list(entity: string, config: EntityConfig, params: CrudQueryParams): Promise<CrudResponse>;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
/** `config` (tùy chọn) để áp cùng `include` như list — detail không hiện raw FK id. */
|
|
26
|
+
getById(entity: string, id: string, config?: EntityConfig): Promise<any>;
|
|
27
|
+
/** `tx` (tùy chọn) để app chạy create CÙNG transaction với sinh số phiếu
|
|
28
|
+
* advisory-lock (document-number yêu cầu cùng tx với insert). */
|
|
29
|
+
create(entity: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
|
|
30
|
+
update(entity: string, id: string, data: any, config?: EntityConfig, tx?: PrismaLike): Promise<any>;
|
|
28
31
|
delete(entity: string, id: string): Promise<any>;
|
|
29
32
|
deleteMany(entity: string, ids: string[]): Promise<any>;
|
|
30
33
|
}
|
|
@@ -43,8 +46,35 @@ export interface ServerCrudDeps {
|
|
|
43
46
|
* {@link createAuditUserNameResolver} if your User model exposes a name field. */
|
|
44
47
|
resolveAuditNames?: (records: any[]) => Promise<any[]>;
|
|
45
48
|
logger?: ServerCrudLogger;
|
|
49
|
+
|
|
50
|
+
// ── Seams 2026-07: đủ điểm cắm để app KHÔNG phải fork nguyên engine ──
|
|
51
|
+
|
|
52
|
+
/** WHERE mặc định theo entity, AND vào mọi list/count — vd 2 entity chia
|
|
53
|
+
* chung 1 model: stock-in → { type: "in" }, stock-out → { type: "out" }. */
|
|
54
|
+
scopeWhere?: (
|
|
55
|
+
entity: string,
|
|
56
|
+
config: EntityConfig,
|
|
57
|
+
) => Record<string, unknown> | undefined;
|
|
58
|
+
/** Field hệ thống luôn cho qua filterValidFields dù không khai trong
|
|
59
|
+
* config.fields. Default: id + bộ audit (createdAt/updatedAt/createdBy/
|
|
60
|
+
* updatedBy). App có cột riêng (companyId…) thì truyền thêm. */
|
|
61
|
+
systemFields?: string[];
|
|
62
|
+
/** Set updatedAt = new Date() khi create nếu payload chưa có — cho model
|
|
63
|
+
* thiếu `@updatedAt`/default trong schema. Default: false. */
|
|
64
|
+
touchUpdatedAtOnCreate?: boolean;
|
|
65
|
+
/** Các cột `*Id` KHÔNG phải FK — bỏ qua transform connect/disconnect
|
|
66
|
+
* (scalar như số CCCD, mã tra cứu…). Gộp thêm vào skip-list mặc định. */
|
|
67
|
+
relationFieldSkip?: string[];
|
|
46
68
|
}
|
|
47
69
|
|
|
70
|
+
const DEFAULT_SYSTEM_FIELDS = [
|
|
71
|
+
"id",
|
|
72
|
+
"createdAt",
|
|
73
|
+
"updatedAt",
|
|
74
|
+
"createdBy",
|
|
75
|
+
"updatedBy",
|
|
76
|
+
];
|
|
77
|
+
|
|
48
78
|
const MAX_PAGE_SIZE = 200;
|
|
49
79
|
|
|
50
80
|
// Default plural→model resolver: strip trailing "s", camelCase kebab. Apps with
|
|
@@ -96,16 +126,18 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
96
126
|
error: (m, ...r) => console.error(m, ...r),
|
|
97
127
|
};
|
|
98
128
|
|
|
99
|
-
const
|
|
129
|
+
const systemFields = deps.systemFields ?? DEFAULT_SYSTEM_FIELDS;
|
|
130
|
+
|
|
131
|
+
const model = (entity: string, client: PrismaLike = prisma) => {
|
|
100
132
|
const name = resolveModel(entity);
|
|
101
|
-
const m =
|
|
133
|
+
const m = client[name];
|
|
102
134
|
if (!m) throw new Error(`Prisma model not found for entity: ${entity} (${name})`);
|
|
103
135
|
return m;
|
|
104
136
|
};
|
|
105
137
|
|
|
106
138
|
const filterValidFields = (data: any, config: EntityConfig) => {
|
|
107
139
|
const valid = new Set(config.fields.filter((f) => !(f as any).isDisplayOnly).map((f) => f.name));
|
|
108
|
-
valid.add(
|
|
140
|
+
for (const f of systemFields) valid.add(f);
|
|
109
141
|
const out: Record<string, unknown> = {};
|
|
110
142
|
for (const [k, v] of Object.entries(data)) {
|
|
111
143
|
if (valid.has(k)) out[k] = v;
|
|
@@ -143,7 +175,16 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
143
175
|
|
|
144
176
|
const transformRelationFields = (data: any, mode: "create" | "update") => {
|
|
145
177
|
const out = { ...data };
|
|
146
|
-
|
|
178
|
+
// citizenId/targetId là legacy-default từ consumer đầu tiên — app mới khai
|
|
179
|
+
// cột scalar *Id của mình qua deps.relationFieldSkip thay vì sửa core.
|
|
180
|
+
const skip = new Set([
|
|
181
|
+
"id",
|
|
182
|
+
"createdBy",
|
|
183
|
+
"updatedBy",
|
|
184
|
+
"citizenId",
|
|
185
|
+
"targetId",
|
|
186
|
+
...(deps.relationFieldSkip ?? []),
|
|
187
|
+
]);
|
|
147
188
|
for (const key of Object.keys(out)) {
|
|
148
189
|
if (skip.has(key)) continue;
|
|
149
190
|
if (key.endsWith("Id") && key.length > 2) {
|
|
@@ -181,7 +222,7 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
181
222
|
return allowedFields.has(name);
|
|
182
223
|
};
|
|
183
224
|
|
|
184
|
-
const where: any = {};
|
|
225
|
+
const where: any = { ...(deps.scopeWhere?.(entity, config) ?? {}) };
|
|
185
226
|
if (search && search.trim()) {
|
|
186
227
|
const term = search.trim();
|
|
187
228
|
const searchFields = config.fields.filter((f) => f.filterable && f.type === "text").map((f) => f.name);
|
|
@@ -253,21 +294,28 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
253
294
|
}
|
|
254
295
|
},
|
|
255
296
|
|
|
256
|
-
async getById(entity, id) {
|
|
297
|
+
async getById(entity, id, config) {
|
|
257
298
|
const prismaModel = model(entity);
|
|
258
|
-
|
|
299
|
+
// Áp cùng include như list — detail dialog hiện label relation thay raw FK id.
|
|
300
|
+
const include: any = {};
|
|
301
|
+
if (config?.include?.length) config.include.forEach((inc) => (include[inc] = true));
|
|
302
|
+
const includeOption = Object.keys(include).length ? { include } : {};
|
|
303
|
+
const result = await prismaModel.findUnique({ where: { id }, ...includeOption });
|
|
259
304
|
if (!result) return null;
|
|
260
305
|
const serialized = serializeDecimalFields(result);
|
|
261
306
|
const [resolved] = await resolveAuditNames([serialized]);
|
|
262
307
|
return resolved;
|
|
263
308
|
},
|
|
264
309
|
|
|
265
|
-
async create(entity, data, config) {
|
|
266
|
-
const prismaModel = model(entity);
|
|
310
|
+
async create(entity, data, config, tx) {
|
|
311
|
+
const prismaModel = model(entity, tx ?? prisma);
|
|
267
312
|
try {
|
|
268
313
|
let filtered = config ? filterValidFields(data, config) : data;
|
|
269
314
|
if (config) filtered = castFieldValues(filtered, config);
|
|
270
315
|
if (!filtered.id) filtered.id = crypto.randomUUID();
|
|
316
|
+
if (deps.touchUpdatedAtOnCreate && !filtered.updatedAt) {
|
|
317
|
+
filtered.updatedAt = new Date();
|
|
318
|
+
}
|
|
271
319
|
const prismaData = transformRelationFields(filtered, "create");
|
|
272
320
|
return serializeDecimalFields(await prismaModel.create({ data: prismaData }));
|
|
273
321
|
} catch (error) {
|
|
@@ -276,8 +324,8 @@ export function createServerCrudService(deps: ServerCrudDeps): ServerCrudService
|
|
|
276
324
|
}
|
|
277
325
|
},
|
|
278
326
|
|
|
279
|
-
async update(entity, id, data, config) {
|
|
280
|
-
const prismaModel = model(entity);
|
|
327
|
+
async update(entity, id, data, config, tx) {
|
|
328
|
+
const prismaModel = model(entity, tx ?? prisma);
|
|
281
329
|
try {
|
|
282
330
|
let filtered = config ? filterValidFields(data, config) : data;
|
|
283
331
|
if (config) filtered = castFieldValues(filtered, config);
|
|
@@ -17,11 +17,27 @@ import { AppError, toAppError } from "./app-error"
|
|
|
17
17
|
*/
|
|
18
18
|
export async function withErrorHandler(
|
|
19
19
|
handler: () => Promise<Response>,
|
|
20
|
-
options?: {
|
|
20
|
+
options?: {
|
|
21
|
+
operation?: string
|
|
22
|
+
req?: NextRequest | Request
|
|
23
|
+
/**
|
|
24
|
+
* Seam persist lỗi (2026-07): mặc định chỉ console.error — lỗi KHÔNG
|
|
25
|
+
* vào error_logs. App truyền `onError: (e, req) => serverError(e, req)`
|
|
26
|
+
* (buildServerError ở ./server-error) để lỗi được lưu bền + giữ nguyên
|
|
27
|
+
* shape response (cả hai cùng kết thúc bằng createErrorResponse).
|
|
28
|
+
*/
|
|
29
|
+
onError?: (
|
|
30
|
+
error: unknown,
|
|
31
|
+
req?: NextRequest | Request
|
|
32
|
+
) => Response | Promise<Response>
|
|
33
|
+
}
|
|
21
34
|
): Promise<Response> {
|
|
22
35
|
try {
|
|
23
36
|
return await handler()
|
|
24
37
|
} catch (error) {
|
|
38
|
+
if (options?.onError) {
|
|
39
|
+
return options.onError(error, options.req)
|
|
40
|
+
}
|
|
25
41
|
const appError = toAppError(error)
|
|
26
42
|
|
|
27
43
|
const requestContext = options?.req
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { NextResponse } from "next/server"
|
|
2
|
-
import { createHash } from "crypto"
|
|
3
2
|
|
|
4
3
|
import { AppError, toAppError } from "./app-error"
|
|
5
4
|
import { createErrorResponse } from "./error-handler"
|
|
@@ -68,7 +67,24 @@ function buildFingerprint(
|
|
|
68
67
|
/* keep raw */
|
|
69
68
|
}
|
|
70
69
|
const raw = [message.slice(0, 200), code || "", moduleTag || "", path].join("|")
|
|
71
|
-
return
|
|
70
|
+
return fingerprint16(raw)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fingerprint 16 hex để GOM NHÓM lỗi — không phải mật mã học. FNV-1a thuần
|
|
75
|
+
* (không import "crypto" của Node) vì error path bị instrumentation/proxy kéo
|
|
76
|
+
* vào Edge bundle, nơi không có module crypto (2026-07: dev webpack vỡ vì
|
|
77
|
+
* import node:crypto ở file này).
|
|
78
|
+
*/
|
|
79
|
+
function fingerprint16(raw: string): string {
|
|
80
|
+
let h1 = 0x811c9dc5
|
|
81
|
+
let h2 = 0xcbf29ce4
|
|
82
|
+
for (let i = 0; i < raw.length; i++) {
|
|
83
|
+
const c = raw.charCodeAt(i)
|
|
84
|
+
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0
|
|
85
|
+
h2 = Math.imul(h2 ^ ((c << 1) | 1), 0x01000193) >>> 0
|
|
86
|
+
}
|
|
87
|
+
return h1.toString(16).padStart(8, "0") + h2.toString(16).padStart(8, "0")
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
/**
|
|
@@ -1,8 +1,54 @@
|
|
|
1
|
-
const db: any = { systemConfig: { findUnique: async () => null, upsert: async () => null } }; // Mocked for build
|
|
2
|
-
|
|
3
1
|
import { cache } from "react";
|
|
4
2
|
import { revalidateTag, unstable_cache } from "next/cache";
|
|
5
3
|
|
|
4
|
+
/**
|
|
5
|
+
* DB được INJECT từ app (core không import prisma client cụ thể). Trước
|
|
6
|
+
* 2026-07 chỗ này là `const db: any = {...} // Mocked for build` — mọi
|
|
7
|
+
* getSettings âm thầm trả default và saveSettings ghi vào hư không (gốc của
|
|
8
|
+
* regression "settings admin chỉnh không ăn"). App gọi
|
|
9
|
+
* `configureSettingsService(db)` MỘT lần ở composition root (cạnh chỗ tạo
|
|
10
|
+
* prisma client).
|
|
11
|
+
*/
|
|
12
|
+
// Structural + schema-agnostic (giống PrismaLike ở crud/server-service): args
|
|
13
|
+
// để lỏng vì generated types của Prisma mỗi app một khác — chỉ chốt shape trả
|
|
14
|
+
// về mà service thật sự đọc.
|
|
15
|
+
export interface SettingsDb {
|
|
16
|
+
systemConfig: {
|
|
17
|
+
findUnique(args: any): Promise<{ value: string | null } | null>;
|
|
18
|
+
upsert(args: any): Promise<unknown>;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let configuredDb: SettingsDb | null = null;
|
|
23
|
+
let warnedMissingDb = false;
|
|
24
|
+
|
|
25
|
+
export function configureSettingsService(db: SettingsDb): void {
|
|
26
|
+
configuredDb = db;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Đọc: thiếu db → cảnh báo TO (một lần) + trả default — không sập build/SSR. */
|
|
30
|
+
function dbForRead(): SettingsDb | null {
|
|
31
|
+
if (configuredDb) return configuredDb;
|
|
32
|
+
if (!warnedMissingDb) {
|
|
33
|
+
warnedMissingDb = true;
|
|
34
|
+
console.warn(
|
|
35
|
+
"[SettingsService] Chưa configureSettingsService(db) — mọi getSettings " +
|
|
36
|
+
"đang trả DEFAULT (setting admin chỉnh sẽ không có hiệu lực). " +
|
|
37
|
+
"Gọi configureSettingsService(db) ở composition root của app.",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Ghi: thiếu db → THROW. Ghi-vào-hư-không tệ hơn lỗi ồn ào. */
|
|
44
|
+
function dbForWrite(): SettingsDb {
|
|
45
|
+
if (configuredDb) return configuredDb;
|
|
46
|
+
throw new Error(
|
|
47
|
+
"[SettingsService] saveSettings cần configureSettingsService(db) — " +
|
|
48
|
+
"từ chối ghi vào hư không (trước đây mock db nuốt mất dữ liệu).",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
6
52
|
export interface AuditSettings {
|
|
7
53
|
retentionDays: number;
|
|
8
54
|
enabledResources: string[];
|
|
@@ -52,6 +98,8 @@ const DEFAULT_SALES_RULES_SETTINGS: SalesRulesSettings = {
|
|
|
52
98
|
export class SettingsService {
|
|
53
99
|
/** Get all settings cached */
|
|
54
100
|
static async getSettings<T>(key: string, defaultValue: T): Promise<T> {
|
|
101
|
+
const db = dbForRead();
|
|
102
|
+
if (!db) return defaultValue;
|
|
55
103
|
return unstable_cache(
|
|
56
104
|
async () => {
|
|
57
105
|
const config = await db.systemConfig.findUnique({
|
|
@@ -88,6 +136,7 @@ export class SettingsService {
|
|
|
88
136
|
value: T,
|
|
89
137
|
category = "general",
|
|
90
138
|
): Promise<void> {
|
|
139
|
+
const db = dbForWrite();
|
|
91
140
|
await db.systemConfig.upsert({
|
|
92
141
|
where: { key },
|
|
93
142
|
update: {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
SelectTrigger,
|
|
22
22
|
SelectValue,
|
|
23
23
|
Switch,
|
|
24
|
+
DatePicker,
|
|
24
25
|
} from "../../ui";
|
|
25
26
|
import { toast } from "sonner";
|
|
26
27
|
import { cn } from "../../utils";
|
|
@@ -38,6 +39,7 @@ import {
|
|
|
38
39
|
Briefcase,
|
|
39
40
|
ShieldCheck,
|
|
40
41
|
KeyRound,
|
|
42
|
+
Star,
|
|
41
43
|
} from "lucide-react";
|
|
42
44
|
|
|
43
45
|
interface UnifiedProfileDialogProps {
|
|
@@ -59,6 +61,22 @@ const fetcher = async (url: string) => {
|
|
|
59
61
|
return Array.isArray(data) ? data : data.items || data.data || [];
|
|
60
62
|
};
|
|
61
63
|
|
|
64
|
+
// birthday được lưu/gửi dưới dạng chuỗi "yyyy-MM-dd" (như input date cũ).
|
|
65
|
+
// DatePicker làm việc với Date nên cần cầu nối 2 chiều, tránh lệch ngày do TZ
|
|
66
|
+
// bằng cách neo giờ 00:00 địa phương và tự dựng chuỗi (không dùng toISOString).
|
|
67
|
+
const ymdToDate = (s?: string): Date | undefined => {
|
|
68
|
+
if (!s) return undefined;
|
|
69
|
+
const d = new Date(`${s}T00:00:00`);
|
|
70
|
+
return isNaN(d.getTime()) ? undefined : d;
|
|
71
|
+
};
|
|
72
|
+
const dateToYmd = (d?: Date): string => {
|
|
73
|
+
if (!d) return "";
|
|
74
|
+
const y = d.getFullYear();
|
|
75
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
76
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
77
|
+
return `${y}-${m}-${day}`;
|
|
78
|
+
};
|
|
79
|
+
|
|
62
80
|
export function UnifiedProfileDialog({
|
|
63
81
|
open,
|
|
64
82
|
onOpenChange,
|
|
@@ -102,6 +120,7 @@ export function UnifiedProfileDialog({
|
|
|
102
120
|
const [profileData, setProfileData] = useState<any>({});
|
|
103
121
|
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
|
104
122
|
const [selectedBranches, setSelectedBranches] = useState<string[]>([]);
|
|
123
|
+
const [defaultBranchId, setDefaultBranchId] = useState<string | null>(null);
|
|
105
124
|
const [passwordData, setPasswordData] = useState({
|
|
106
125
|
password: "",
|
|
107
126
|
confirm: "",
|
|
@@ -173,6 +192,7 @@ export function UnifiedProfileDialog({
|
|
|
173
192
|
});
|
|
174
193
|
setSelectedRoles([]);
|
|
175
194
|
setSelectedBranches([]);
|
|
195
|
+
setDefaultBranchId(null);
|
|
176
196
|
setPasswordData({ password: "", confirm: "" });
|
|
177
197
|
setEnableAccount(false);
|
|
178
198
|
} else {
|
|
@@ -203,6 +223,9 @@ export function UnifiedProfileDialog({
|
|
|
203
223
|
const roles = d.roleCodes || [];
|
|
204
224
|
setSelectedRoles(roles);
|
|
205
225
|
setSelectedBranches(d.branchIds || []);
|
|
226
|
+
setDefaultBranchId(
|
|
227
|
+
d.defaultBranchId || (d.branchIds && d.branchIds[0]) || null,
|
|
228
|
+
);
|
|
206
229
|
setPasswordData({ password: "", confirm: "" });
|
|
207
230
|
// Enable account if user has roles or if it's not a customer (employees always have accounts?)
|
|
208
231
|
// For now, if roles exist, we assume account is enabled.
|
|
@@ -216,11 +239,39 @@ export function UnifiedProfileDialog({
|
|
|
216
239
|
if (mode === "create" || (data?.branchIds?.length || 0) === 0) {
|
|
217
240
|
if (selectedBranches.length === 0) {
|
|
218
241
|
setSelectedBranches([branches[0].id]);
|
|
242
|
+
setDefaultBranchId(branches[0].id);
|
|
219
243
|
}
|
|
220
244
|
}
|
|
221
245
|
}
|
|
222
246
|
}, [open, branches, mode, data]);
|
|
223
247
|
|
|
248
|
+
// -- Branch selection helpers --
|
|
249
|
+
// Toggle a branch's membership while keeping a valid default branch.
|
|
250
|
+
const toggleBranch = (branchId: string) => {
|
|
251
|
+
if (selectedBranches.includes(branchId)) {
|
|
252
|
+
const next = selectedBranches.filter((id) => id !== branchId);
|
|
253
|
+
setSelectedBranches(next);
|
|
254
|
+
// Removing the current default → promote the first remaining branch.
|
|
255
|
+
if (defaultBranchId === branchId) {
|
|
256
|
+
setDefaultBranchId(next[0] ?? null);
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
setSelectedBranches([...selectedBranches, branchId]);
|
|
260
|
+
// First branch selected becomes the default automatically.
|
|
261
|
+
if (!defaultBranchId) {
|
|
262
|
+
setDefaultBranchId(branchId);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Mark a (selected) branch as the default working branch.
|
|
268
|
+
const markDefaultBranch = (branchId: string) => {
|
|
269
|
+
if (!selectedBranches.includes(branchId)) {
|
|
270
|
+
setSelectedBranches((prev) => [...prev, branchId]);
|
|
271
|
+
}
|
|
272
|
+
setDefaultBranchId(branchId);
|
|
273
|
+
};
|
|
274
|
+
|
|
224
275
|
// -- Helpers --
|
|
225
276
|
const getInitials = (name: string | null) => {
|
|
226
277
|
if (!name) return "U";
|
|
@@ -254,6 +305,10 @@ export function UnifiedProfileDialog({
|
|
|
254
305
|
userType, // Add userType to payload
|
|
255
306
|
roleCodes: selectedRoles,
|
|
256
307
|
branchIds: selectedBranches,
|
|
308
|
+
defaultBranchId:
|
|
309
|
+
defaultBranchId && selectedBranches.includes(defaultBranchId)
|
|
310
|
+
? defaultBranchId
|
|
311
|
+
: (selectedBranches[0] ?? null),
|
|
257
312
|
};
|
|
258
313
|
|
|
259
314
|
// Handle Password for Customer
|
|
@@ -394,25 +449,41 @@ export function UnifiedProfileDialog({
|
|
|
394
449
|
onValueChange={(val) => setUserType(val)}
|
|
395
450
|
disabled={mode === "view"}
|
|
396
451
|
>
|
|
397
|
-
<SelectTrigger
|
|
452
|
+
<SelectTrigger
|
|
453
|
+
style={{ width: 210 }}
|
|
454
|
+
// Base SelectTrigger có `[&_span]:line-clamp-none` — selector này biên
|
|
455
|
+
// dịch kèm `display:block` với độ ưu tiên (0,1,1) cao hơn `inline-flex`
|
|
456
|
+
// (0,1,0) của span icon+chữ bên trong, nên icon (svg Preflight display:block)
|
|
457
|
+
// bị đẩy xuống dòng riêng. `!inline-flex` (!important) thắng tuyệt đối.
|
|
458
|
+
className="shrink-0 h-9 min-h-0 whitespace-nowrap [&_span]:!inline-flex [&_span]:!items-center [&_span]:!gap-2 [&_span]:overflow-hidden [&_svg]:shrink-0 bg-white dark:bg-[#1e293b] border-[#e2e8f0] dark:border-[#334155] focus:ring-[#1641CE] focus:border-[#1641CE] rounded-md"
|
|
459
|
+
>
|
|
398
460
|
<SelectValue placeholder="Chọn loại" />
|
|
399
461
|
</SelectTrigger>
|
|
400
462
|
<SelectContent>
|
|
401
463
|
<SelectItem value="employee">
|
|
402
|
-
<span
|
|
403
|
-
|
|
464
|
+
<span
|
|
465
|
+
className="inline-flex items-center gap-2"
|
|
466
|
+
style={{ whiteSpace: "nowrap" }}
|
|
467
|
+
>
|
|
468
|
+
<UserCog className="h-4 w-4 text-[#1641CE] shrink-0" />
|
|
404
469
|
Nhân viên
|
|
405
470
|
</span>
|
|
406
471
|
</SelectItem>
|
|
407
472
|
<SelectItem value="customer">
|
|
408
|
-
<span
|
|
409
|
-
|
|
473
|
+
<span
|
|
474
|
+
className="inline-flex items-center gap-2"
|
|
475
|
+
style={{ whiteSpace: "nowrap" }}
|
|
476
|
+
>
|
|
477
|
+
<User className="h-4 w-4 text-[#059669] shrink-0" />
|
|
410
478
|
Khách hàng
|
|
411
479
|
</span>
|
|
412
480
|
</SelectItem>
|
|
413
481
|
<SelectItem value="supplier">
|
|
414
|
-
<span
|
|
415
|
-
|
|
482
|
+
<span
|
|
483
|
+
className="inline-flex items-center gap-2"
|
|
484
|
+
style={{ whiteSpace: "nowrap" }}
|
|
485
|
+
>
|
|
486
|
+
<Store className="h-4 w-4 text-[#EA580C] shrink-0" />
|
|
416
487
|
Nhà cung cấp
|
|
417
488
|
</span>
|
|
418
489
|
</SelectItem>
|
|
@@ -587,16 +658,17 @@ export function UnifiedProfileDialog({
|
|
|
587
658
|
{/* Additional Info for All */}
|
|
588
659
|
<div className="space-y-1.5">
|
|
589
660
|
<Label className="text-[13px] font-medium text-[#334155] dark:text-[#cbd5e1]">Ngày sinh</Label>
|
|
590
|
-
<
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
onChange={(e) =>
|
|
661
|
+
<DatePicker
|
|
662
|
+
value={ymdToDate(profileData.birthday)}
|
|
663
|
+
onValueChange={(d) =>
|
|
594
664
|
setProfileData({
|
|
595
665
|
...profileData,
|
|
596
|
-
birthday:
|
|
666
|
+
birthday: dateToYmd(d),
|
|
597
667
|
})
|
|
598
668
|
}
|
|
599
|
-
|
|
669
|
+
formatStr="dd/MM/yyyy"
|
|
670
|
+
placeholder="dd/mm/yyyy"
|
|
671
|
+
buttonClassName="bg-white dark:bg-[#1e293b] border-[#e2e8f0] dark:border-[#334155] focus:ring-primary focus:border-primary rounded-md h-9 text-[13px]"
|
|
600
672
|
/>
|
|
601
673
|
</div>
|
|
602
674
|
<div className="space-y-1.5">
|
|
@@ -816,34 +888,56 @@ export function UnifiedProfileDialog({
|
|
|
816
888
|
</Select>
|
|
817
889
|
</div>
|
|
818
890
|
<div className="col-span-2 space-y-3 pt-2">
|
|
819
|
-
<
|
|
891
|
+
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
892
|
+
<Label className="text-[13px] font-medium text-[#41454d] dark:text-[#9297a0]">Chi nhánh làm việc</Label>
|
|
893
|
+
<span className="inline-flex items-center gap-1 text-[12px] text-[#94a3b8]">
|
|
894
|
+
<Star className="h-3 w-3" />
|
|
895
|
+
Đánh dấu một chi nhánh mặc định
|
|
896
|
+
</span>
|
|
897
|
+
</div>
|
|
820
898
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
821
899
|
{branches?.map((branch) => {
|
|
822
900
|
const isSelected = selectedBranches.includes(branch.id);
|
|
901
|
+
const isDefault =
|
|
902
|
+
isSelected && defaultBranchId === branch.id;
|
|
823
903
|
return (
|
|
824
904
|
<div
|
|
825
905
|
key={branch.id}
|
|
826
906
|
className={cn(
|
|
827
|
-
"flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-slate-50 transition-colors",
|
|
907
|
+
"flex items-center gap-3 p-3 rounded-md border cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/40 transition-colors",
|
|
828
908
|
isSelected
|
|
829
|
-
? "border-blue-500 bg-blue-50/50"
|
|
909
|
+
? "border-blue-500 bg-blue-50/50 dark:bg-blue-500/10"
|
|
830
910
|
: "bg-background border-slate-200 dark:border-slate-800",
|
|
831
911
|
)}
|
|
832
|
-
onClick={() =>
|
|
833
|
-
setSelectedBranches((prev) =>
|
|
834
|
-
prev.includes(branch.id)
|
|
835
|
-
? prev.filter((id) => id !== branch.id)
|
|
836
|
-
: [...prev, branch.id],
|
|
837
|
-
);
|
|
838
|
-
}}
|
|
912
|
+
onClick={() => toggleBranch(branch.id)}
|
|
839
913
|
>
|
|
840
914
|
<Checkbox
|
|
841
915
|
checked={isSelected}
|
|
842
916
|
className="data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
|
|
843
917
|
/>
|
|
844
|
-
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
918
|
+
<span className="flex-1 text-sm font-medium text-slate-900 dark:text-slate-100">
|
|
845
919
|
{branch.name}
|
|
846
920
|
</span>
|
|
921
|
+
{isSelected &&
|
|
922
|
+
(isDefault ? (
|
|
923
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-[11px] font-semibold text-white shrink-0">
|
|
924
|
+
<Star className="h-3 w-3 fill-current" />
|
|
925
|
+
Mặc định
|
|
926
|
+
</span>
|
|
927
|
+
) : (
|
|
928
|
+
<button
|
|
929
|
+
type="button"
|
|
930
|
+
onClick={(e) => {
|
|
931
|
+
e.stopPropagation();
|
|
932
|
+
markDefaultBranch(branch.id);
|
|
933
|
+
}}
|
|
934
|
+
title="Đặt làm chi nhánh mặc định"
|
|
935
|
+
className="inline-flex items-center gap-1 rounded-full border border-slate-200 dark:border-slate-700 px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:text-slate-400 hover:border-blue-400 hover:text-blue-600 transition-colors shrink-0"
|
|
936
|
+
>
|
|
937
|
+
<Star className="h-3 w-3" />
|
|
938
|
+
Đặt mặc định
|
|
939
|
+
</button>
|
|
940
|
+
))}
|
|
847
941
|
</div>
|
|
848
942
|
);
|
|
849
943
|
})}
|
package/src/rbac/permissions.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
// @goerp/core/rbac/permissions
|
|
2
|
-
// Server-only permission utilities - no client/UI imports
|
|
3
|
-
// Use this in Server Components and API routes to avoid bundling client code
|
|
4
|
-
|
|
5
|
-
import type { Permission, Session } from "../types";
|
|
6
|
-
|
|
7
|
-
// ============================================================================
|
|
8
|
-
// Action Mapping
|
|
9
|
-
// ============================================================================
|
|
10
|
-
|
|
11
|
-
export const CRUD_ACTIONS = {
|
|
12
|
-
create: "create",
|
|
13
|
-
view: "view",
|
|
14
|
-
update: "update",
|
|
15
|
-
delete: "delete",
|
|
16
|
-
export: "export",
|
|
17
|
-
import: "import",
|
|
18
|
-
approve: "approve",
|
|
19
|
-
reject: "reject",
|
|
20
|
-
} as const;
|
|
21
|
-
|
|
22
|
-
export type CrudAction = keyof typeof CRUD_ACTIONS;
|
|
23
|
-
|
|
24
|
-
export function getActionCode(operation: CrudAction): string {
|
|
25
|
-
return CRUD_ACTIONS[operation];
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// ============================================================================
|
|
29
|
-
// Permission Helpers
|
|
30
|
-
// ============================================================================
|
|
31
|
-
|
|
32
|
-
interface ExtendedUser {
|
|
33
|
-
id: string;
|
|
34
|
-
name?: string | null;
|
|
35
|
-
email?: string | null;
|
|
36
|
-
image?: string | null;
|
|
37
|
-
roles?: string[];
|
|
38
|
-
permissions?: Permission[];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const BYPASS_AUTH =
|
|
42
|
-
process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1";
|
|
43
|
-
|
|
44
|
-
const ADMIN_ROLE_CODES = ["admin", "SUPER_ADMIN"];
|
|
45
|
-
|
|
46
|
-
export function getCrudPermissionsFromSession(
|
|
47
|
-
session: Session | null,
|
|
48
|
-
entity: string,
|
|
49
|
-
): {
|
|
50
|
-
create: boolean;
|
|
51
|
-
view: boolean;
|
|
52
|
-
update: boolean;
|
|
53
|
-
delete: boolean;
|
|
54
|
-
export: boolean;
|
|
55
|
-
import: boolean;
|
|
56
|
-
approve: boolean;
|
|
57
|
-
reject: boolean;
|
|
58
|
-
} {
|
|
59
|
-
if (BYPASS_AUTH) {
|
|
60
|
-
return {
|
|
61
|
-
create: true,
|
|
62
|
-
view: true,
|
|
63
|
-
update: true,
|
|
64
|
-
delete: true,
|
|
65
|
-
export: true,
|
|
66
|
-
import: true,
|
|
67
|
-
approve: true,
|
|
68
|
-
reject: true,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (!session?.user) {
|
|
73
|
-
return {
|
|
74
|
-
create: false,
|
|
75
|
-
view: false,
|
|
76
|
-
update: false,
|
|
77
|
-
delete: false,
|
|
78
|
-
export: false,
|
|
79
|
-
import: false,
|
|
80
|
-
approve: false,
|
|
81
|
-
reject: false,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const user = session.user as ExtendedUser;
|
|
86
|
-
|
|
87
|
-
if (!user.id) {
|
|
88
|
-
return {
|
|
89
|
-
create: false,
|
|
90
|
-
view: false,
|
|
91
|
-
update: false,
|
|
92
|
-
delete: false,
|
|
93
|
-
export: false,
|
|
94
|
-
import: false,
|
|
95
|
-
approve: false,
|
|
96
|
-
reject: false,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const isAdmin = user.roles?.some((role) => ADMIN_ROLE_CODES.includes(role));
|
|
101
|
-
if (isAdmin) {
|
|
102
|
-
return {
|
|
103
|
-
create: true,
|
|
104
|
-
view: true,
|
|
105
|
-
update: true,
|
|
106
|
-
delete: true,
|
|
107
|
-
export: true,
|
|
108
|
-
import: true,
|
|
109
|
-
approve: true,
|
|
110
|
-
reject: true,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const permissions = user.permissions || [];
|
|
115
|
-
const permissionKeys = new Set(
|
|
116
|
-
permissions.map((p) => `${p.resourceCode}:${p.actionCode}`),
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
const hasPermission = (action: string) => {
|
|
120
|
-
const key = `${entity}:${action}`;
|
|
121
|
-
return permissionKeys.has(key);
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
return {
|
|
125
|
-
create: hasPermission(getActionCode("create")),
|
|
126
|
-
view: hasPermission(getActionCode("view")),
|
|
127
|
-
update: hasPermission(getActionCode("update")),
|
|
128
|
-
delete: hasPermission(getActionCode("delete")),
|
|
129
|
-
export: hasPermission(getActionCode("export")),
|
|
130
|
-
import: hasPermission(getActionCode("import")),
|
|
131
|
-
approve: hasPermission(getActionCode("approve")),
|
|
132
|
-
reject: hasPermission(getActionCode("reject")),
|
|
133
|
-
};
|
|
134
|
-
}
|