rbac-manager 0.0.0-stage → 1.0.0

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.
Files changed (67) hide show
  1. package/.github/workflows/publish.yml +29 -0
  2. package/README.md +317 -2
  3. package/package.json +45 -4
  4. package/scripts/postbuild.js +17 -0
  5. package/src/config/rbac.config.ts +206 -0
  6. package/src/constants/actions.constants.ts +21 -0
  7. package/src/constants/apiKeys.constants.ts +25 -0
  8. package/src/constants/auth.constants.ts +39 -0
  9. package/src/constants/endpoints.constants.ts +66 -0
  10. package/src/constants/i18n.constants.ts +82 -0
  11. package/src/constants/index.ts +70 -0
  12. package/src/constants/masters.constants.ts +68 -0
  13. package/src/constants/permissions/index.ts +44 -0
  14. package/src/constants/permissions/masterPermissions.constants.ts +44 -0
  15. package/src/constants/permissions/rolePermissions.constants.ts +18 -0
  16. package/src/constants/permissions.constants.ts +34 -0
  17. package/src/constants/roles.constants.ts +34 -0
  18. package/src/core/adapters/authAdapter.ts +89 -0
  19. package/src/core/adapters/index.ts +17 -0
  20. package/src/core/adapters/mastersAdapter.ts +132 -0
  21. package/src/core/adapters/permissionsAdapter.ts +90 -0
  22. package/src/core/adapters/rolesAdapter.ts +115 -0
  23. package/src/core/api/httpClient.ts +296 -0
  24. package/src/core/context/PermissionContext.tsx +232 -0
  25. package/src/core/context/RBACConfigContext.tsx +52 -0
  26. package/src/core/guards/Can.tsx +98 -0
  27. package/src/core/guards/RouteGuard.tsx +42 -0
  28. package/src/core/hooks/useMaster.ts +99 -0
  29. package/src/core/hooks/usePermissions.ts +114 -0
  30. package/src/core/hooks/useRoles.ts +120 -0
  31. package/src/core/types/adapter.types.ts +54 -0
  32. package/src/core/types/auth.types.ts +44 -0
  33. package/src/core/types/index.ts +7 -0
  34. package/src/core/types/masters.types.ts +63 -0
  35. package/src/core/types/pagination.types.ts +37 -0
  36. package/src/core/types/rbac.types.ts +69 -0
  37. package/src/core/types/router.types.ts +43 -0
  38. package/src/core/types/style.types.ts +53 -0
  39. package/src/core/utils/permissionResolver.ts +44 -0
  40. package/src/index.ts +44 -0
  41. package/src/modules/masters/DepartmentMasterView.tsx +126 -0
  42. package/src/modules/masters/DesignationMasterView.tsx +97 -0
  43. package/src/modules/masters/EmployeeMasterView.tsx +107 -0
  44. package/src/modules/masters/EntityMasterView.tsx +102 -0
  45. package/src/modules/masters/ModuleMasterView.tsx +104 -0
  46. package/src/modules/masters/SubModuleMasterView.tsx +106 -0
  47. package/src/modules/masters/components/MasterDataTable.tsx +684 -0
  48. package/src/modules/masters/index.ts +7 -0
  49. package/src/modules/roles-and-permissions/components/PermissionMatrix.tsx +289 -0
  50. package/src/modules/roles-and-permissions/components/PermissionRegistry.tsx +108 -0
  51. package/src/modules/roles-and-permissions/components/RoleListTable.tsx +568 -0
  52. package/src/modules/roles-and-permissions/components/RoleModal.tsx +213 -0
  53. package/src/modules/roles-and-permissions/index.ts +4 -0
  54. package/src/shared/components/Badge.tsx +24 -0
  55. package/src/shared/components/Button.tsx +75 -0
  56. package/src/shared/components/DynamicSidebar.tsx +181 -0
  57. package/src/shared/components/Input.tsx +48 -0
  58. package/src/shared/components/Modal.tsx +70 -0
  59. package/src/shared/components/Table.tsx +345 -0
  60. package/src/styles/standalone.css +51 -0
  61. package/src/theme/ThemeContext.tsx +74 -0
  62. package/src/theme/defaultTheme.ts +81 -0
  63. package/src/theme/index.ts +3 -0
  64. package/src/theme/theme.types.ts +106 -0
  65. package/tsconfig.cjs.json +10 -0
  66. package/tsconfig.esm.json +10 -0
  67. package/tsconfig.json +28 -0
