@goplusvn/core 0.1.15 → 0.1.16
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 +66 -52
- package/package.json +2 -1
- package/src/rbac/route-handlers.ts +182 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,59 +1,73 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.16 — RBAC roles route-handler factory
|
|
4
|
+
|
|
5
|
+
Slice ⑧b. Mỗi app hand-write /api/roles + /api/roles/[id] (list users[], create/update
|
|
6
|
+
thay RolePermission set, delete guard theo user count). Nay ở core:
|
|
7
|
+
`@goerp/core/rbac/route-handlers` — `createRolesCollectionHandlers` (GET list
|
|
8
|
+
schema-tolerant qua getRolesData + POST create+writePermissions) +
|
|
9
|
+
`createRoleItemHandlers` (GET/PUT/DELETE). App inject prisma+getSession+
|
|
10
|
+
getCrudPermissions + schema field-map. Additive. wu thay ~90 LOC route tay → factory;
|
|
11
|
+
/api/roles 200 (6 roles), unauth 401, /vi/roles 200.
|
|
12
|
+
|
|
13
|
+
|
|
3
14
|
## 0.1.15 — Shared UI: ConfirmDialog, StatBar, ListToolbar
|
|
4
15
|
|
|
5
|
-
Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog,
|
|
16
|
+
Tiếp 0.1.14: promote 3 component dùng chung nữa vào `@goerp/core/ui` (ConfirmDialog,
|
|
17
|
+
StatBar, ListToolbar + AdvancedField) — import từ sub-barrel tránh circular.
|
|
18
|
+
Additive; wu shim + verify render 200.
|
|
19
|
+
|
|
6
20
|
|
|
7
|
-
## 0.1.14
|
|
21
|
+
## 0.1.14 — Init-completion: server-CRUD engine, auth gate, schema-tolerant RBAC, shared UI
|
|
8
22
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
compatible**
|
|
23
|
+
Đúc kết từ việc dựng app mới (wu-vpbank): những thứ MỖI app phải tự viết lại nay
|
|
24
|
+
đưa vào core (xem `docs/CORE-INIT-COMPLETION-PLAN.md`). Toàn bộ **additive, backward-
|
|
25
|
+
compatible** — 4 app (vinhhoa/thingtodo/wu ^0.1.13, tanloc ^0.1.3) an toàn.
|
|
12
26
|
|
|
13
27
|
- **Server-CRUD engine** (`@goerp/core/crud/server`): `createServerCrudService`
|
|
14
28
|
(Prisma-agnostic qua DI), `getModelName`, `createAuditUserNameResolver` (schema-
|
|
15
|
-
tolerant
|
|
16
|
-
Next.js
|
|
29
|
+
tolerant tên user), `createCrudCollectionHandlers`/`createCrudItemHandlers` (route
|
|
30
|
+
Next.js tự gác quyền). App bỏ ~350 LOC engine tự chế.
|
|
17
31
|
- **Auth request-gate** (`@goerp/core/auth/proxy-gate`): `createAuthProxy` (default-
|
|
18
|
-
deny) + `unauthorizedResponse`. proxy.ts app
|
|
19
|
-
- **RBAC schema-tolerant**: `getRolesData(db, params, schema?)`
|
|
20
|
-
(`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`)
|
|
21
|
-
|
|
32
|
+
deny) + `unauthorizedResponse`. proxy.ts app còn ~10 LOC.
|
|
33
|
+
- **RBAC schema-tolerant**: `getRolesData(db, params, schema?)` nhận field-map
|
|
34
|
+
(`userNameField`/`userActiveField`/`userImageField`/`roleTimestamps`) → app schema
|
|
35
|
+
khác (User.fullName/active, Role không timestamps) không còn crash/tự chế.
|
|
22
36
|
- **Shared UI** (`@goerp/core/ui`): PageHeader, StatusIndicator + getStatusMeta,
|
|
23
|
-
SumFooterCell + sumBy, table-styles (
|
|
37
|
+
SumFooterCell + sumBy, table-styles (mẫu bảng đơn bán hàng, subpath server-safe
|
|
24
38
|
`@goerp/core/ui/shared/table-styles`); export DynamicIcon.
|
|
25
39
|
|
|
26
|
-
Verify: core tsc 0
|
|
40
|
+
Verify: core tsc 0 lỗi; wu consume toàn bộ (tsc 0/0 + render/CRUD/auth chạy đúng);
|
|
27
41
|
golden vinhhoa tsc 20=20 (0 regression).
|
|
28
42
|
|
|
29
|
-
## 0.1.13
|
|
43
|
+
## 0.1.13 — Customizer: make radius, density & inset/floating actually apply
|
|
30
44
|
|
|
31
45
|
The Customizer exposed controls that stored a value but changed nothing on
|
|
32
46
|
screen. Wired them up at the source so every app using core gets them working:
|
|
33
47
|
|
|
34
|
-
- **Radius ("Bo
|
|
48
|
+
- **Radius ("Bo góc") now applies.** `SettingsProvider` drives the real
|
|
35
49
|
`--radius` token from `settings.radius`; Tailwind's `@theme inline` derives
|
|
36
|
-
`rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0
|
|
37
|
-
1rem
|
|
38
|
-
- **Density ("
|
|
50
|
+
`rounded-sm/md/lg/xl` from it, so the whole UI re-rounds live (0 → square,
|
|
51
|
+
1rem → fully rounded). Previously nothing read `settings.radius`.
|
|
52
|
+
- **Density ("Mật độ") now applies.** `SettingsProvider` sets
|
|
39
53
|
`data-density="compact"` on `<html>`; `base.css` tightens the global
|
|
40
|
-
`--spacing` scale (0.25rem
|
|
54
|
+
`--spacing` scale (0.25rem → 0.215rem) so every spacing utility packs in
|
|
41
55
|
compact mode. Previously `useDensity()` was read nowhere.
|
|
42
56
|
- **Inset / floating sidebar no longer renders a harsh frame.** Core's default
|
|
43
57
|
sidebar is deep navy, and shadcn's inset/floating paints the page frame with
|
|
44
|
-
that sidebar colour
|
|
58
|
+
that sidebar colour — reading as a broken-looking block. The frame is now the
|
|
45
59
|
soft `--muted` neutral, so the content floats as a card on a calm page
|
|
46
60
|
(adapts to dark mode; the rounded-xl + shadow core already applies stay).
|
|
47
61
|
- **Inset / floating collapsed rail no longer looks squeezed.** In icon mode the
|
|
48
62
|
floating/inset content box is `--sidebar-width-icon + 8px` (56px) but had
|
|
49
|
-
`px-2.5` padding, leaving the pill only 36px
|
|
63
|
+
`px-2.5` padding, leaving the pill only 36px — narrower than the default 48px
|
|
50
64
|
rail. Reduced to `px-1` so the pill is a comfortable 48px with a 4px floating
|
|
51
65
|
gutter.
|
|
52
66
|
- **Inset / floating sidebar corners are softened.** The floating panel was
|
|
53
67
|
`rounded-lg` and the inset panel had NO radius (a sharp navy block whose
|
|
54
|
-
top-right corner met the header as a hard 90
|
|
68
|
+
top-right corner met the header as a hard 90°). Both now use `rounded-xl`
|
|
55
69
|
(matching the inset content card) so the sidebar reads as a soft floating
|
|
56
|
-
panel
|
|
70
|
+
panel — the header/sidebar junction is a gentle curve, not a rough corner.
|
|
57
71
|
The floating content area also rounds both left corners
|
|
58
72
|
(`rounded-l-xl`, clipping the sticky header + footer via overflow-hidden) so
|
|
59
73
|
its whole left edge hugs the floating sidebar with soft curves; the right
|
|
@@ -64,20 +78,20 @@ screen. Wired them up at the source so every app using core gets them working:
|
|
|
64
78
|
And the content no longer touches the sidebar: inset drops its `ml-0` (so the
|
|
65
79
|
uniform `m-2` leaves a left gap) and floating gains `ml-2`, giving a small
|
|
66
80
|
gap between the sidebar and the content/header.
|
|
67
|
-
- **"
|
|
68
|
-
`AppSidebar` force-remapped `offcanvas
|
|
69
|
-
icon rail instead of hiding
|
|
81
|
+
- **"Ẩn ngoài" (`collapsible="offcanvas"`) can finally hide the sidebar.**
|
|
82
|
+
`AppSidebar` force-remapped `offcanvas → icon`, so the option collapsed to an
|
|
83
|
+
icon rail instead of hiding — identical to "Biểu tượng" and impossible to fully
|
|
70
84
|
hide the sidebar. The choice is now honoured as-is: offcanvas slides the
|
|
71
|
-
sidebar off-screen (reopen via the header toggle), icon
|
|
72
|
-
- **Dropped the "none" (
|
|
73
|
-
the sidebar expanded made the header collapse-toggle a no-op
|
|
85
|
+
sidebar off-screen (reopen via the header toggle), icon → rail.
|
|
86
|
+
- **Dropped the "none" (Tắt) collapsible option from the Customizer.** Locking
|
|
87
|
+
the sidebar expanded made the header collapse-toggle a no-op — a
|
|
74
88
|
conflicting/dead control. The picker now offers only offcanvas + icon, both of
|
|
75
89
|
which the toggle actually drives. (`none` remains a valid type for any code
|
|
76
90
|
that sets it directly; it's just no longer offered in the UI.)
|
|
77
|
-
- **Dropped the "
|
|
91
|
+
- **Dropped the "Bố cục" (layout: horizontal/vertical) control from the
|
|
78
92
|
Customizer.** The horizontal layout swaps the whole nav for a top menubar,
|
|
79
|
-
which turns every sidebar option ("
|
|
80
|
-
a no-op
|
|
93
|
+
which turns every sidebar option ("Kiểu thanh bên", "Thu gọn thanh bên") into
|
|
94
|
+
a no-op — the same dead-control problem. The customizer now locks to the
|
|
81
95
|
vertical sidebar the apps are designed around (`defaultSettings.layout` stays
|
|
82
96
|
`vertical`; `HorizontalLayout` is untouched for code that sets it directly).
|
|
83
97
|
- **Non-collapsing sidebar (`collapsible="none"`) is now readable.** It rendered
|
|
@@ -85,9 +99,9 @@ screen. Wired them up at the source so every app using core gets them working:
|
|
|
85
99
|
active item are `text-primary`, giving ~1.9:1 contrast (unreadable) on that
|
|
86
100
|
navy. A non-collapsing sidebar is permanently *expanded*, so it now uses the
|
|
87
101
|
expanded surface (`bg-background` / `text-foreground`), matching the rest of
|
|
88
|
-
the design (labels-on-light)
|
|
102
|
+
the design (labels-on-light) — contrast jumps to ~9:1.
|
|
89
103
|
|
|
90
|
-
## 0.1.11
|
|
104
|
+
## 0.1.11 — print: force light colours on the print surface
|
|
91
105
|
|
|
92
106
|
- **Fix faded print output in dark mode.** Print pages render under the app's
|
|
93
107
|
shared `<html class="dark">`, so the print surface inherited dark mode's light
|
|
@@ -96,34 +110,34 @@ screen. Wired them up at the source so every app using core gets them working:
|
|
|
96
110
|
`.print-container, #print-content`, keeping every shared print template
|
|
97
111
|
black-on-white regardless of the active theme.
|
|
98
112
|
|
|
99
|
-
## 0.1.8
|
|
113
|
+
## 0.1.8 — branding wiring + auth/rbac unification + clean typecheck
|
|
100
114
|
|
|
101
115
|
- **Single source for permission checks (security fix).** `@goerp/core/rbac`
|
|
102
116
|
used to ship its own copy of `checkPermission` / `getUserPermissions` /
|
|
103
|
-
`getCrudPermissionsFromSession` /
|
|
104
|
-
|
|
117
|
+
`getCrudPermissionsFromSession` / … which had drifted from `@goerp/core/auth`
|
|
118
|
+
— it still allowed `BYPASS_AUTH` in production and denied admins with no
|
|
105
119
|
explicit permission rows. `rbac` now re-exports these from the hardened `auth`
|
|
106
120
|
module (bypass dev-only, admin-before-empty-guard), so the two can't diverge.
|
|
107
121
|
|
|
108
122
|
|
|
109
123
|
- **Branding via `TenantProvider`.** `Logo`/`LogoCompact` now resolve their image,
|
|
110
|
-
company name and tagline from `props
|
|
124
|
+
company name and tagline from `props → TenantProvider branding → defaults`, so a
|
|
111
125
|
new app re-skins by wrapping with `<TenantProvider tenant={{ branding }}>` (or
|
|
112
126
|
passing `logoSrc`/`companyName`/`tagline` props) instead of hardcoding. Added a
|
|
113
127
|
non-throwing `useOptionalTenant()` hook and a `tagline` field to `TenantBranding`.
|
|
114
128
|
`TenantProvider` also applies `branding.primaryColor` (an HSL triple) to
|
|
115
129
|
`--primary`/`--accent-primary` at runtime, so the brand color is config-driven
|
|
116
|
-
too. Fully backward compatible (no provider/props
|
|
130
|
+
too. Fully backward compatible (no provider/props → previous GoERP defaults).
|
|
117
131
|
- **Core now type-checks clean (0 errors).** Fixed the `system/categories`
|
|
118
132
|
components that imported the consumer alias `@goerp/core/ui` from inside the
|
|
119
|
-
package (unresolvable in core's own tsconfig
|
|
133
|
+
package (unresolvable in core's own tsconfig → cascaded into implicit-any in
|
|
120
134
|
their event handlers); switched to relative imports.
|
|
121
135
|
- **Standard entity configs.** `@goerp/core/configs/entities` now ships canonical
|
|
122
136
|
`EntityConfig`s (starting with `departmentsConfig`, plus the previously
|
|
123
137
|
unexported `materialCategoriesConfig`) so apps import + spread-to-override
|
|
124
138
|
instead of copy-pasting near-identical configs.
|
|
125
139
|
|
|
126
|
-
## 0.1.7
|
|
140
|
+
## 0.1.7 — auth hardening
|
|
127
141
|
|
|
128
142
|
Hardened `@goerp/core/auth` so the Vinh Hoa app (and others) can consolidate
|
|
129
143
|
their hand-copied RBAC clones onto core without regressing two safety behaviors:
|
|
@@ -135,46 +149,46 @@ their hand-copied RBAC clones onto core without regressing two safety behaviors:
|
|
|
135
149
|
admin-role bypass BEFORE the empty-permissions guard, so an admin who relies on
|
|
136
150
|
their role (no explicit permission rows) is no longer denied everything.
|
|
137
151
|
|
|
138
|
-
## 0.1.6
|
|
152
|
+
## 0.1.6 — platform foundation extraction
|
|
139
153
|
|
|
140
154
|
Promoted the cross-cutting platform layer out of the Vinh Hoa reference app into
|
|
141
155
|
core so every app inherits the same design system + infrastructure and only
|
|
142
|
-
builds its own business logic. All additive
|
|
156
|
+
builds its own business logic. All additive — existing subpaths are unchanged.
|
|
143
157
|
|
|
144
158
|
### Added
|
|
145
159
|
|
|
146
|
-
- **`@goerp/core/styles/base.css`**
|
|
147
|
-
scale
|
|
160
|
+
- **`@goerp/core/styles/base.css`** — the canonical design-system tokens (Radix
|
|
161
|
+
scale → shadcn semantic → Plane semantic; status, charts, sidebar, scrollbar,
|
|
148
162
|
animations). Apps `@import` this from their Tailwind v4 entry stylesheet and
|
|
149
163
|
override only brand tokens (`--primary`, `--sidebar-*`). Brand default: indigo.
|
|
150
164
|
Also ships `@goerp/core/styles/radix-themes.css` (the HSL Radix scale).
|
|
151
165
|
|
|
152
|
-
- **`@goerp/core/errors`**
|
|
153
|
-
P2002/P2025/P2003/P2014
|
|
166
|
+
- **`@goerp/core/errors`** — `AppError` + `toAppError` (maps Prisma
|
|
167
|
+
P2002/P2025/P2003/P2014 → friendly message + stable `errorId`),
|
|
154
168
|
`withErrorHandler` / `createErrorResponse` / `errorResponse`, and the
|
|
155
169
|
db-injected factories `createErrorLogger(db)` (fingerprint-deduped persistence
|
|
156
170
|
to an `error_logs` table) + `buildServerError({ logServerError })` /
|
|
157
171
|
`createServerError({ db })`. Granular client-safe paths:
|
|
158
172
|
`@goerp/core/errors/{app-error,error-handler,server-error}`.
|
|
159
173
|
|
|
160
|
-
- **`@goerp/core/export`**
|
|
174
|
+
- **`@goerp/core/export`** — `useExport()` (the unified "Xuất Excel" hook),
|
|
161
175
|
`triggerBlobDownload` / `filenameFromContentDisposition`, and `toCsv` /
|
|
162
176
|
`escapeCsvField` (RFC 4180). The crud `exportToCSV` now delegates to `toCsv`,
|
|
163
177
|
fixing unescaped commas/quotes/newlines and falsy `0`/`false`. Granular paths:
|
|
164
178
|
`@goerp/core/export/{to-csv,download-file,use-export}`.
|
|
165
179
|
|
|
166
|
-
- **`@goerp/core/print`**
|
|
180
|
+
- **`@goerp/core/print`** — `DirectPrintProvider` (document-agnostic print device
|
|
167
181
|
layer: hidden-iframe URL printing with a mobile same-tab fallback, one-page
|
|
168
182
|
sizing, loading overlay) driven by a `renderContent` render-prop, the
|
|
169
183
|
`useDirectPrint` hook, and `PrintStyles` (A4/A5 `@media print`). Granular paths:
|
|
170
184
|
`@goerp/core/print/{direct-print-provider,print-styles}`.
|
|
171
185
|
|
|
172
|
-
- **`@goerp/core/repository`**
|
|
186
|
+
- **`@goerp/core/repository`** — `casUpdate` / `casUpdateById`: the compare-and-
|
|
173
187
|
swap invariant for money/stock mutations (`updateMany` on expected state,
|
|
174
188
|
throw on zero rows so an enclosing transaction rolls back).
|
|
175
189
|
|
|
176
|
-
- **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`**
|
|
177
|
-
`serializeDecimalFields` (Prisma Decimal
|
|
190
|
+
- **`@goerp/core/utils/{serialize,fetcher,date-utils,cccd-parser}`** —
|
|
191
|
+
`serializeDecimalFields` (Prisma Decimal → string for Client Components),
|
|
178
192
|
`swrFetcher` / `swrListFetcher` / `unwrapList`, Vietnam-timezone date helpers,
|
|
179
193
|
and the CCCD (VN ID-card) QR parser.
|
|
180
194
|
|
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.16",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"./assets/*": "./src/assets/*",
|
|
38
38
|
"./styles/*": "./src/styles/*",
|
|
39
39
|
"./auth/proxy-gate": "./src/auth/proxy-gate.ts",
|
|
40
|
+
"./rbac/route-handlers": "./src/rbac/route-handlers.ts",
|
|
40
41
|
"./ui/shared/table-styles": "./src/ui/shared/table-styles.ts",
|
|
41
42
|
"./errors/app-error": "./src/errors/app-error.ts",
|
|
42
43
|
"./errors/error-handler": "./src/errors/error-handler.ts",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
// Next.js route-handler factories for the RBAC "roles" API. Every app hand-wrote
|
|
3
|
+
// /api/roles + /api/roles/[id] (list with users[], create/update replacing the
|
|
4
|
+
// RolePermission set, delete guarded by user count). Promoted to core.
|
|
5
|
+
//
|
|
6
|
+
// Permissions are the canonical "action:resource" string[] (matches RoleListPage
|
|
7
|
+
// + getRolesData). Schema-tolerant via RoleServiceSchema (see role-service).
|
|
8
|
+
//
|
|
9
|
+
// Usage (app side):
|
|
10
|
+
// // src/app/api/roles/route.ts
|
|
11
|
+
// import { createRolesCollectionHandlers } from "@goerp/core/rbac/route-handlers";
|
|
12
|
+
// export const { GET, POST } = createRolesCollectionHandlers({
|
|
13
|
+
// prisma, getSession, getCrudPermissions, schema: { userNameField: "fullName", ... },
|
|
14
|
+
// });
|
|
15
|
+
|
|
16
|
+
import { getRolesData, type RoleServiceSchema } from "./role-service";
|
|
17
|
+
|
|
18
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
19
|
+
|
|
20
|
+
export interface RbacHandlerDeps {
|
|
21
|
+
prisma: any;
|
|
22
|
+
getSession: () => MaybePromise<any | null>;
|
|
23
|
+
/** Permission gate for resource "role". Usually getCrudPermissions from @goerp/core/crud/server. */
|
|
24
|
+
getCrudPermissions: (session: any, resource: string) => Promise<{ read?: boolean; create?: boolean; update?: boolean; delete?: boolean }>;
|
|
25
|
+
/** Schema field-map when User/Role diverge from defaults. */
|
|
26
|
+
schema?: RoleServiceSchema;
|
|
27
|
+
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const json = (data: unknown, status = 200) =>
|
|
31
|
+
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
|
|
32
|
+
|
|
33
|
+
// permissions "action:resource" → RolePermission rows {roleCode,resourceCode,actionCode}.
|
|
34
|
+
async function writePermissions(tx: any, roleCode: string, permissions: string[]) {
|
|
35
|
+
await tx.rolePermission.deleteMany({ where: { roleCode } });
|
|
36
|
+
const rows = (permissions || [])
|
|
37
|
+
.map((p) => {
|
|
38
|
+
const [actionCode, resourceCode] = p.split(":");
|
|
39
|
+
return actionCode && resourceCode ? { roleCode, resourceCode, actionCode } : null;
|
|
40
|
+
})
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
if (rows.length) await tx.rolePermission.createMany({ data: rows, skipDuplicates: true });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// GET (list, schema-tolerant) + POST (create + permissions) for /api/roles.
|
|
46
|
+
export function createRolesCollectionHandlers(deps: RbacHandlerDeps) {
|
|
47
|
+
const { prisma, getSession, getCrudPermissions, schema, onError } = deps;
|
|
48
|
+
const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
|
|
49
|
+
|
|
50
|
+
async function GET(req: Request) {
|
|
51
|
+
try {
|
|
52
|
+
const session = await getSession();
|
|
53
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
54
|
+
const perms = await getCrudPermissions(session, "role");
|
|
55
|
+
if (!perms.read) return json({ error: "Forbidden" }, 403);
|
|
56
|
+
const sp = new URL(req.url).searchParams;
|
|
57
|
+
const result = await getRolesData(
|
|
58
|
+
prisma,
|
|
59
|
+
{
|
|
60
|
+
page: Number(sp.get("page") || 1),
|
|
61
|
+
pageSize: Math.min(Number(sp.get("pageSize") || 20), 200),
|
|
62
|
+
search: sp.get("search")?.trim() || undefined,
|
|
63
|
+
status: sp.get("status")?.trim() || undefined,
|
|
64
|
+
},
|
|
65
|
+
schema,
|
|
66
|
+
);
|
|
67
|
+
return json(result);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return fail(e, req);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function POST(req: Request) {
|
|
74
|
+
try {
|
|
75
|
+
const session = await getSession();
|
|
76
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
77
|
+
const perms = await getCrudPermissions(session, "role");
|
|
78
|
+
if (!perms.create) return json({ error: "Forbidden" }, 403);
|
|
79
|
+
const body = await req.json();
|
|
80
|
+
const code = (body.code ?? "").trim();
|
|
81
|
+
const name = (body.name ?? "").trim();
|
|
82
|
+
if (!code || !name) return json({ error: "Thiếu mã hoặc tên vai trò" }, 400);
|
|
83
|
+
const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
|
|
84
|
+
const role = await prisma.$transaction(async (tx: any) => {
|
|
85
|
+
const created = await tx.role.create({
|
|
86
|
+
data: { code, name, description: body.description ?? null, status: body.status ?? "active" },
|
|
87
|
+
});
|
|
88
|
+
await writePermissions(tx, created.code, permissions);
|
|
89
|
+
return created;
|
|
90
|
+
});
|
|
91
|
+
return json({ id: role.id, code: role.code, name: role.name }, 201);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return fail(e, req);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { GET, POST };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// GET + PUT (update + replace permissions) + DELETE (guarded by user count) for /api/roles/[id].
|
|
101
|
+
export function createRoleItemHandlers(deps: RbacHandlerDeps) {
|
|
102
|
+
const { prisma, getSession, getCrudPermissions, onError } = deps;
|
|
103
|
+
const fail = (e: unknown, req: Request) => (onError ? onError(e, req) : json({ error: "Internal error" }, 500));
|
|
104
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
105
|
+
|
|
106
|
+
async function GET(_req: Request, ctx: Ctx) {
|
|
107
|
+
try {
|
|
108
|
+
const { id } = await ctx.params;
|
|
109
|
+
const session = await getSession();
|
|
110
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
111
|
+
const perms = await getCrudPermissions(session, "role");
|
|
112
|
+
if (!perms.read) return json({ error: "Forbidden" }, 403);
|
|
113
|
+
const role = await prisma.role.findUnique({
|
|
114
|
+
where: { id },
|
|
115
|
+
include: { rolePermissions: { select: { resourceCode: true, actionCode: true } } },
|
|
116
|
+
});
|
|
117
|
+
if (!role) return json({ error: "Not found" }, 404);
|
|
118
|
+
return json({
|
|
119
|
+
id: role.id,
|
|
120
|
+
code: role.code,
|
|
121
|
+
name: role.name,
|
|
122
|
+
description: role.description ?? "",
|
|
123
|
+
status: role.status,
|
|
124
|
+
permissions: role.rolePermissions.map((p: any) => `${p.actionCode}:${p.resourceCode}`),
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return fail(e, _req);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function PUT(req: Request, ctx: Ctx) {
|
|
132
|
+
try {
|
|
133
|
+
const { id } = await ctx.params;
|
|
134
|
+
const session = await getSession();
|
|
135
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
136
|
+
const perms = await getCrudPermissions(session, "role");
|
|
137
|
+
if (!perms.update) return json({ error: "Forbidden" }, 403);
|
|
138
|
+
const body = await req.json();
|
|
139
|
+
const existing = await prisma.role.findUnique({ where: { id } });
|
|
140
|
+
if (!existing) return json({ error: "Not found" }, 404);
|
|
141
|
+
const permissions: string[] = Array.isArray(body.permissions) ? body.permissions : [];
|
|
142
|
+
const role = await prisma.$transaction(async (tx: any) => {
|
|
143
|
+
const updated = await tx.role.update({
|
|
144
|
+
where: { id },
|
|
145
|
+
data: {
|
|
146
|
+
name: (body.name ?? existing.name).trim(),
|
|
147
|
+
description: body.description ?? existing.description,
|
|
148
|
+
status: body.status ?? existing.status,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
await writePermissions(tx, updated.code, permissions);
|
|
152
|
+
return updated;
|
|
153
|
+
});
|
|
154
|
+
return json({ id: role.id, code: role.code, name: role.name });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return fail(e, req);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function DELETE(req: Request, ctx: Ctx) {
|
|
161
|
+
try {
|
|
162
|
+
const { id } = await ctx.params;
|
|
163
|
+
const session = await getSession();
|
|
164
|
+
if (!session) return json({ error: "Unauthorized" }, 401);
|
|
165
|
+
const perms = await getCrudPermissions(session, "role");
|
|
166
|
+
if (!perms.delete) return json({ error: "Forbidden" }, 403);
|
|
167
|
+
const role = await prisma.role.findUnique({ where: { id }, include: { _count: { select: { userRoles: true } } } });
|
|
168
|
+
if (!role) return json({ error: "Not found" }, 404);
|
|
169
|
+
if (role._count.userRoles > 0)
|
|
170
|
+
return json({ error: `Vai trò còn ${role._count.userRoles} người dùng, không thể xóa` }, 400);
|
|
171
|
+
await prisma.$transaction(async (tx: any) => {
|
|
172
|
+
await tx.rolePermission.deleteMany({ where: { roleCode: role.code } });
|
|
173
|
+
await tx.role.delete({ where: { id } });
|
|
174
|
+
});
|
|
175
|
+
return new Response(null, { status: 204 });
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return fail(e, req);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { GET, PUT, DELETE };
|
|
182
|
+
}
|