@jmruthers/pace-core 0.5.108 → 0.5.110

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 (195) hide show
  1. package/CHANGELOG.md +93 -173
  2. package/dist/{AuthService-1D2ifNfa.d.ts → AuthService-DrHrvXNZ.d.ts} +8 -1
  3. package/dist/{DataTable-WFCHVWTY.js → DataTable-D3BK2FCN.js} +7 -7
  4. package/dist/{UnifiedAuthProvider-XU4BHFXZ.js → UnifiedAuthProvider-A7I23UCN.js} +3 -3
  5. package/dist/{api-KG4A2X7P.js → api-PIE4JRFS.js} +2 -2
  6. package/dist/{chunk-DMNMZKWS.js → chunk-2W4WKJVF.js} +4 -4
  7. package/dist/{chunk-B3QX32P5.js → chunk-3J5N2T2N.js} +85 -28
  8. package/dist/chunk-3J5N2T2N.js.map +1 -0
  9. package/dist/{chunk-MOMYOQMC.js → chunk-7GBEBJLR.js} +29 -37
  10. package/dist/chunk-7GBEBJLR.js.map +1 -0
  11. package/dist/{chunk-X4FRXJV6.js → chunk-AUXS7XSO.js} +57 -6
  12. package/dist/{chunk-X4FRXJV6.js.map → chunk-AUXS7XSO.js.map} +1 -1
  13. package/dist/{chunk-VJ7MPS2K.js → chunk-AWK2FAUN.js} +6 -6
  14. package/dist/{chunk-LT6RKRA7.js → chunk-D6MEKC27.js} +2 -2
  15. package/dist/{chunk-KBG34SVL.js → chunk-EYSXQ756.js} +2 -2
  16. package/dist/{chunk-ZXY5NTJB.js → chunk-EZ64QG2I.js} +2 -2
  17. package/dist/chunk-GZRXOUBE.js +176 -0
  18. package/dist/chunk-GZRXOUBE.js.map +1 -0
  19. package/dist/{chunk-QDDUU625.js → chunk-HADXAZT3.js} +4 -4
  20. package/dist/{chunk-IMZGJ2X7.js → chunk-HGZSO43Y.js} +4 -4
  21. package/dist/{chunk-S63MFSY6.js → chunk-XRSP3H52.js} +15 -8
  22. package/dist/chunk-XRSP3H52.js.map +1 -0
  23. package/dist/{chunk-GVRSXXAA.js → chunk-YFMENCR4.js} +3 -3
  24. package/dist/components.js +9 -9
  25. package/dist/{database-BXAfr2Y_.d.ts → database-C6jy7EOu.d.ts} +21 -9
  26. package/dist/{formatting-BiEv5oEk.d.ts → formatting-B1jSqgl-.d.ts} +16 -1
  27. package/dist/hooks.d.ts +2 -2
  28. package/dist/hooks.js +7 -7
  29. package/dist/index.d.ts +6 -6
  30. package/dist/index.js +16 -14
  31. package/dist/index.js.map +1 -1
  32. package/dist/providers.d.ts +4 -3
  33. package/dist/providers.js +2 -2
  34. package/dist/rbac/index.d.ts +35 -23
  35. package/dist/rbac/index.js +8 -8
  36. package/dist/types.d.ts +2 -2
  37. package/dist/{usePublicRouteParams-CnM-IK2I.d.ts → usePublicRouteParams-BdF8bZgs.d.ts} +1 -1
  38. package/dist/utils.d.ts +2 -15
  39. package/dist/utils.js +4 -145
  40. package/dist/utils.js.map +1 -1
  41. package/dist/validation.d.ts +1 -1
  42. package/docs/api/classes/ColumnFactory.md +1 -1
  43. package/docs/api/classes/ErrorBoundary.md +1 -1
  44. package/docs/api/classes/InvalidScopeError.md +1 -1
  45. package/docs/api/classes/MissingUserContextError.md +1 -1
  46. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  47. package/docs/api/classes/PermissionDeniedError.md +1 -1
  48. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  49. package/docs/api/classes/RBACAuditManager.md +1 -1
  50. package/docs/api/classes/RBACCache.md +1 -1
  51. package/docs/api/classes/RBACEngine.md +9 -8
  52. package/docs/api/classes/RBACError.md +1 -1
  53. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  54. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  55. package/docs/api/classes/StorageUtils.md +1 -1
  56. package/docs/api/enums/FileCategory.md +1 -1
  57. package/docs/api/interfaces/AggregateConfig.md +1 -1
  58. package/docs/api/interfaces/ButtonProps.md +1 -1
  59. package/docs/api/interfaces/CardProps.md +1 -1
  60. package/docs/api/interfaces/ColorPalette.md +1 -1
  61. package/docs/api/interfaces/ColorShade.md +1 -1
  62. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  63. package/docs/api/interfaces/DataRecord.md +1 -1
  64. package/docs/api/interfaces/DataTableAction.md +1 -1
  65. package/docs/api/interfaces/DataTableColumn.md +3 -3
  66. package/docs/api/interfaces/DataTableProps.md +1 -1
  67. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  68. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  69. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  70. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  71. package/docs/api/interfaces/FileMetadata.md +1 -1
  72. package/docs/api/interfaces/FileReference.md +1 -1
  73. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  74. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  75. package/docs/api/interfaces/FileUploadProps.md +1 -1
  76. package/docs/api/interfaces/FooterProps.md +1 -1
  77. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  78. package/docs/api/interfaces/InputProps.md +1 -1
  79. package/docs/api/interfaces/LabelProps.md +1 -1
  80. package/docs/api/interfaces/LoginFormProps.md +1 -1
  81. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  82. package/docs/api/interfaces/NavigationContextType.md +1 -1
  83. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  84. package/docs/api/interfaces/NavigationItem.md +1 -1
  85. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  86. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  87. package/docs/api/interfaces/Organisation.md +1 -1
  88. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  89. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  90. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  91. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  92. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  93. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  94. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  95. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  96. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  97. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  98. package/docs/api/interfaces/PaletteData.md +1 -1
  99. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  100. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  101. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  102. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  103. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  104. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  105. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  106. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  107. package/docs/api/interfaces/RBACConfig.md +19 -8
  108. package/docs/api/interfaces/RBACLogger.md +5 -5
  109. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  110. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  111. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  112. package/docs/api/interfaces/RouteConfig.md +1 -1
  113. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  114. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  115. package/docs/api/interfaces/StorageConfig.md +1 -1
  116. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  117. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  118. package/docs/api/interfaces/StorageListOptions.md +1 -1
  119. package/docs/api/interfaces/StorageListResult.md +1 -1
  120. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  121. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  122. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  123. package/docs/api/interfaces/StyleImport.md +1 -1
  124. package/docs/api/interfaces/SwitchProps.md +1 -1
  125. package/docs/api/interfaces/ToastActionElement.md +1 -1
  126. package/docs/api/interfaces/ToastProps.md +1 -1
  127. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  128. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  129. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  130. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  131. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  132. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  133. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  134. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  135. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  136. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  137. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  138. package/docs/api/interfaces/UserEventAccess.md +1 -1
  139. package/docs/api/interfaces/UserMenuProps.md +1 -1
  140. package/docs/api/interfaces/UserProfile.md +1 -1
  141. package/docs/api/modules.md +55 -20
  142. package/docs/api-reference/hooks.md +53 -0
  143. package/docs/api-reference/providers.md +60 -0
  144. package/docs/core-concepts/authentication.md +2 -0
  145. package/docs/documentation-index.md +0 -2
  146. package/docs/implementation-guides/authentication.md +1 -0
  147. package/docs/rbac/README.md +114 -38
  148. package/docs/rbac/api-reference.md +63 -16
  149. package/docs/rbac/getting-started.md +16 -16
  150. package/docs/rbac/quick-start.md +110 -35
  151. package/docs/rbac/troubleshooting.md +125 -2
  152. package/docs/security/README.md +59 -0
  153. package/package.json +1 -1
  154. package/src/components/NavigationMenu/NavigationMenu.test.tsx +38 -4
  155. package/src/components/NavigationMenu/NavigationMenu.tsx +71 -6
  156. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +2 -2
  157. package/src/components/PaceAppLayout/PaceAppLayout.tsx +48 -16
  158. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +2 -1
  159. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +9 -9
  160. package/src/index.ts +3 -0
  161. package/src/providers/services/AuthServiceProvider.tsx +4 -3
  162. package/src/providers/services/UnifiedAuthProvider.tsx +1 -1
  163. package/src/rbac/api.test.ts +2 -2
  164. package/src/rbac/api.ts +2 -1
  165. package/src/rbac/components/PagePermissionGuard.tsx +21 -38
  166. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1 -1
  167. package/src/rbac/config.ts +2 -0
  168. package/src/rbac/engine.ts +17 -5
  169. package/src/rbac/security.ts +1 -1
  170. package/src/services/AuthService.ts +79 -1
  171. package/src/services/__tests__/AuthService.test.ts +184 -0
  172. package/src/types/database.ts +21 -9
  173. package/src/types/rbac-functions.ts +2 -1
  174. package/src/utils/__tests__/sessionTracking.unit.test.ts +6 -171
  175. package/src/utils/sessionTracking.ts +7 -81
  176. package/dist/chunk-B3QX32P5.js.map +0 -1
  177. package/dist/chunk-MOMYOQMC.js.map +0 -1
  178. package/dist/chunk-NFPV7MRN.js +0 -94
  179. package/dist/chunk-NFPV7MRN.js.map +0 -1
  180. package/dist/chunk-S63MFSY6.js.map +0 -1
  181. package/docs/rbac/breaking-changes-v3.md +0 -222
  182. package/docs/rbac/migration-guide.md +0 -260
  183. package/src/providers/AuthProvider.simplified.tsx +0 -974
  184. package/dist/{DataTable-WFCHVWTY.js.map → DataTable-D3BK2FCN.js.map} +0 -0
  185. package/dist/{UnifiedAuthProvider-XU4BHFXZ.js.map → UnifiedAuthProvider-A7I23UCN.js.map} +0 -0
  186. package/dist/{api-KG4A2X7P.js.map → api-PIE4JRFS.js.map} +0 -0
  187. package/dist/{chunk-DMNMZKWS.js.map → chunk-2W4WKJVF.js.map} +0 -0
  188. package/dist/{chunk-VJ7MPS2K.js.map → chunk-AWK2FAUN.js.map} +0 -0
  189. package/dist/{chunk-LT6RKRA7.js.map → chunk-D6MEKC27.js.map} +0 -0
  190. package/dist/{chunk-KBG34SVL.js.map → chunk-EYSXQ756.js.map} +0 -0
  191. package/dist/{chunk-ZXY5NTJB.js.map → chunk-EZ64QG2I.js.map} +0 -0
  192. package/dist/{chunk-QDDUU625.js.map → chunk-HADXAZT3.js.map} +0 -0
  193. package/dist/{chunk-IMZGJ2X7.js.map → chunk-HGZSO43Y.js.map} +0 -0
  194. package/dist/{chunk-GVRSXXAA.js.map → chunk-YFMENCR4.js.map} +0 -0
  195. package/dist/{validation-D8VcbTzC.d.ts → validation-DnhrNMju.d.ts} +2 -2