@@ -0,0 +1,132 @@
1
+ import { RBAC_CONSTANTS } from '../../constants';
2
+ import {
3
+ MastersAdapter,
4
+ StandardEntityMaster,
5
+ StandardDepartmentMaster,
6
+ StandardDesignationMaster,
7
+ StandardEmployeeMaster,
8
+ StandardModuleMasterItem,
9
+ PaginatedResult,
10
+ } from '../types';
11
+
12
+ function extractListAndMeta(raw: any): { itemsRaw: any[]; total: number; page: number; limit: number } {
13
+ const dataKey = RBAC_CONSTANTS.api.DATA;
14
+ const pagKeys = RBAC_CONSTANTS.pagination;
15
+ const root = raw?.[dataKey] ?? raw;
16
+
17
+ if (Array.isArray(root)) {
18
+ return { itemsRaw: root, total: root.length, page: 1, limit: 10 };
19
+ } else if (root && typeof root === 'object') {
20
+ const itemsRaw = root[pagKeys.ITEMS] || root.items || root.rows || [];
21
+ const total = root[pagKeys.TOTAL] ?? itemsRaw.length;
22
+ const page = root[pagKeys.PAGE] ?? 1;
23
+ const limit = root[pagKeys.LIMIT] ?? 10;
24
+ return { itemsRaw, total, page, limit };
25
+ }
26
+ return { itemsRaw: [], total: 0, page: 1, limit: 10 };
27
+ }
28
+
29
+ export const defaultMastersAdapter: MastersAdapter = {
30
+ transformEntityListResponse: (raw: any): PaginatedResult<StandardEntityMaster> => {
31
+ const k = RBAC_CONSTANTS.masters.ENTITY;
32
+ const { itemsRaw, total, page, limit } = extractListAndMeta(raw);
33
+
34
+ const items = itemsRaw.map((e: any) => ({
35
+ id: e[k.ID] ?? e.id,
36
+ name: e[k.NAME] ?? e.name,
37
+ code: e[k.CODE],
38
+ state: e[k.STATE],
39
+ city: e[k.CITY],
40
+ pincode: e[k.PINCODE],
41
+ isActive: Boolean(e[RBAC_CONSTANTS.masters.IS_ACTIVE] ?? e.is_active ?? true),
42
+ createdAt: e[RBAC_CONSTANTS.masters.CREATED_AT],
43
+ }));
44
+
45
+ return { items, total, page, limit };
46
+ },
47
+
48
+ transformDepartmentListResponse: (raw: any): PaginatedResult<StandardDepartmentMaster> => {
49
+ const k = RBAC_CONSTANTS.masters.DEPARTMENT;
50
+ const { itemsRaw, total, page, limit } = extractListAndMeta(raw);
51
+
52
+ const items = itemsRaw.map((d: any) => ({
53
+ id: d[k.ID] ?? d.id,
54
+ name: d[k.NAME] ?? d.name,
55
+ code: d[k.CODE],
56
+ entityId: d[k.ENTITY_ID] ?? d.entity?.entityId,
57
+ entityName: d.entity?.entity_name ?? d.entityName,
58
+ isActive: Boolean(d[RBAC_CONSTANTS.masters.IS_ACTIVE] ?? d.is_active ?? true),
59
+ createdAt: d[RBAC_CONSTANTS.masters.CREATED_AT],
60
+ }));
61
+
62
+ return { items, total, page, limit };
63
+ },
64
+
65
+ transformDesignationListResponse: (raw: any): PaginatedResult<StandardDesignationMaster> => {
66
+ const k = RBAC_CONSTANTS.masters.DESIGNATION;
67
+ const { itemsRaw, total, page, limit } = extractListAndMeta(raw);
68
+
69
+ const items = itemsRaw.map((des: any) => ({
70
+ id: des[k.ID] ?? des.id,
71
+ name: des[k.NAME] ?? des.name,
72
+ code: des[k.CODE],
73
+ departmentId: des[k.DEPARTMENT_ID] ?? des.department?.departmentId,
74
+ departmentName: des.department?.department_name ?? des.departmentName,
75
+ isActive: Boolean(des[RBAC_CONSTANTS.masters.IS_ACTIVE] ?? des.is_active ?? true),
76
+ createdAt: des[RBAC_CONSTANTS.masters.CREATED_AT],
77
+ }));
78
+
79
+ return { items, total, page, limit };
80
+ },
81
+
82
+ transformEmployeeListResponse: (raw: any): PaginatedResult<StandardEmployeeMaster> => {
83
+ const k = RBAC_CONSTANTS.masters.EMPLOYEE;
84
+ const { itemsRaw, total, page, limit } = extractListAndMeta(raw);
85
+
86
+ const items = itemsRaw.map((emp: any) => {
87
+ const fn = emp[k.FIRST_NAME] || '';
88
+ const ln = emp[k.LAST_NAME] || '';
89
+ return {
90
+ id: emp[k.ID] ?? emp.id,
91
+ employeeCode: emp[k.EMPLOYEE_CODE],
92
+ firstName: fn,
93
+ lastName: ln,
94
+ fullName: `${fn} ${ln}`.trim() || emp.name || emp[k.EMAIL] || '',
95
+ email: emp[k.EMAIL] || '',
96
+ mobileNumber: emp[k.MOBILE_NUMBER],
97
+ entityId: emp[k.ENTITY_ID],
98
+ entityName: emp.entity?.entity_name,
99
+ departmentId: emp[k.DEPARTMENT_ID],
100
+ departmentName: emp.department?.department_name,
101
+ designationId: emp[k.DESIGNATION_ID],
102
+ designationName: emp.designation?.designation_name,
103
+ reportingManagerId: emp[k.REPORTING_MANAGER_ID],
104
+ roleId: emp[k.ROLE_ID] ?? emp.role?.id,
105
+ roleName: emp.role?.role_name,
106
+ isActive: Boolean(emp[RBAC_CONSTANTS.masters.IS_ACTIVE] ?? emp.is_active ?? true),
107
+ createdAt: emp[RBAC_CONSTANTS.masters.CREATED_AT],
108
+ };
109
+ });
110
+
111
+ return { items, total, page, limit };
112
+ },
113
+
114
+ transformModuleMasterListResponse: (raw: any): PaginatedResult<StandardModuleMasterItem> => {
115
+ const k = RBAC_CONSTANTS.masters.MODULE;
116
+ const { itemsRaw, total, page, limit } = extractListAndMeta(raw);
117
+
118
+ const items = itemsRaw.map((m: any) => ({
119
+ id: m[k.ID] ?? m.id,
120
+ name: m[k.NAME] ?? m.name,
121
+ slug: m[k.SLUG_NAME] ?? m.slug,
122
+ routePath: m[k.ROUTE_PATH],
123
+ sortOrder: m[k.SORT_ORDER] || 0,
124
+ parentId: m[k.PARENT_ID] || 0,
125
+ parentName: m.parent?.name,
126
+ isActive: Boolean(m[RBAC_CONSTANTS.masters.IS_ACTIVE] ?? m.is_active ?? true),
127
+ createdAt: m[RBAC_CONSTANTS.masters.CREATED_AT],
128
+ }));
129
+
130
+ return { items, total, page, limit };
131
+ },
132
+ };
@@ -0,0 +1,90 @@
1
+ import { RBAC_CONSTANTS } from '../../constants';
2
+ import { PermissionsAdapter, StandardAction, StandardModuleWithActions, PaginatedResult } from '../types';
3
+
4
+ export const defaultPermissionsAdapter: PermissionsAdapter = {
5
+ transformActionsListResponse: (raw: any): StandardAction[] => {
6
+ const dataKey = RBAC_CONSTANTS.api.DATA;
7
+ const root = raw?.[dataKey] || raw || [];
8
+ const items = Array.isArray(root) ? root : root.list || [];
9
+
10
+ return items.map((a: any) => ({
11
+ id: a[RBAC_CONSTANTS.permissions.ACTION_ID] ?? a.id,
12
+ name: a[RBAC_CONSTANTS.permissions.ACTION_NAME] ?? a.name,
13
+ key: a[RBAC_CONSTANTS.permissions.ACTION_KEY] ?? a.key,
14
+ isActive: Boolean(a[RBAC_CONSTANTS.permissions.IS_ACTIVE] ?? true),
15
+ sortOrder: a.sort_order ?? 0,
16
+ }));
17
+ },
18
+
19
+ transformRegistryResponse: (raw: any): PaginatedResult<StandardModuleWithActions> => {
20
+ const permKeys = RBAC_CONSTANTS.permissions;
21
+ const dataKey = RBAC_CONSTANTS.api.DATA;
22
+ const pagKeys = RBAC_CONSTANTS.pagination;
23
+
24
+ let itemsRaw: any[] = [];
25
+ let total = 0;
26
+ let page = 1;
27
+ let limit = 10;
28
+
29
+ const root = raw?.[dataKey] ?? raw;
30
+ if (Array.isArray(root)) {
31
+ itemsRaw = root;
32
+ total = root.length;
33
+ } else if (root && typeof root === 'object') {
34
+ itemsRaw = root[pagKeys.ITEMS] || root.items || [];
35
+ total = root[pagKeys.TOTAL] ?? itemsRaw.length;
36
+ page = root[pagKeys.PAGE] ?? 1;
37
+ limit = root[pagKeys.LIMIT] ?? 10;
38
+ }
39
+
40
+ const mapModule = (m: any): StandardModuleWithActions => {
41
+ const rawActions = m[permKeys.ACTIONS_ARRAY] || [];
42
+ const actions = Array.isArray(rawActions)
43
+ ? rawActions.map((pa: any) => {
44
+ const ad = pa[permKeys.ACTION_DETAILS] || {};
45
+ return {
46
+ permissionActionId: pa[permKeys.PERMISSION_ACTION_ID] ?? pa.id,
47
+ moduleId: pa[permKeys.PERMISSION_MASTER_ID] ?? m[permKeys.PERMISSION_MASTER_ID],
48
+ actionId: pa[permKeys.ACTION_ID] ?? ad[permKeys.ACTION_ID],
49
+ permissionKey: pa[permKeys.PERMISSION_KEY] || '',
50
+ routePath: pa[permKeys.ROUTE_PATH],
51
+ description: pa.description,
52
+ sortOrder: pa[permKeys.SORT_ORDER] || 0,
53
+ actionDetails: {
54
+ id: ad[permKeys.ACTION_ID] ?? pa[permKeys.ACTION_ID] ?? 0,
55
+ name: ad[permKeys.ACTION_NAME] || pa[permKeys.ACTION_NAME] || '',
56
+ key: ad[permKeys.ACTION_KEY] || pa[permKeys.ACTION_KEY] || '',
57
+ isActive: Boolean(ad[permKeys.IS_ACTIVE] ?? true),
58
+ },
59
+ };
60
+ })
61
+ : [];
62
+
63
+ const rawChildren = m[permKeys.CHILDREN_ARRAY] || m[permKeys.SUBMODULES_ARRAY] || [];
64
+ const children = Array.isArray(rawChildren) ? rawChildren.map(mapModule) : [];
65
+
66
+ return {
67
+ id: m[permKeys.PERMISSION_MASTER_ID] ?? m.id,
68
+ name: m[permKeys.MODULE_NAME] || '',
69
+ slug: m[permKeys.SLUG_NAME] || '',
70
+ routePath: m[permKeys.ROUTE_PATH],
71
+ sortOrder: m[permKeys.SORT_ORDER] || 0,
72
+ parentId: m[permKeys.PARENT_ID] || 0,
73
+ isActive: Boolean(m[permKeys.IS_ACTIVE] ?? true),
74
+ actions,
75
+ children,
76
+ };
77
+ };
78
+
79
+ const items = itemsRaw.map(mapModule);
80
+ return { items, total, page, limit };
81
+ },
82
+
83
+ transformModuleParentsResponse: (raw: any): PaginatedResult<StandardModuleWithActions> => {
84
+ return defaultPermissionsAdapter.transformRegistryResponse(raw);
85
+ },
86
+
87
+ transformModuleSubmodulesResponse: (raw: any): PaginatedResult<StandardModuleWithActions> => {
88
+ return defaultPermissionsAdapter.transformRegistryResponse(raw);
89
+ },
90
+ };
@@ -0,0 +1,115 @@
1
+ import { RBAC_CONSTANTS } from '../../constants';
2
+ import { RolesAdapter, StandardRole, PaginatedResult, RoleFormData } from '../types';
3
+
4
+ export const defaultRolesAdapter: RolesAdapter = {
5
+ transformRoleListResponse: (raw: any): PaginatedResult<StandardRole> => {
6
+ const roleKeys = RBAC_CONSTANTS.roles;
7
+ const dataKey = RBAC_CONSTANTS.api.DATA;
8
+ const pagKeys = RBAC_CONSTANTS.pagination;
9
+
10
+ let itemsRaw: any[] = [];
11
+ let total = 0;
12
+ let page = 1;
13
+ let limit = 10;
14
+
15
+ const root = raw?.[dataKey] ?? raw;
16
+
17
+ if (Array.isArray(root)) {
18
+ itemsRaw = root;
19
+ total = root.length;
20
+ } else if (root && typeof root === 'object') {
21
+ itemsRaw = root[pagKeys.ITEMS] || root.roles || root.items || root.rows || [];
22
+ total = root[pagKeys.TOTAL] ?? itemsRaw.length;
23
+ page = root[pagKeys.PAGE] ?? 1;
24
+ limit = root[pagKeys.LIMIT] ?? 10;
25
+ }
26
+
27
+ const items: StandardRole[] = itemsRaw.map((r: any) => {
28
+ const entityObj = r[roleKeys.ENTITY_OBJECT];
29
+ const deptObj = r[roleKeys.DEPARTMENT_OBJECT];
30
+
31
+ return {
32
+ id: r[roleKeys.ID] ?? r[roleKeys.ROLE_ID],
33
+ name: r[roleKeys.ROLE_NAME] || '',
34
+ key: r[roleKeys.ROLE_KEY] || '',
35
+ description: r[roleKeys.DESCRIPTION],
36
+ isActive: Boolean(r[roleKeys.IS_ACTIVE] ?? true),
37
+ entityId: r[roleKeys.ENTITY_ID] ?? entityObj?.[RBAC_CONSTANTS.masters.ENTITY.ID],
38
+ entityName: entityObj?.[roleKeys.ENTITY_NAME] ?? entityObj?.name,
39
+ departmentId: r[roleKeys.DEPARTMENT_ID] ?? deptObj?.[RBAC_CONSTANTS.masters.DEPARTMENT.ID],
40
+ departmentName: deptObj?.[roleKeys.DEPARTMENT_NAME] ?? deptObj?.name,
41
+ createdAt: r[roleKeys.CREATED_AT],
42
+ updatedAt: r[roleKeys.UPDATED_AT],
43
+ };
44
+ });
45
+
46
+ return { items, total, page, limit };
47
+ },
48
+
49
+ transformRoleDetailsResponse: (raw: any): StandardRole => {
50
+ const roleKeys = RBAC_CONSTANTS.roles;
51
+ const dataKey = RBAC_CONSTANTS.api.DATA;
52
+ const r = raw?.[dataKey] || raw || {};
53
+
54
+ return {
55
+ id: r[roleKeys.ID] ?? r[roleKeys.ROLE_ID],
56
+ name: r[roleKeys.ROLE_NAME] || '',
57
+ key: r[roleKeys.ROLE_KEY] || '',
58
+ description: r[roleKeys.DESCRIPTION],
59
+ isActive: Boolean(r[roleKeys.IS_ACTIVE] ?? true),
60
+ entityId: r[roleKeys.ENTITY_ID],
61
+ departmentId: r[roleKeys.DEPARTMENT_ID],
62
+ createdAt: r[roleKeys.CREATED_AT],
63
+ updatedAt: r[roleKeys.UPDATED_AT],
64
+ };
65
+ },
66
+
67
+ transformCreateRolePayload: (data: RoleFormData): Record<string, any> => {
68
+ const roleKeys = RBAC_CONSTANTS.roles;
69
+ return {
70
+ [roleKeys.ROLE_NAME]: data.role_name,
71
+ [roleKeys.ROLE_KEY]: data.role_key,
72
+ [roleKeys.DESCRIPTION]: data.description || '',
73
+ ...(data.entityId ? { [roleKeys.ENTITY_ID]: data.entityId } : {}),
74
+ ...(data.departmentId ? { [roleKeys.DEPARTMENT_ID]: data.departmentId } : {}),
75
+ [roleKeys.IS_ACTIVE]: data.is_active ?? true,
76
+ };
77
+ },
78
+
79
+ transformEditRolePayload: (data: Partial<RoleFormData>): Record<string, any> => {
80
+ const roleKeys = RBAC_CONSTANTS.roles;
81
+ const payload: Record<string, any> = {};
82
+ if (data.role_name !== undefined) payload[roleKeys.ROLE_NAME] = data.role_name;
83
+ if (data.role_key !== undefined) payload[roleKeys.ROLE_KEY] = data.role_key;
84
+ if (data.description !== undefined) payload[roleKeys.DESCRIPTION] = data.description;
85
+ if (data.entityId !== undefined) payload[roleKeys.ENTITY_ID] = data.entityId;
86
+ if (data.departmentId !== undefined) payload[roleKeys.DEPARTMENT_ID] = data.departmentId;
87
+ if (data.is_active !== undefined) payload[roleKeys.IS_ACTIVE] = data.is_active;
88
+ return payload;
89
+ },
90
+
91
+ transformRolePermissionsResponse: (raw: any): (string | number)[] => {
92
+ const dataKey = RBAC_CONSTANTS.api.DATA;
93
+ const root = raw?.[dataKey] || raw || {};
94
+ const permissions = root[RBAC_CONSTANTS.roles.ASSIGNED_PERMISSIONS_ARRAY] || root.permissionActionIds || root;
95
+
96
+ if (Array.isArray(permissions)) {
97
+ return permissions.map((p: any) => {
98
+ if (typeof p === 'number' || typeof p === 'string') return p;
99
+ return (
100
+ p[RBAC_CONSTANTS.permissions.PERMISSION_ACTION_ID] ??
101
+ p[RBAC_CONSTANTS.permissions.ACTION_ID] ??
102
+ p[RBAC_CONSTANTS.permissions.PERMISSION_KEY_ID] ??
103
+ p.id
104
+ );
105
+ });
106
+ }
107
+ return [];
108
+ },
109
+
110
+ transformAssignPermissionsPayload: (_roleId: string | number, permissionActionIds: (string | number)[]) => {
111
+ return {
112
+ [RBAC_CONSTANTS.roles.ASSIGNED_ACTION_IDS_PAYLOAD_KEY]: permissionActionIds,
113
+ };
114
+ },
115
+ };
@@ -0,0 +1,296 @@
1
+ export interface RequestOptions extends RequestInit {
2
+ params?: Record<string, string | number | boolean | undefined | null>;
3
+ skipAuthRedirect?: boolean;
4
+ skipTokenRefresh?: boolean;
5
+ }
6
+
7
+ export function extractFieldErrors(data: any): Record<string, string> | undefined {
8
+ if (!data || typeof data !== 'object') return undefined;
9
+
10
+ // Pattern 1: { fieldErrors: { email: "taken" } }
11
+ if (data.fieldErrors && typeof data.fieldErrors === 'object' && !Array.isArray(data.fieldErrors)) {
12
+ return data.fieldErrors;
13
+ }
14
+
15
+ // Pattern 2: { errors: { email: "taken" | ["taken", "invalid"] } }
16
+ if (data.errors && typeof data.errors === 'object' && !Array.isArray(data.errors)) {
17
+ const res: Record<string, string> = {};
18
+ for (const [k, v] of Object.entries(data.errors)) {
19
+ res[k] = Array.isArray(v) ? String(v[0]) : String(v);
20
+ }
21
+ return res;
22
+ }
23
+
24
+ // Pattern 3: { errors: [{ field: "email", message: "taken" }] } or [{ path: "email", msg: "taken" }]
25
+ if (Array.isArray(data.errors)) {
26
+ const res: Record<string, string> = {};
27
+ for (const item of data.errors) {
28
+ const key = item.field || item.path || item.param || item.key;
29
+ const msg = item.message || item.msg || item.error;
30
+ if (key && msg) res[key] = String(msg);
31
+ }
32
+ return Object.keys(res).length > 0 ? res : undefined;
33
+ }
34
+
35
+ // Pattern 4: { validationErrors: { ... } }
36
+ if (data.validationErrors && typeof data.validationErrors === 'object') {
37
+ const res: Record<string, string> = {};
38
+ for (const [k, v] of Object.entries(data.validationErrors)) {
39
+ res[k] = Array.isArray(v) ? String(v[0]) : String(v);
40
+ }
41
+ return res;
42
+ }
43
+
44
+ return undefined;
45
+ }
46
+
47
+ export class HttpClient {
48
+ private baseUrl: string;
49
+ private getToken?: () => string | null | undefined;
50
+ private getTenantId?: () => string | number | null | undefined;
51
+ private getCustomHeaders?: () => Record<string, string>;
52
+ private onUnauthorized?: () => void;
53
+ private refreshTokenFn?: () => Promise<string | null | undefined>;
54
+
55
+ private isRefreshing = false;
56
+ private refreshSubscribers: Array<(token: string | null) => void> = [];
57
+
58
+ constructor(options: {
59
+ baseUrl: string;
60
+ getToken?: () => string | null | undefined;
61
+ getTenantId?: () => string | number | null | undefined;
62
+ getCustomHeaders?: () => Record<string, string>;
63
+ onUnauthorized?: () => void;
64
+ refreshToken?: () => Promise<string | null | undefined>;
65
+ }) {
66
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
67
+ this.getToken = options.getToken;
68
+ this.getTenantId = options.getTenantId;
69
+ this.getCustomHeaders = options.getCustomHeaders;
70
+ this.onUnauthorized = options.onUnauthorized;
71
+ this.refreshTokenFn = options.refreshToken;
72
+ }
73
+
74
+ public setBaseUrl(url: string) {
75
+ this.baseUrl = url.replace(/\/$/, "");
76
+ }
77
+
78
+ private subscribeTokenRefresh(cb: (token: string | null) => void) {
79
+ this.refreshSubscribers.push(cb);
80
+ }
81
+
82
+ private onTokenRefreshed(token: string | null) {
83
+ this.refreshSubscribers.forEach((cb) => cb(token));
84
+ this.refreshSubscribers = [];
85
+ }
86
+
87
+ private buildUrl(path: string, params?: Record<string, any>): string {
88
+ const cleanPath = path.startsWith("/") ? path : `/${path}`;
89
+ const url = new URL(`${this.baseUrl}${cleanPath}`);
90
+
91
+ if (params) {
92
+ Object.entries(params).forEach(([key, value]) => {
93
+ if (value !== undefined && value !== null && value !== '') {
94
+ url.searchParams.append(key, String(value));
95
+ }
96
+ });
97
+ }
98
+
99
+ return url.toString();
100
+ }
101
+
102
+ public async request<T = any>(
103
+ path: string,
104
+ options: RequestOptions = {},
105
+ ): Promise<T> {
106
+ const url = this.buildUrl(path, options.params);
107
+ const headers: Record<string, string> = {
108
+ ...((options.headers as Record<string, string>) || {}),
109
+ };
110
+
111
+ const isFormData =
112
+ typeof FormData !== "undefined" && options.body instanceof FormData;
113
+ if (!isFormData && !headers["Content-Type"]) {
114
+ headers["Content-Type"] = "application/json";
115
+ } else if (isFormData) {
116
+ delete headers["Content-Type"]; // Let browser generate boundary
117
+ }
118
+
119
+ // Inject custom headers if configured
120
+ if (this.getCustomHeaders) {
121
+ Object.assign(headers, this.getCustomHeaders());
122
+ }
123
+
124
+ // Inject tenant/entity identifier if configured
125
+ if (this.getTenantId) {
126
+ const tenantId = this.getTenantId();
127
+ if (tenantId) {
128
+ headers["X-Tenant-Id"] = String(tenantId);
129
+ headers["X-Entity-Id"] = String(tenantId);
130
+ }
131
+ }
132
+
133
+ // Inject Bearer token
134
+ const token = this.getToken ? this.getToken() : null;
135
+ if (token && !headers["Authorization"]) {
136
+ headers["Authorization"] = `Bearer ${token}`;
137
+ }
138
+
139
+ let response: Response;
140
+ try {
141
+ response = await fetch(url, {
142
+ ...options,
143
+ headers,
144
+ credentials: options.credentials || 'include',
145
+ });
146
+ } catch (networkError: any) {
147
+ throw new Error(
148
+ `Network error: ${networkError.message || "Failed to fetch"}`,
149
+ );
150
+ }
151
+
152
+ // Handle 401 Unauthorized with Silent Token Refresh
153
+ if (response.status === 401 && !options.skipAuthRedirect) {
154
+ if (this.refreshTokenFn && !options.skipTokenRefresh) {
155
+ if (!this.isRefreshing) {
156
+ this.isRefreshing = true;
157
+ try {
158
+ const newToken = await this.refreshTokenFn();
159
+ this.isRefreshing = false;
160
+ this.onTokenRefreshed(newToken || null);
161
+
162
+ if (newToken) {
163
+ // Retry original request with newly refreshed token
164
+ headers["Authorization"] = `Bearer ${newToken}`;
165
+ return this.request<T>(path, {
166
+ ...options,
167
+ headers,
168
+ skipTokenRefresh: true,
169
+ });
170
+ }
171
+ } catch (refreshErr) {
172
+ this.isRefreshing = false;
173
+ this.onTokenRefreshed(null);
174
+ if (this.onUnauthorized) this.onUnauthorized();
175
+ throw refreshErr;
176
+ }
177
+ } else {
178
+ // Another request is already refreshing the token; wait for it
179
+ return new Promise<T>((resolve, reject) => {
180
+ this.subscribeTokenRefresh((newToken) => {
181
+ if (newToken) {
182
+ headers["Authorization"] = `Bearer ${newToken}`;
183
+ resolve(
184
+ this.request<T>(path, {
185
+ ...options,
186
+ headers,
187
+ skipTokenRefresh: true,
188
+ }),
189
+ );
190
+ } else {
191
+ if (this.onUnauthorized) this.onUnauthorized();
192
+ reject(new Error("Session expired"));
193
+ }
194
+ });
195
+ });
196
+ }
197
+ }
198
+
199
+ // If no refreshToken handler, trigger unauthorized callback
200
+ if (this.onUnauthorized) {
201
+ this.onUnauthorized();
202
+ }
203
+ }
204
+
205
+ const data = await response.json().catch(() => null);
206
+
207
+ if (!response.ok) {
208
+ const errorMsg =
209
+ data?.message ||
210
+ data?.error ||
211
+ `Request failed with status ${response.status}`;
212
+ const error: any = new Error(errorMsg);
213
+ error.status = response.status;
214
+ error.data = data;
215
+ // Extract field-level validation errors if returned by backend
216
+ error.fieldErrors = extractFieldErrors(data);
217
+ throw error;
218
+ }
219
+
220
+ return data as T;
221
+ }
222
+
223
+ public get<T = any>(
224
+ path: string,
225
+ params?: Record<string, any>,
226
+ options?: RequestOptions,
227
+ ): Promise<T> {
228
+ return this.request<T>(path, { ...options, method: "GET", params });
229
+ }
230
+
231
+ public post<T = any>(
232
+ path: string,
233
+ body?: any,
234
+ options?: RequestOptions,
235
+ ): Promise<T> {
236
+ const isFormData =
237
+ typeof FormData !== "undefined" && body instanceof FormData;
238
+ return this.request<T>(path, {
239
+ ...options,
240
+ method: "POST",
241
+ body: isFormData
242
+ ? body
243
+ : body !== undefined
244
+ ? JSON.stringify(body)
245
+ : undefined,
246
+ });
247
+ }
248
+
249
+ public patch<T = any>(
250
+ path: string,
251
+ body?: any,
252
+ options?: RequestOptions,
253
+ ): Promise<T> {
254
+ const isFormData =
255
+ typeof FormData !== "undefined" && body instanceof FormData;
256
+ return this.request<T>(path, {
257
+ ...options,
258
+ method: "PATCH",
259
+ body: isFormData
260
+ ? body
261
+ : body !== undefined
262
+ ? JSON.stringify(body)
263
+ : undefined,
264
+ });
265
+ }
266
+
267
+ public put<T = any>(
268
+ path: string,
269
+ body?: any,
270
+ options?: RequestOptions,
271
+ ): Promise<T> {
272
+ const isFormData =
273
+ typeof FormData !== "undefined" && body instanceof FormData;
274
+ return this.request<T>(path, {
275
+ ...options,
276
+ method: "PUT",
277
+ body: isFormData
278
+ ? body
279
+ : body !== undefined
280
+ ? JSON.stringify(body)
281
+ : undefined,
282
+ });
283
+ }
284
+
285
+ public delete<T = any>(path: string, options?: RequestOptions): Promise<T> {
286
+ return this.request<T>(path, { ...options, method: "DELETE" });
287
+ }
288
+
289
+ public upload<T = any>(path: string, formData: FormData, options?: RequestOptions): Promise<T> {
290
+ return this.request<T>(path, {
291
+ ...options,
292
+ method: 'POST',
293
+ body: formData,
294
+ });
295
+ }
296
+ }