@goplusvn/core 0.1.7 → 0.1.9
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/PLATFORM.md +47 -0
- package/package.json +2 -1
- package/src/configs/entities/departments.config.ts +140 -0
- package/src/configs/entities/index.ts +4 -0
- package/src/crud/components/crud-table.tsx +36 -1
- package/src/providers/tenant-provider.tsx +26 -0
- package/src/rbac/index.ts +14 -172
- package/src/system/pages/components/categories/category-list.tsx +1 -1
- package/src/system/pages/components/categories/category-manager.tsx +1 -1
- package/src/system/pages/components/categories/group-sidebar.tsx +2 -2
- package/src/ui/layout/logo.tsx +39 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.8 — branding wiring + auth/rbac unification + clean typecheck
|
|
4
|
+
|
|
5
|
+
- **Single source for permission checks (security fix).** `@goerp/core/rbac`
|
|
6
|
+
used to ship its own copy of `checkPermission` / `getUserPermissions` /
|
|
7
|
+
`getCrudPermissionsFromSession` / … which had drifted from `@goerp/core/auth`
|
|
8
|
+
— it still allowed `BYPASS_AUTH` in production and denied admins with no
|
|
9
|
+
explicit permission rows. `rbac` now re-exports these from the hardened `auth`
|
|
10
|
+
module (bypass dev-only, admin-before-empty-guard), so the two can't diverge.
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
- **Branding via `TenantProvider`.** `Logo`/`LogoCompact` now resolve their image,
|
|
14
|
+
company name and tagline from `props → TenantProvider branding → defaults`, so a
|
|
15
|
+
new app re-skins by wrapping with `<TenantProvider tenant={{ branding }}>` (or
|
|
16
|
+
passing `logoSrc`/`companyName`/`tagline` props) instead of hardcoding. Added a
|
|
17
|
+
non-throwing `useOptionalTenant()` hook and a `tagline` field to `TenantBranding`.
|
|
18
|
+
`TenantProvider` also applies `branding.primaryColor` (an HSL triple) to
|
|
19
|
+
`--primary`/`--accent-primary` at runtime, so the brand color is config-driven
|
|
20
|
+
too. Fully backward compatible (no provider/props → previous GoERP defaults).
|
|
21
|
+
- **Core now type-checks clean (0 errors).** Fixed the `system/categories`
|
|
22
|
+
components that imported the consumer alias `@goerp/core/ui` from inside the
|
|
23
|
+
package (unresolvable in core's own tsconfig → cascaded into implicit-any in
|
|
24
|
+
their event handlers); switched to relative imports.
|
|
25
|
+
- **Standard entity configs.** `@goerp/core/configs/entities` now ships canonical
|
|
26
|
+
`EntityConfig`s (starting with `departmentsConfig`, plus the previously
|
|
27
|
+
unexported `materialCategoriesConfig`) so apps import + spread-to-override
|
|
28
|
+
instead of copy-pasting near-identical configs.
|
|
29
|
+
|
|
3
30
|
## 0.1.7 — auth hardening
|
|
4
31
|
|
|
5
32
|
Hardened `@goerp/core/auth` so the Vinh Hoa app (and others) can consolidate
|
package/PLATFORM.md
CHANGED
|
@@ -31,6 +31,53 @@ Use semantic tokens, never the raw Tailwind palette: `text-muted-foreground` not
|
|
|
31
31
|
`text-slate-500`, `bg-card` not `bg-white`, `text-destructive` not `text-red-600`.
|
|
32
32
|
Editing `base.css` reskins every app at once.
|
|
33
33
|
|
|
34
|
+
## Branding (logo / company name)
|
|
35
|
+
|
|
36
|
+
Wrap the app shell with `TenantProvider` — `Logo`/`LogoCompact` (used by the
|
|
37
|
+
sidebar/header) then render the app's logo, name and tagline from config instead
|
|
38
|
+
of the GoERP defaults. No provider → defaults (backward compatible).
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { TenantProvider } from "@goerp/core/providers"
|
|
42
|
+
|
|
43
|
+
<TenantProvider
|
|
44
|
+
tenant={{
|
|
45
|
+
id: "tanloc",
|
|
46
|
+
name: "Tấn Lộc",
|
|
47
|
+
branding: {
|
|
48
|
+
logo: "/logo.png",
|
|
49
|
+
companyName: "Tấn Lộc",
|
|
50
|
+
tagline: "Suất ăn CN",
|
|
51
|
+
primaryColor: "262 83% 58%", // HSL triple → re-skins --primary at runtime
|
|
52
|
+
favicon: "/favicon.ico",
|
|
53
|
+
},
|
|
54
|
+
currency: "VND",
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<MainLayout>{children}</MainLayout>
|
|
58
|
+
</TenantProvider>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`primaryColor` (an HSL triple, no `hsl()` wrapper) reskins the whole app's brand
|
|
62
|
+
token at runtime — no globals edit needed. `favicon` is data the app reads in its
|
|
63
|
+
metadata. Read branding anywhere via `useOptionalTenant()` (non-throwing); for a
|
|
64
|
+
one-off, `Logo` also accepts `logoSrc` / `companyName` / `tagline` props.
|
|
65
|
+
|
|
66
|
+
## Standard entity configs
|
|
67
|
+
|
|
68
|
+
Core ships canonical configs for org entities so apps don't copy-paste them.
|
|
69
|
+
Import and override by spreading:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { departmentsConfig } from "@goerp/core/configs/entities"
|
|
73
|
+
|
|
74
|
+
// use as-is, or tweak:
|
|
75
|
+
export const departments = { ...departmentsConfig, apiEndpoint: "/api/org/departments" }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
These drive the core CRUD engine (`@goerp/core/crud`) — declare an `EntityConfig`
|
|
79
|
+
(fields/filters/permissions/features) and you get table/form/import/export.
|
|
80
|
+
|
|
34
81
|
## Errors
|
|
35
82
|
|
|
36
83
|
Bind the db-injected factories once, then use `serverError` in every catch block.
|
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.9",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"registry": "https://registry.npmjs.org",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"./utils/date-utils": "./src/utils/date-utils.ts",
|
|
50
50
|
"./utils/cccd-parser": "./src/utils/cccd-parser.ts",
|
|
51
51
|
"./configs/status": "./src/configs/status.ts",
|
|
52
|
+
"./configs/entities": "./src/configs/entities/index.ts",
|
|
52
53
|
"./package.json": "./package.json"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Building2 } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
import type { EntityConfig } from "../../types";
|
|
4
|
+
|
|
5
|
+
import { STATUS_OPTIONS } from "../status";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Canonical "Phòng ban" (department) entity config — the org-structure entity
|
|
9
|
+
* every business app needs. Apps import this instead of copy-pasting it, and
|
|
10
|
+
* override fields by spreading: `{ ...departmentsConfig, apiEndpoint: "..." }`.
|
|
11
|
+
*/
|
|
12
|
+
export const departmentsConfig: EntityConfig = {
|
|
13
|
+
name: "department",
|
|
14
|
+
permissionResource: "department",
|
|
15
|
+
label: "Phòng ban",
|
|
16
|
+
pluralLabel: "Phòng ban",
|
|
17
|
+
icon: Building2,
|
|
18
|
+
description: "Quản lý cơ cấu tổ chức phòng ban",
|
|
19
|
+
apiEndpoint: "/api/departments",
|
|
20
|
+
idField: "id",
|
|
21
|
+
displayField: "name",
|
|
22
|
+
|
|
23
|
+
fields: [
|
|
24
|
+
{
|
|
25
|
+
name: "id",
|
|
26
|
+
label: "ID",
|
|
27
|
+
type: "text",
|
|
28
|
+
hideInForm: true,
|
|
29
|
+
hideInTable: true,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "code",
|
|
33
|
+
label: "Mã phòng ban",
|
|
34
|
+
type: "text",
|
|
35
|
+
required: true,
|
|
36
|
+
placeholder: "VD: IT, HR, SALES",
|
|
37
|
+
filterable: true,
|
|
38
|
+
width: 120,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "name",
|
|
42
|
+
label: "Tên phòng ban",
|
|
43
|
+
type: "text",
|
|
44
|
+
required: true,
|
|
45
|
+
placeholder: "Nhập tên phòng ban",
|
|
46
|
+
filterable: true,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "description",
|
|
50
|
+
label: "Mô tả",
|
|
51
|
+
type: "textarea",
|
|
52
|
+
placeholder: "Nhập mô tả phòng ban",
|
|
53
|
+
hideInTable: true,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: "parentId",
|
|
57
|
+
label: "Phòng ban cha",
|
|
58
|
+
type: "select",
|
|
59
|
+
placeholder: "Chọn phòng ban cha (nếu có)",
|
|
60
|
+
dataSource: {
|
|
61
|
+
type: "api",
|
|
62
|
+
endpoint: "/api/departments",
|
|
63
|
+
labelField: "name",
|
|
64
|
+
valueField: "id",
|
|
65
|
+
searchable: true,
|
|
66
|
+
},
|
|
67
|
+
hideInTable: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "parentName",
|
|
71
|
+
label: "Phòng ban cha",
|
|
72
|
+
type: "text",
|
|
73
|
+
hideInForm: true,
|
|
74
|
+
isDisplayOnly: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "order",
|
|
78
|
+
label: "Thứ tự",
|
|
79
|
+
type: "number",
|
|
80
|
+
defaultValue: 0,
|
|
81
|
+
placeholder: "Thứ tự hiển thị",
|
|
82
|
+
width: 80,
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: "status",
|
|
86
|
+
label: "Trạng thái",
|
|
87
|
+
type: "switch",
|
|
88
|
+
required: true,
|
|
89
|
+
defaultValue: "active",
|
|
90
|
+
filterable: true,
|
|
91
|
+
options: STATUS_OPTIONS,
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "createdAt",
|
|
95
|
+
label: "Ngày tạo",
|
|
96
|
+
type: "datetime",
|
|
97
|
+
hideInForm: true,
|
|
98
|
+
width: 120,
|
|
99
|
+
renderCell: (value) =>
|
|
100
|
+
new Date(value as string).toLocaleDateString("vi-VN"),
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
|
|
104
|
+
filters: [
|
|
105
|
+
{
|
|
106
|
+
name: "search",
|
|
107
|
+
label: "Tìm kiếm",
|
|
108
|
+
type: "text",
|
|
109
|
+
field: "name",
|
|
110
|
+
operator: "contains",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: "status",
|
|
114
|
+
label: "Trạng thái",
|
|
115
|
+
type: "select",
|
|
116
|
+
field: "status",
|
|
117
|
+
operator: "eq",
|
|
118
|
+
options: [
|
|
119
|
+
{ label: "Hoạt động", value: "active" },
|
|
120
|
+
{ label: "Vô hiệu hóa", value: "inactive" },
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
|
|
125
|
+
permissions: {
|
|
126
|
+
create: true,
|
|
127
|
+
read: true,
|
|
128
|
+
update: true,
|
|
129
|
+
delete: true,
|
|
130
|
+
export: true,
|
|
131
|
+
import: true,
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
features: {
|
|
135
|
+
search: true,
|
|
136
|
+
bulkActions: true,
|
|
137
|
+
export: true,
|
|
138
|
+
import: true,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Canonical entity configs shipped by core. Apps import these instead of
|
|
2
|
+
// copy-pasting, and override by spreading: `{ ...departmentsConfig, ... }`.
|
|
3
|
+
export { departmentsConfig } from "./departments.config";
|
|
4
|
+
export { materialCategoriesConfig } from "./material-categories.config";
|
|
@@ -23,6 +23,40 @@ import {
|
|
|
23
23
|
} from "../../ui/primitives/client";
|
|
24
24
|
import { DataTable } from "../../ui/data-display/data-table/data-table";
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a relation FK column's label from the relation object the server already
|
|
28
|
+
* included on the row (e.g. `branchId` → `row.branch.name`).
|
|
29
|
+
*
|
|
30
|
+
* Relation columns are configured as a `select` field whose `dataSource` points at
|
|
31
|
+
* `/api/<relation>`; the client resolves the id → label by fetching that endpoint
|
|
32
|
+
* asynchronously. That secondary fetch is the only label source, so the column shows
|
|
33
|
+
* the raw FK id whenever it hasn't resolved yet (first paint), fails, is slow, or is
|
|
34
|
+
* capped by pagination (the endpoint's default page size). A per-config `renderCell`
|
|
35
|
+
* that reads `row.branch?.name` can't help — render functions are stripped when the
|
|
36
|
+
* config is serialized for Client Components.
|
|
37
|
+
*
|
|
38
|
+
* When the entity `include`s the relation, the row already carries the related record,
|
|
39
|
+
* so we can render its label directly with no secondary fetch and no race. The relation
|
|
40
|
+
* key is inferred from the field name (`branchId` → `branch`) and the label field from
|
|
41
|
+
* the `dataSource` (default `name`).
|
|
42
|
+
*/
|
|
43
|
+
function resolveIncludedRelationLabel(
|
|
44
|
+
field: { name?: string; dataSource?: { labelField?: string } },
|
|
45
|
+
row: Record<string, unknown> | undefined,
|
|
46
|
+
): string | undefined {
|
|
47
|
+
const name = field?.name;
|
|
48
|
+
if (!field?.dataSource || !row || !name || !name.endsWith("Id")) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
const relationKey = name.slice(0, -2);
|
|
52
|
+
if (!relationKey) return undefined;
|
|
53
|
+
const rel = row[relationKey];
|
|
54
|
+
if (!rel || typeof rel !== "object" || Array.isArray(rel)) return undefined;
|
|
55
|
+
const labelField = field.dataSource.labelField || "name";
|
|
56
|
+
const label = (rel as Record<string, unknown>)[labelField];
|
|
57
|
+
return label != null && label !== "" ? String(label) : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
26
60
|
interface CrudTableProps<TData = Record<string, unknown>> {
|
|
27
61
|
data: CrudResponse<TData>;
|
|
28
62
|
loading?: boolean;
|
|
@@ -196,7 +230,8 @@ export function CrudTable<TData extends Record<string, unknown>>({
|
|
|
196
230
|
);
|
|
197
231
|
content = option
|
|
198
232
|
? option.label
|
|
199
|
-
:
|
|
233
|
+
: (resolveIncludedRelationLabel(field, row.original) ??
|
|
234
|
+
formatFieldValue(value, field));
|
|
200
235
|
}
|
|
201
236
|
} else {
|
|
202
237
|
content = formatFieldValue(value, field);
|
|
@@ -4,7 +4,14 @@ import React, { createContext, useContext } from "react";
|
|
|
4
4
|
export interface TenantBranding {
|
|
5
5
|
logo?: string;
|
|
6
6
|
companyName: string;
|
|
7
|
+
/** Subtitle shown under the company name in the logo (e.g. "ERP System"). */
|
|
8
|
+
tagline?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Brand color as an HSL triple, e.g. "262 83% 58%" (no `hsl(...)` wrapper).
|
|
11
|
+
* Applied at runtime to `--primary` / `--accent-primary` by TenantProvider.
|
|
12
|
+
*/
|
|
7
13
|
primaryColor?: string;
|
|
14
|
+
/** Favicon URL — apps read `tenant.branding.favicon` in their metadata. */
|
|
8
15
|
favicon?: string;
|
|
9
16
|
}
|
|
10
17
|
|
|
@@ -29,8 +36,18 @@ interface TenantProviderProps {
|
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export function TenantProvider({ children, tenant }: TenantProviderProps) {
|
|
39
|
+
const { primaryColor } = tenant.branding;
|
|
32
40
|
return (
|
|
33
41
|
<TenantContext.Provider value={{ tenant }}>
|
|
42
|
+
{primaryColor && (
|
|
43
|
+
// Re-skin the brand token at runtime so the whole app follows the
|
|
44
|
+
// tenant color. Rendered after the imported base.css → wins the cascade.
|
|
45
|
+
<style
|
|
46
|
+
dangerouslySetInnerHTML={{
|
|
47
|
+
__html: `:root{--primary:${primaryColor};--accent-primary:${primaryColor};}`,
|
|
48
|
+
}}
|
|
49
|
+
/>
|
|
50
|
+
)}
|
|
34
51
|
{children}
|
|
35
52
|
</TenantContext.Provider>
|
|
36
53
|
);
|
|
@@ -43,3 +60,12 @@ export function useTenantContext() {
|
|
|
43
60
|
}
|
|
44
61
|
return context;
|
|
45
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Non-throwing variant: returns the tenant config if inside a TenantProvider,
|
|
66
|
+
* otherwise `undefined`. Lets shared components (e.g. Logo) read branding
|
|
67
|
+
* without forcing every app to mount a provider.
|
|
68
|
+
*/
|
|
69
|
+
export function useOptionalTenant(): TenantConfig | undefined {
|
|
70
|
+
return useContext(TenantContext)?.tenant;
|
|
71
|
+
}
|
package/src/rbac/index.ts
CHANGED
|
@@ -116,179 +116,21 @@ interface ExtendedUser {
|
|
|
116
116
|
permissions?: Permission[];
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
// Permission checks now live in a single hardened source: @goerp/core/auth
|
|
120
|
+
// (bypass is dev-only; the admin role is honored BEFORE the empty-permissions
|
|
121
|
+
// guard). Re-exported here so existing @goerp/core/rbac consumers are unchanged
|
|
122
|
+
// and can never diverge from auth again.
|
|
123
|
+
export {
|
|
124
|
+
getUserPermissions,
|
|
125
|
+
getCrudPermissionsFromSession,
|
|
126
|
+
checkPermission,
|
|
127
|
+
hasPermission,
|
|
128
|
+
requirePermission,
|
|
129
|
+
hasRole,
|
|
130
|
+
hasAnyRole,
|
|
131
|
+
isAdmin,
|
|
132
|
+
} from "../auth";
|
|
122
133
|
|
|
123
|
-
if (!user.permissions) {
|
|
124
|
-
return [];
|
|
125
|
-
}
|
|
126
|
-
return user.permissions;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const BYPASS_AUTH =
|
|
130
|
-
process.env.BYPASS_AUTH === "true" || process.env.BYPASS_AUTH === "1";
|
|
131
|
-
|
|
132
|
-
const ADMIN_ROLE_CODES = ["admin", "SUPER_ADMIN"];
|
|
133
|
-
|
|
134
|
-
export function getCrudPermissionsFromSession(
|
|
135
|
-
session: Session | null,
|
|
136
|
-
entity: string,
|
|
137
|
-
): {
|
|
138
|
-
create: boolean;
|
|
139
|
-
view: boolean;
|
|
140
|
-
update: boolean;
|
|
141
|
-
delete: boolean;
|
|
142
|
-
export: boolean;
|
|
143
|
-
import: boolean;
|
|
144
|
-
approve: boolean;
|
|
145
|
-
reject: boolean;
|
|
146
|
-
} {
|
|
147
|
-
if (BYPASS_AUTH) {
|
|
148
|
-
return {
|
|
149
|
-
create: true,
|
|
150
|
-
view: true,
|
|
151
|
-
update: true,
|
|
152
|
-
delete: true,
|
|
153
|
-
export: true,
|
|
154
|
-
import: true,
|
|
155
|
-
approve: true,
|
|
156
|
-
reject: true,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (!session?.user) {
|
|
161
|
-
return {
|
|
162
|
-
create: false,
|
|
163
|
-
view: false,
|
|
164
|
-
update: false,
|
|
165
|
-
delete: false,
|
|
166
|
-
export: false,
|
|
167
|
-
import: false,
|
|
168
|
-
approve: false,
|
|
169
|
-
reject: false,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const user = session.user as ExtendedUser;
|
|
174
|
-
|
|
175
|
-
if (!user.id) {
|
|
176
|
-
return {
|
|
177
|
-
create: false,
|
|
178
|
-
view: false,
|
|
179
|
-
update: false,
|
|
180
|
-
delete: false,
|
|
181
|
-
export: false,
|
|
182
|
-
import: false,
|
|
183
|
-
approve: false,
|
|
184
|
-
reject: false,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const isAdmin = user.roles?.some((role) => ADMIN_ROLE_CODES.includes(role));
|
|
189
|
-
if (isAdmin) {
|
|
190
|
-
return {
|
|
191
|
-
create: true,
|
|
192
|
-
view: true,
|
|
193
|
-
update: true,
|
|
194
|
-
delete: true,
|
|
195
|
-
export: true,
|
|
196
|
-
import: true,
|
|
197
|
-
approve: true,
|
|
198
|
-
reject: true,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const permissions = user.permissions || [];
|
|
203
|
-
const permissionKeys = new Set(
|
|
204
|
-
permissions.map((p) => `${p.resourceCode}:${p.actionCode}`),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
const hasPermission = (action: string) => {
|
|
208
|
-
const key = `${entity}:${action}`;
|
|
209
|
-
return permissionKeys.has(key);
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
create: hasPermission(getActionCode("create")),
|
|
214
|
-
view: hasPermission(getActionCode("view")),
|
|
215
|
-
update: hasPermission(getActionCode("update")),
|
|
216
|
-
delete: hasPermission(getActionCode("delete")),
|
|
217
|
-
export: hasPermission(getActionCode("export")),
|
|
218
|
-
import: hasPermission(getActionCode("import")),
|
|
219
|
-
approve: hasPermission(getActionCode("approve")),
|
|
220
|
-
reject: hasPermission(getActionCode("reject")),
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
export function checkPermission(
|
|
225
|
-
session: Session | null,
|
|
226
|
-
resourceCode: string,
|
|
227
|
-
actionCode: string,
|
|
228
|
-
): boolean {
|
|
229
|
-
if (BYPASS_AUTH) {
|
|
230
|
-
return true;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
if (!session?.user) return false;
|
|
234
|
-
const user = session.user as ExtendedUser;
|
|
235
|
-
|
|
236
|
-
if (!user.permissions || user.permissions.length === 0) {
|
|
237
|
-
return false;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (user.roles?.some((role) => ADMIN_ROLE_CODES.includes(role))) {
|
|
241
|
-
return true;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const permissionKey = `${resourceCode}:${actionCode}`;
|
|
245
|
-
return user.permissions.some(
|
|
246
|
-
(p) => p.resourceCode === resourceCode && p.actionCode === actionCode,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
export function hasPermission(
|
|
251
|
-
session: Session | null,
|
|
252
|
-
resourceCode: string,
|
|
253
|
-
actionCode: string,
|
|
254
|
-
): boolean {
|
|
255
|
-
return checkPermission(session, resourceCode, actionCode);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function requirePermission(
|
|
259
|
-
session: Session | null,
|
|
260
|
-
resourceCode: string,
|
|
261
|
-
actionCode: string,
|
|
262
|
-
): void {
|
|
263
|
-
if (!checkPermission(session, resourceCode, actionCode)) {
|
|
264
|
-
throw new Error(
|
|
265
|
-
`Unauthorized: User does not have permission ${actionCode} on ${resourceCode}`,
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export function hasRole(session: Session | null, roleCode: string): boolean {
|
|
271
|
-
if (!session?.user) return false;
|
|
272
|
-
const user = session.user as ExtendedUser;
|
|
273
|
-
|
|
274
|
-
if (!user.roles) {
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
return user.roles.includes(roleCode);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function hasAnyRole(
|
|
281
|
-
session: Session | null,
|
|
282
|
-
roleCodes: string[],
|
|
283
|
-
): boolean {
|
|
284
|
-
if (!session?.user) return false;
|
|
285
|
-
const user = session.user as ExtendedUser;
|
|
286
|
-
|
|
287
|
-
if (!user.roles) {
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
return roleCodes.some((role) => user.roles!.includes(role));
|
|
291
|
-
}
|
|
292
134
|
export * from "./permission-service";
|
|
293
135
|
export * from "./role-service";
|
|
294
136
|
export * from "./resource-service";
|
|
@@ -4,7 +4,7 @@ import { useState } from "react";
|
|
|
4
4
|
import { Search } from "lucide-react";
|
|
5
5
|
|
|
6
6
|
import type { SystemCategoryGroup } from "../../../types";
|
|
7
|
-
import { Input } from "
|
|
7
|
+
import { Input } from "../../../../ui";
|
|
8
8
|
import { GroupSidebar } from "./group-sidebar";
|
|
9
9
|
import { CategoryList } from "./category-list";
|
|
10
10
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
DropdownMenuItem,
|
|
12
12
|
DropdownMenuTrigger,
|
|
13
13
|
ScrollArea,
|
|
14
|
-
} from "
|
|
14
|
+
} from "../../../../ui";
|
|
15
15
|
import { cn } from "../../../../utils";
|
|
16
16
|
// Assuming we'll implement these dialogs or use generic ones
|
|
17
17
|
// For now, I'll assume we might use a generic form or specific dialogs
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
FormMessage,
|
|
35
35
|
Input as FormInput,
|
|
36
36
|
Textarea,
|
|
37
|
-
} from "
|
|
37
|
+
} from "../../../../ui";
|
|
38
38
|
import { systemCategoryGroupSchema } from "../../../schemas/system-category-group.schema";
|
|
39
39
|
|
|
40
40
|
interface GroupSidebarProps {
|
package/src/ui/layout/logo.tsx
CHANGED
|
@@ -2,13 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import Image from "next/image";
|
|
4
4
|
|
|
5
|
+
import { useOptionalTenant } from "../../providers/tenant-provider";
|
|
5
6
|
import { cn } from "../../utils";
|
|
6
7
|
|
|
8
|
+
// Defaults used when neither props nor a TenantProvider supply branding — keeps
|
|
9
|
+
// the component working out of the box (and backward compatible).
|
|
10
|
+
const DEFAULT_LOGO_SRC = "/images/logos/goeat_logo.png";
|
|
11
|
+
const DEFAULT_COMPANY = "GoERP";
|
|
12
|
+
const DEFAULT_TAGLINE = "ERP System";
|
|
13
|
+
|
|
7
14
|
interface LogoProps {
|
|
8
15
|
className?: string;
|
|
9
16
|
size?: "sm" | "md" | "lg" | "xl" | "2xl";
|
|
10
17
|
showText?: boolean;
|
|
11
18
|
collapsed?: boolean;
|
|
19
|
+
/** Direct overrides. Otherwise read from TenantProvider branding, else defaults. */
|
|
20
|
+
logoSrc?: string;
|
|
21
|
+
companyName?: string;
|
|
22
|
+
tagline?: string;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
const sizeMap = {
|
|
@@ -24,14 +35,21 @@ export function Logo({
|
|
|
24
35
|
size = "md",
|
|
25
36
|
showText = true,
|
|
26
37
|
collapsed = false,
|
|
38
|
+
logoSrc,
|
|
39
|
+
companyName,
|
|
40
|
+
tagline,
|
|
27
41
|
}: LogoProps) {
|
|
42
|
+
const branding = useOptionalTenant()?.branding;
|
|
43
|
+
const src = logoSrc ?? branding?.logo ?? DEFAULT_LOGO_SRC;
|
|
44
|
+
const name = companyName ?? branding?.companyName ?? DEFAULT_COMPANY;
|
|
45
|
+
const sub = tagline ?? branding?.tagline ?? DEFAULT_TAGLINE;
|
|
28
46
|
const dimensions = sizeMap[size];
|
|
29
47
|
|
|
30
48
|
return (
|
|
31
49
|
<div className={cn("flex items-center gap-2", className)}>
|
|
32
50
|
<Image
|
|
33
|
-
src=
|
|
34
|
-
alt=
|
|
51
|
+
src={src}
|
|
52
|
+
alt={`${name} Logo`}
|
|
35
53
|
width={dimensions.width}
|
|
36
54
|
height={dimensions.height}
|
|
37
55
|
className="object-contain"
|
|
@@ -39,20 +57,34 @@ export function Logo({
|
|
|
39
57
|
/>
|
|
40
58
|
{showText && !collapsed && (
|
|
41
59
|
<div className="flex flex-col leading-none">
|
|
42
|
-
<span className="text-base font-bold text-foreground">
|
|
43
|
-
|
|
60
|
+
<span className="text-base font-bold text-foreground">{name}</span>
|
|
61
|
+
{sub && (
|
|
62
|
+
<span className="text-[11px] text-muted-foreground">{sub}</span>
|
|
63
|
+
)}
|
|
44
64
|
</div>
|
|
45
65
|
)}
|
|
46
66
|
</div>
|
|
47
67
|
);
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
export function LogoCompact({
|
|
70
|
+
export function LogoCompact({
|
|
71
|
+
className,
|
|
72
|
+
logoSrc,
|
|
73
|
+
companyName,
|
|
74
|
+
}: {
|
|
75
|
+
className?: string;
|
|
76
|
+
logoSrc?: string;
|
|
77
|
+
companyName?: string;
|
|
78
|
+
}) {
|
|
79
|
+
const branding = useOptionalTenant()?.branding;
|
|
80
|
+
const src = logoSrc ?? branding?.logo ?? DEFAULT_LOGO_SRC;
|
|
81
|
+
const name = companyName ?? branding?.companyName ?? DEFAULT_COMPANY;
|
|
82
|
+
|
|
51
83
|
return (
|
|
52
84
|
<div className={cn("flex items-center justify-center", className)}>
|
|
53
85
|
<Image
|
|
54
|
-
src=
|
|
55
|
-
alt=
|
|
86
|
+
src={src}
|
|
87
|
+
alt={name}
|
|
56
88
|
width={32}
|
|
57
89
|
height={32}
|
|
58
90
|
className="object-contain"
|