@@ -462,10 +462,16 @@ export const NavigationMenu = React.forwardRef<
462
462
  return (items || []).filter(item => !item.meta?.hidden);
463
463
  }
464
464
 
465
- return (items || []).filter(item => {
466
- // Check if item should be hidden
467
- if (item.meta?.hidden) return false;
468
-
465
+ // Helper function to derive page ID from href
466
+ const getPageIdFromHref = (href?: string): string | null => {
467
+ if (!href) return null;
468
+ // Remove leading slash and any query params/hash
469
+ const path = href.split('?')[0].split('#')[0].replace(/^\//, '');
470
+ return path || 'home';
471
+ };
472
+
473
+ // Helper function to check if item has permission to be shown
474
+ const hasItemPermission = (item: NavigationItem): boolean => {
469
475
  // Check permissions if available
470
476
  if (item.permissions && item.permissions.length > 0) {
471
477
  // Convert string permissions to Permission type and check
@@ -547,8 +553,66 @@ export const NavigationMenu = React.forwardRef<
547
553
  }
548
554
  }
549
555
 
556
+ // NEW: Auto-check page permissions for items with href but no explicit permissions
557
+ // If item has an href but no explicit permissions/roles/accessLevel, check page permission
558
+ if (item.href && !item.permissions && !item.roles && !item.accessLevel) {
559
+ const pageId = item.pageId || getPageIdFromHref(item.href);
560
+ if (pageId) {
561
+ // Check for read permission on the page
562
+ const pagePermission: Permission = `read:page.${pageId}` as Permission;
563
+
564
+ // Check permission map (super admin has access to everything via '*' key)
565
+ const hasPagePermission = permissionMap['*'] === true || permissionMap[pagePermission] === true;
566
+
567
+ if (!hasPagePermission) {
568
+ if (auditLog) {
569
+ console.log(`[NavigationMenu] Filtering out navigation item "${item.label}" - no page permission:`, {
570
+ itemId: item.id,
571
+ href: item.href,
572
+ pageId,
573
+ permission: pagePermission,
574
+ hasPermission: hasPagePermission
575
+ });
576
+ }
577
+ return false;
578
+ }
579
+ }
580
+ }
581
+
550
582
  return true;
551
- });
583
+ };
584
+
585
+ // Helper function to filter items recursively (creates new objects to avoid mutations)
586
+ const filterItem = (item: NavigationItem): NavigationItem | null => {
587
+ // Check if item should be hidden
588
+ if (item.meta?.hidden) return null;
589
+
590
+ // Check if item has permission
591
+ if (!hasItemPermission(item)) return null;
592
+
593
+ // Recursively filter children if present
594
+ let filteredChildren: NavigationItem[] | undefined;
595
+ if (item.children && item.children.length > 0) {
596
+ filteredChildren = item.children
597
+ .map(child => filterItem(child))
598
+ .filter((child): child is NavigationItem => child !== null);
599
+
600
+ // If parent has no accessible children, hide the parent too (unless it has its own href)
601
+ if (filteredChildren.length === 0 && !item.href) {
602
+ return null;
603
+ }
604
+ }
605
+
606
+ // Return filtered item (with filtered children if applicable)
607
+ return {
608
+ ...item,
609
+ children: filteredChildren
610
+ };
611
+ };
612
+
613
+ return (items || [])
614
+ .map(item => filterItem(item))
615
+ .filter((item): item is NavigationItem => item !== null);
552
616
  }, [
553
617
  items,
554
618
  filterByPermissions,
@@ -558,7 +622,8 @@ export const NavigationMenu = React.forwardRef<
558
622
  hasAnyPermission,
559
623
  scopeLoading,
560
624
  permissionsLoading,
561
- resolvedScope
625
+ resolvedScope,
626
+ auditLog
562
627
  ]);
563
628
 
564
629
  // Log navigation access attempts for debugging
@@ -540,7 +540,7 @@ describe('PaceAppLayout Component', () => {
540
540
  eventId: 'event-123',
541
541
  appId: 'app-123',
542
542
  },
543
- permission: 'update',
543
+ permission: 'update:page.dashboard-page',
544
544
  pageId: 'dashboard-page',
545
545
  });
546
546
  }, { timeout: 3000 });
@@ -571,7 +571,7 @@ describe('PaceAppLayout Component', () => {
571
571
  eventId: 'event-123',
572
572
  appId: 'app-123',
573
573
  },
574
- permission: 'create',
574
+ permission: 'create:page.dashboard',
575
575
  pageId: 'dashboard',
576
576
  });
577
577
  }, { timeout: 3000 });
@@ -395,10 +395,16 @@ export function PaceAppLayout({
395
395
  return false;
396
396
  }
397
397
 
398
+ // Construct the full permission string in format "operation:page.pageId"
399
+ // If permission already includes ':', use it as-is, otherwise format as "operation:page.pageId"
400
+ const fullPermission: Permission = permission.includes(':')
401
+ ? (permission as Permission)
402
+ : (pageId ? `${permission}:page.${pageId}` : permission) as Permission;
403
+
398
404
  return await isPermitted({
399
405
  userId: user.id,
400
406
  scope,
401
- permission: permission as Permission,
407
+ permission: fullPermission,
402
408
  pageId
403
409
  });
404
410
  } catch (error) {
@@ -577,30 +583,56 @@ export function PaceAppLayout({
577
583
  }
578
584
 
579
585
  // Organisation context is ready - now filter items based on permissions
580
- const filtered = await Promise.all(
581
- baseMenuItems.map(async (item) => {
586
+ // OPTIMIZATION: Use batch permission map instead of individual checks to avoid rate limits
587
+ // This makes 1 call instead of N calls (where N = number of navigation items)
588
+ try {
589
+ const { getPermissionMap } = await import('../../rbac/api');
590
+ const permissionMap = await getPermissionMap({
591
+ userId: user.id,
592
+ scope,
593
+ });
594
+
595
+ // Filter items using the permission map (synchronous, no rate limit issues)
596
+ const filtered = baseMenuItems.map((item) => {
582
597
  if (!item.href) return { item, hasAccess: true };
583
598
 
584
599
  const pageId = pageIdMapping[item.href] || item.href.slice(1) || 'home';
585
600
  const permission = routePermissions[item.href] || defaultPermission;
601
+ const fullPermission: Permission = permission.includes(':')
602
+ ? (permission as Permission)
603
+ : (pageId ? `${permission}:page.${pageId}` : permission) as Permission;
586
604
 
587
- try {
588
- const hasAccess = await checkPermission(permission, pageId);
589
- return { item, hasAccess };
590
- } catch {
591
- // On error, default to hiding the item (fail-safe)
592
- return { item, hasAccess: false };
605
+ // Check permission map (super admin check already handled in getPermissionMap)
606
+ const hasAccess = permissionMap['*'] === true || permissionMap[fullPermission] === true;
607
+
608
+ if (auditLog) {
609
+ console.log(`[PaceAppLayout] Navigation filtering:`, {
610
+ item: item.label,
611
+ href: item.href,
612
+ pageId,
613
+ permission: fullPermission,
614
+ hasAccess,
615
+ });
593
616
  }
594
- })
595
- );
617
+
618
+ return { item, hasAccess };
619
+ });
596
620
 
597
- if (!isMounted) return;
621
+ if (!isMounted) return;
598
622
 
599
- const accessibleItems = filtered
600
- .filter(({ hasAccess }) => hasAccess)
601
- .map(({ item }) => item);
623
+ const accessibleItems = filtered
624
+ .filter(({ hasAccess }) => hasAccess)
625
+ .map(({ item }) => item);
602
626
 
603
- setFilteredMenuItems(accessibleItems);
627
+ setFilteredMenuItems(accessibleItems);
628
+ } catch (error) {
629
+ // On error, fall back to showing all items (graceful degradation)
630
+ // This prevents navigation from being empty if permission checks fail
631
+ console.error('[PaceAppLayout] Failed to load permission map for navigation filtering:', error);
632
+ if (isMounted) {
633
+ setFilteredMenuItems(baseMenuItems);
634
+ }
635
+ }
604
636
  };
605
637
 
606
638
  filterItems();
@@ -715,7 +715,8 @@ describe('PaceAppLayout Security', () => {
715
715
  const { isPermitted } = await import('../../../rbac/api');
716
716
  vi.mocked(isPermitted).mockImplementation(({ userId, scope, permission, pageId }) => {
717
717
  // Simulate user trying to access admin with read permission
718
- if (pageId === 'admin' && permission === 'read') {
718
+ // Permission is now formatted as "operation:page.pageId"
719
+ if (pageId === 'admin' && permission === 'read:page.admin') {
719
720
  return Promise.resolve(false);
720
721
  }
721
722
  return Promise.resolve(true);
@@ -572,7 +572,7 @@ describe('PaceAppLayout Component', () => {
572
572
  expect(mockIsPermitted).toHaveBeenCalledWith({
573
573
  userId: 'test-user-id',
574
574
  scope: expect.objectContaining({ organisationId: 'test-org-123' }),
575
- permission: 'delete',
575
+ permission: 'delete:page.test-path',
576
576
  pageId: 'test-path'
577
577
  });
578
578
  });
@@ -597,7 +597,7 @@ describe('PaceAppLayout Component', () => {
597
597
  expect(mockIsPermitted).toHaveBeenCalledWith({
598
598
  userId: 'test-user-id',
599
599
  scope: expect.objectContaining({ organisationId: 'test-org-123' }),
600
- permission: 'read',
600
+ permission: 'read:page.custom-page-id',
601
601
  pageId: 'custom-page-id'
602
602
  });
603
603
  });
@@ -816,11 +816,8 @@ describe('PaceAppLayout Component', () => {
816
816
  });
817
817
 
818
818
  it('handles location changes correctly', async () => {
819
- // Change location
820
- Object.defineProperty(window, 'location', {
821
- value: { pathname: '/new-path' },
822
- writable: true
823
- });
819
+ // Change location - update the mockLocation object since component uses useLocation()
820
+ mockLocation.pathname = '/new-path';
824
821
 
825
822
  render(
826
823
  <TestWrapper>
@@ -832,10 +829,13 @@ describe('PaceAppLayout Component', () => {
832
829
  expect(mockIsPermitted).toHaveBeenCalledWith({
833
830
  userId: 'test-user-id',
834
831
  scope: expect.objectContaining({ organisationId: 'test-org-123' }),
835
- permission: 'read',
836
- pageId: 'test-path'
832
+ permission: 'read:page.new-path',
833
+ pageId: 'new-path'
837
834
  });
838
835
  });
836
+
837
+ // Reset for other tests
838
+ mockLocation.pathname = '/test-path';
839
839
  });
840
840
  });
841
841
 
package/src/index.ts CHANGED
@@ -20,6 +20,9 @@
20
20
  export { UnifiedAuthProvider, useUnifiedAuth } from './providers/UnifiedAuthProvider';
21
21
  export type { UnifiedAuthProviderProps, UnifiedAuthContextType, UserEventAccess } from './providers/UnifiedAuthProvider';
22
22
 
23
+ // Session tracking utility (for manual use if needed)
24
+ export { useSessionTracking } from './utils/sessionTracking';
25
+
23
26
  // Provider components (using service architecture)
24
27
  export { EventProvider } from './providers/EventProvider';
25
28
  export { OrganisationProvider } from './providers/OrganisationProvider';
@@ -24,13 +24,14 @@ export const AuthServiceContext = createContext<AuthServiceContextType | null>(n
24
24
  export interface AuthServiceProviderProps {
25
25
  children: React.ReactNode;
26
26
  supabaseClient: SupabaseClient;
27
+ appName?: string;
27
28
  }
28
29
 
29
- export function AuthServiceProvider({ children, supabaseClient }: AuthServiceProviderProps) {
30
+ export function AuthServiceProvider({ children, supabaseClient, appName }: AuthServiceProviderProps) {
30
31
  // Create service instance with useMemo to prevent recreation on every render
31
32
  const authService = useMemo(
32
- () => new AuthService(supabaseClient),
33
- [supabaseClient]
33
+ () => new AuthService(supabaseClient, appName),
34
+ [supabaseClient, appName]
34
35
  );
35
36
 
36
37
  const [sessionRestoration, setSessionRestoration] = useState<SessionRestorationState>(
@@ -526,7 +526,7 @@ export function UnifiedAuthProvider({
526
526
  dangerouslyDisableInactivity = false
527
527
  }: UnifiedAuthProviderProps) {
528
528
  return (
529
- <AuthServiceProvider supabaseClient={supabaseClient}>
529
+ <AuthServiceProvider supabaseClient={supabaseClient} appName={appName}>
530
530
  <ServiceAwareProviders
531
531
  supabaseClient={supabaseClient}
532
532
  appName={appName}
@@ -119,7 +119,7 @@ describe('RBAC API', () => {
119
119
 
120
120
  process.env.NODE_ENV = originalEnv;
121
121
 
122
- expect(mockCreateRBACEngine).toHaveBeenCalledWith(mockSupabase);
122
+ expect(mockCreateRBACEngine).toHaveBeenCalledWith(mockSupabase, undefined);
123
123
  expect(mockCreateAuditManager).toHaveBeenCalledWith(mockSupabase);
124
124
  expect(mockSetGlobalAuditManager).toHaveBeenCalledWith(mockAuditManager);
125
125
  expect(mockLogger.info).toHaveBeenCalledWith('RBAC system initialized successfully');
@@ -203,7 +203,7 @@ describe('RBAC API', () => {
203
203
 
204
204
  setupRBAC(mockSupabase as any);
205
205
 
206
- expect(mockCreateRBACEngine).toHaveBeenCalledWith(mockSupabase);
206
+ expect(mockCreateRBACEngine).toHaveBeenCalledWith(mockSupabase, undefined);
207
207
  });
208
208
 
209
209
  it('handles multiple initialization calls', () => {
package/src/rbac/api.ts CHANGED
@@ -49,7 +49,8 @@ export function setupRBAC(supabase: SupabaseClient<Database>, config?: Partial<R
49
49
 
50
50
  createRBACConfig(fullConfig);
51
51
 
52
- globalEngine = createRBACEngine(supabase);
52
+ // Pass security config to engine if provided
53
+ globalEngine = createRBACEngine(supabase, config?.security);
53
54
 
54
55
  // Setup audit manager
55
56
  const auditManager = createAuditManager(supabase);
@@ -148,41 +148,6 @@ const PagePermissionGuardComponent = ({
148
148
  const supabaseRef = useRef(supabase);
149
149
  supabaseRef.current = supabase;
150
150
 
151
- // Track the last scope we called useCan with to prevent infinite loops
152
- const lastScopeRef = useRef<string | null>(null);
153
-
154
- // Use a ref to store the stable scope and only update it when it actually changes
155
- const stableScopeRef = useRef<{ organisationId: string; appId: string; eventId: string | undefined }>({
156
- organisationId: '',
157
- appId: '',
158
- eventId: undefined
159
- });
160
-
161
- // Only update the stable scope if the resolved scope has actually changed
162
- if (resolvedScope && resolvedScope.organisationId) {
163
- const newScope = {
164
- organisationId: resolvedScope.organisationId,
165
- appId: resolvedScope.appId,
166
- eventId: resolvedScope.eventId
167
- };
168
-
169
- // Only update if the scope has actually changed
170
- if (stableScopeRef.current.organisationId !== newScope.organisationId ||
171
- stableScopeRef.current.eventId !== newScope.eventId ||
172
- stableScopeRef.current.appId !== newScope.appId) {
173
- stableScopeRef.current = {
174
- organisationId: newScope.organisationId,
175
- appId: newScope.appId || '',
176
- eventId: newScope.eventId
177
- };
178
- }
179
- } else if (!resolvedScope) {
180
- // Reset to empty scope when no resolved scope
181
- stableScopeRef.current = { organisationId: '', appId: '', eventId: undefined };
182
- }
183
-
184
- const stableScope = stableScopeRef.current;
185
-
186
151
  // Resolve scope - either use provided scope or resolve from context
187
152
  useEffect(() => {
188
153
  const abortController = new AbortController();
@@ -412,9 +377,22 @@ const PagePermissionGuardComponent = ({
412
377
  return `${operation}:page.${pageName}` as Permission;
413
378
  }, [operation, pageName]);
414
379
 
380
+ // Create a stable scope that only includes valid values
381
+ // This ensures useCan doesn't run with empty organisationId which causes it to stay in loading state
382
+ const stableScope = useMemo(() => {
383
+ if (resolvedScope && resolvedScope.organisationId) {
384
+ return {
385
+ organisationId: resolvedScope.organisationId,
386
+ appId: resolvedScope.appId || undefined,
387
+ eventId: resolvedScope.eventId || undefined
388
+ };
389
+ }
390
+ // Return a scope with empty string organisationId - useCan will handle this by keeping loading state
391
+ return { organisationId: '', appId: undefined, eventId: undefined };
392
+ }, [resolvedScope]);
415
393
 
416
- // Check if user has permission - only call useCan when we have a resolved scope
417
- // If resolvedScope is null, we're still resolving, so show loading state
394
+ // Check if user has permission - only call useCan when we have a resolved scope with valid organisationId
395
+ // If resolvedScope is null or has no organisationId, useCan will keep isLoading=true
418
396
  const { can, isLoading: canIsLoading, error: canError } = useCan(
419
397
  user?.id || '',
420
398
  stableScope,
@@ -464,12 +442,17 @@ const PagePermissionGuardComponent = ({
464
442
  console.error(`[PagePermissionGuard] STRICT MODE VIOLATION: User attempted to access protected page without permission`, {
465
443
  pageName,
466
444
  operation,
445
+ permission: `${operation}:page.${pageName}`,
446
+ pageId: effectivePageId,
467
447
  userId: user?.id,
468
448
  scope: resolvedScope,
449
+ scopeValid: resolvedScope && resolvedScope.organisationId ? true : false,
450
+ checkError,
451
+ canError,
469
452
  timestamp: new Date().toISOString()
470
453
  });
471
454
  }
472
- }, [strictMode, hasChecked, isLoading, can, pageName, operation, user?.id, resolvedScope]);
455
+ }, [strictMode, hasChecked, isLoading, can, pageName, operation, effectivePageId, user?.id, resolvedScope, checkError, canError]);
473
456
 
474
457
  // Calculate the actual render state - FIXED: Proper state calculation
475
458
  // Add defensive checks to ensure we have valid state
@@ -949,7 +949,7 @@ describe('PagePermissionGuard Component', () => {
949
949
  expect.objectContaining({
950
950
  organisationId: '',
951
951
  eventId: undefined,
952
- appId: ''
952
+ appId: undefined // appId is optional and should be undefined when not resolved
953
953
  }),
954
954
  'read:page.dashboard',
955
955
  'dashboard',
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { SupabaseClient } from '@supabase/supabase-js';
11
11
  import { Database } from '../types/database';
12
+ import { RBACSecurityConfig } from './security';
12
13
 
13
14
  export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
14
15
 
@@ -26,6 +27,7 @@ export interface RBACConfig {
26
27
  enabled?: boolean;
27
28
  logLevel?: LogLevel;
28
29
  };
30
+ security?: Partial<RBACSecurityConfig>;
29
31
  }
30
32
 
31
33
  export interface RBACLogger {
@@ -36,7 +36,8 @@ import {
36
36
  RBACSecurityValidator,
37
37
  RBACSecurityMiddleware,
38
38
  SecurityContext,
39
- DEFAULT_SECURITY_CONFIG
39
+ DEFAULT_SECURITY_CONFIG,
40
+ RBACSecurityConfig
40
41
  } from './security';
41
42
 
42
43
  /**
@@ -49,9 +50,14 @@ export class RBACEngine {
49
50
  private supabase: SupabaseClient<Database>;
50
51
  private securityMiddleware: RBACSecurityMiddleware;
51
52
 
52
- constructor(supabase: SupabaseClient<Database>) {
53
+ constructor(supabase: SupabaseClient<Database>, securityConfig?: Partial<RBACSecurityConfig>) {
53
54
  this.supabase = supabase;
54
- this.securityMiddleware = new RBACSecurityMiddleware(DEFAULT_SECURITY_CONFIG);
55
+ // Merge provided security config with defaults
56
+ const mergedSecurityConfig: RBACSecurityConfig = {
57
+ ...DEFAULT_SECURITY_CONFIG,
58
+ ...securityConfig,
59
+ };
60
+ this.securityMiddleware = new RBACSecurityMiddleware(mergedSecurityConfig);
55
61
 
56
62
  // Initialize cache invalidation for automatic cache clearing
57
63
  initializeCacheInvalidation(supabase);
@@ -474,11 +480,13 @@ export class RBACEngine {
474
480
 
475
481
  try {
476
482
  const { userId, scope } = input;
483
+ // Call unified function (tech debt removed: consolidated from 2 overloaded versions)
477
484
  const { data, error } = await (this.supabase as any).rpc('rbac_permissions_get', {
478
485
  p_user_id: userId,
479
486
  p_organisation_id: scope.organisationId || null,
480
487
  p_event_id: scope.eventId || null,
481
488
  p_app_id: scope.appId || null,
489
+ p_page_id: null, // Optional: can filter to specific page if needed
482
490
  });
483
491
 
484
492
  if (error) {
@@ -587,9 +595,13 @@ export class RBACEngine {
587
595
  * Create an RBAC engine instance
588
596
  *
589
597
  * @param supabase - Supabase client
598
+ * @param securityConfig - Optional security configuration
590
599
  * @returns RBACEngine instance
591
600
  */
592
- export function createRBACEngine(supabase: SupabaseClient<Database>): RBACEngine {
593
- return new RBACEngine(supabase);
601
+ export function createRBACEngine(
602
+ supabase: SupabaseClient<Database>,
603
+ securityConfig?: Partial<RBACSecurityConfig>
604
+ ): RBACEngine {
605
+ return new RBACEngine(supabase, securityConfig);
594
606
  }
595
607
 
@@ -251,7 +251,7 @@ export const DEFAULT_SECURITY_CONFIG: RBACSecurityConfig = {
251
251
  enableInputValidation: true,
252
252
  enableRateLimiting: true,
253
253
  enableAuditLogging: true,
254
- maxPermissionChecksPerMinute: 100,
254
+ maxPermissionChecksPerMinute: 1000, // Increased from 100 to 1000 for normal app usage
255
255
  suspiciousActivityThreshold: 10,
256
256
  };
257
257
 
@@ -28,10 +28,12 @@ export class AuthService extends BaseService implements IAuthService {
28
28
  private restorationTimeoutId: ReturnType<typeof setTimeout> | null = null;
29
29
  private readonly restorationTimeoutMs = 5000;
30
30
  private restorationStartTime: number | null = null;
31
+ private appName: string | undefined = undefined;
31
32
 
32
- constructor(supabaseClient: SupabaseClient) {
33
+ constructor(supabaseClient: SupabaseClient, appName?: string) {
33
34
  super();
34
35
  this.supabaseClient = supabaseClient;
36
+ this.appName = appName;
35
37
  }
36
38
 
37
39
  // Auth state getters
@@ -386,6 +388,13 @@ export class AuthService extends BaseService implements IAuthService {
386
388
  this.session = null;
387
389
  this.user = null;
388
390
  this.authError = null;
391
+
392
+ // Automatic session tracking (non-blocking)
393
+ if (session?.user) {
394
+ this.trackSession('logout', session).catch(err => {
395
+ console.warn('[AuthService] Failed to track logout session:', err);
396
+ });
397
+ }
389
398
  } else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
390
399
  this.session = session;
391
400
  this.user = session?.user ?? null;
@@ -394,6 +403,14 @@ export class AuthService extends BaseService implements IAuthService {
394
403
  if (session) {
395
404
  this.authError = null;
396
405
  }
406
+
407
+ // Automatic session tracking for login (non-blocking)
408
+ // Only track on SIGNED_IN, not TOKEN_REFRESHED (to avoid duplicate login records)
409
+ if (event === 'SIGNED_IN' && session?.user) {
410
+ this.trackSession('login', session).catch(err => {
411
+ console.warn('[AuthService] Failed to track login session:', err);
412
+ });
413
+ }
397
414
  } else if (event === 'INITIAL_SESSION') {
398
415
  if (session) {
399
416
  this.session = session;
@@ -502,6 +519,67 @@ export class AuthService extends BaseService implements IAuthService {
502
519
  }
503
520
  }
504
521
 
522
+ /**
523
+ * Automatically track user session using rbac_session_track
524
+ * This method is called automatically on SIGNED_IN and SIGNED_OUT events.
525
+ * It's non-blocking and failures are logged as warnings.
526
+ */
527
+ private async trackSession(
528
+ sessionType: 'login' | 'logout',
529
+ session: Session | null
530
+ ): Promise<void> {
531
+ if (!this.supabaseClient || !session?.user) {
532
+ return;
533
+ }
534
+
535
+ try {
536
+ // Resolve app_id from appName if available
537
+ let appId: string | undefined = undefined;
538
+ if (this.appName) {
539
+ const { data, error } = await this.supabaseClient
540
+ .from('rbac_apps')
541
+ .select('id')
542
+ .eq('name', this.appName)
543
+ .eq('is_active', true)
544
+ .single();
545
+
546
+ if (!error && data) {
547
+ appId = data.id;
548
+ }
549
+ }
550
+
551
+ // Get IP address and user agent from browser (if available)
552
+ const ipAddress = undefined; // Browser doesn't expose IP directly, could use API
553
+ const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
554
+
555
+ // Get device fingerprint from localStorage if available
556
+ // Note: Device fingerprinting should be done by consuming app and passed via custom header
557
+ // For now, we'll skip it to avoid dependencies
558
+ const deviceFingerprint = undefined;
559
+
560
+ // Call rbac_session_track RPC function
561
+ // This automatically inserts into rbac_user_sessions AND rbac_user_login_history (for login)
562
+ const { error } = await (this.supabaseClient as any).rpc('rbac_session_track', {
563
+ p_user_id: session.user.id,
564
+ p_session_type: sessionType,
565
+ p_event_id: null, // Event ID should come from context, not auth service
566
+ p_app_id: appId,
567
+ p_ip_address: ipAddress,
568
+ p_user_agent: userAgent,
569
+ p_device_fingerprint: deviceFingerprint,
570
+ });
571
+
572
+ if (error) {
573
+ console.warn(`[AuthService] Failed to track ${sessionType} session:`, error);
574
+ } else {
575
+ console.debug(`[AuthService] Successfully tracked ${sessionType} session`);
576
+ }
577
+ } catch (error) {
578
+ // Log error but don't throw (non-blocking)
579
+ console.warn(`[AuthService] Error tracking ${sessionType} session:`, error);
580
+ }
581
+ }
582
+
505
583
  private setupErrorHandlers(): void {
506
584
  if (typeof window === 'undefined') return;
507
585