@jmruthers/pace-core 0.5.110 → 0.5.112

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 (236) hide show
  1. package/dist/{AuthService-DrHrvXNZ.d.ts → AuthService-CVgsgtaZ.d.ts} +8 -0
  2. package/dist/{DataTable-D3BK2FCN.js → DataTable-3D3BUZDV.js} +8 -8
  3. package/dist/{UnifiedAuthProvider-A7I23UCN.js → UnifiedAuthProvider-KZZUO27W.js} +3 -3
  4. package/dist/{api-PIE4JRFS.js → api-QPMBZZUZ.js} +5 -3
  5. package/dist/{audit-65VNHEV2.js → audit-H4YJJF7R.js} +2 -2
  6. package/dist/{chunk-Q7APDV6H.js → chunk-3OGQLOJM.js} +23 -7
  7. package/dist/chunk-3OGQLOJM.js.map +1 -0
  8. package/dist/{chunk-EYSXQ756.js → chunk-7H75SHXZ.js} +2 -2
  9. package/dist/{chunk-D6MEKC27.js → chunk-BUN7NMV7.js} +2 -2
  10. package/dist/{chunk-AWK2FAUN.js → chunk-C5RN4TE5.js} +7 -7
  11. package/dist/{chunk-3J5N2T2N.js → chunk-EKVVTPIF.js} +183 -127
  12. package/dist/chunk-EKVVTPIF.js.map +1 -0
  13. package/dist/{chunk-2W4WKJVF.js → chunk-F6QB26OS.js} +290 -255
  14. package/dist/chunk-F6QB26OS.js.map +1 -0
  15. package/dist/{chunk-HADXAZT3.js → chunk-I7JC7PTJ.js} +54 -92
  16. package/dist/chunk-I7JC7PTJ.js.map +1 -0
  17. package/dist/{chunk-EZ64QG2I.js → chunk-L36JW4KV.js} +2 -2
  18. package/dist/{chunk-7GBEBJLR.js → chunk-MNSGWRPB.js} +45 -37
  19. package/dist/chunk-MNSGWRPB.js.map +1 -0
  20. package/dist/{chunk-YFMENCR4.js → chunk-NEONKMTU.js} +3 -3
  21. package/dist/{chunk-AUXS7XSO.js → chunk-OO3V7W4H.js} +35 -11
  22. package/dist/chunk-OO3V7W4H.js.map +1 -0
  23. package/dist/{chunk-XRSP3H52.js → chunk-TAJRS6YB.js} +57 -23
  24. package/dist/chunk-TAJRS6YB.js.map +1 -0
  25. package/dist/{chunk-HGZSO43Y.js → chunk-WMPZY26G.js} +8 -4
  26. package/dist/{chunk-HGZSO43Y.js.map → chunk-WMPZY26G.js.map} +1 -1
  27. package/dist/components.js +10 -10
  28. package/dist/hooks.d.ts +11 -1
  29. package/dist/hooks.js +9 -7
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +13 -13
  33. package/dist/providers.d.ts +2 -2
  34. package/dist/providers.js +2 -2
  35. package/dist/rbac/index.d.ts +13 -8
  36. package/dist/rbac/index.js +9 -9
  37. package/dist/utils.js +1 -1
  38. package/docs/api/classes/ColumnFactory.md +1 -1
  39. package/docs/api/classes/ErrorBoundary.md +1 -1
  40. package/docs/api/classes/InvalidScopeError.md +4 -4
  41. package/docs/api/classes/MissingUserContextError.md +4 -4
  42. package/docs/api/classes/OrganisationContextRequiredError.md +4 -4
  43. package/docs/api/classes/PermissionDeniedError.md +4 -4
  44. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  45. package/docs/api/classes/RBACAuditManager.md +8 -8
  46. package/docs/api/classes/RBACCache.md +8 -8
  47. package/docs/api/classes/RBACEngine.md +4 -4
  48. package/docs/api/classes/RBACError.md +4 -4
  49. package/docs/api/classes/RBACNotInitializedError.md +4 -4
  50. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  51. package/docs/api/classes/StorageUtils.md +1 -1
  52. package/docs/api/enums/FileCategory.md +1 -1
  53. package/docs/api/interfaces/AggregateConfig.md +1 -1
  54. package/docs/api/interfaces/ButtonProps.md +1 -1
  55. package/docs/api/interfaces/CardProps.md +1 -1
  56. package/docs/api/interfaces/ColorPalette.md +1 -1
  57. package/docs/api/interfaces/ColorShade.md +1 -1
  58. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  59. package/docs/api/interfaces/DataRecord.md +1 -1
  60. package/docs/api/interfaces/DataTableAction.md +1 -1
  61. package/docs/api/interfaces/DataTableColumn.md +1 -1
  62. package/docs/api/interfaces/DataTableProps.md +1 -1
  63. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  64. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  65. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  66. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  67. package/docs/api/interfaces/FileMetadata.md +1 -1
  68. package/docs/api/interfaces/FileReference.md +1 -1
  69. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  70. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  71. package/docs/api/interfaces/FileUploadProps.md +1 -1
  72. package/docs/api/interfaces/FooterProps.md +1 -1
  73. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  74. package/docs/api/interfaces/InputProps.md +1 -1
  75. package/docs/api/interfaces/LabelProps.md +1 -1
  76. package/docs/api/interfaces/LoginFormProps.md +1 -1
  77. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  78. package/docs/api/interfaces/NavigationContextType.md +1 -1
  79. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  80. package/docs/api/interfaces/NavigationItem.md +1 -1
  81. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  82. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  83. package/docs/api/interfaces/Organisation.md +1 -1
  84. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  85. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  86. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  87. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  88. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  89. package/docs/api/interfaces/PaceLoginPageProps.md +4 -4
  90. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  91. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  92. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  93. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  94. package/docs/api/interfaces/PaletteData.md +1 -1
  95. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  96. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  97. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  98. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  99. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  100. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  101. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  102. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  103. package/docs/api/interfaces/RBACConfig.md +1 -1
  104. package/docs/api/interfaces/RBACLogger.md +1 -1
  105. package/docs/api/interfaces/RoleBasedRouterContextType.md +8 -8
  106. package/docs/api/interfaces/RoleBasedRouterProps.md +10 -10
  107. package/docs/api/interfaces/RouteAccessRecord.md +10 -10
  108. package/docs/api/interfaces/RouteConfig.md +19 -6
  109. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  110. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  111. package/docs/api/interfaces/StorageConfig.md +1 -1
  112. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  113. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  114. package/docs/api/interfaces/StorageListOptions.md +1 -1
  115. package/docs/api/interfaces/StorageListResult.md +1 -1
  116. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  117. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  118. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  119. package/docs/api/interfaces/StyleImport.md +1 -1
  120. package/docs/api/interfaces/SwitchProps.md +1 -1
  121. package/docs/api/interfaces/ToastActionElement.md +1 -1
  122. package/docs/api/interfaces/ToastProps.md +1 -1
  123. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  124. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  125. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  126. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  127. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  128. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  129. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  130. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  131. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  132. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  133. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  134. package/docs/api/interfaces/UserEventAccess.md +1 -1
  135. package/docs/api/interfaces/UserMenuProps.md +1 -1
  136. package/docs/api/interfaces/UserProfile.md +1 -1
  137. package/docs/api/modules.md +36 -36
  138. package/docs/api-reference/hooks.md +8 -4
  139. package/docs/architecture/rpc-function-standards.md +3 -1
  140. package/docs/best-practices/common-patterns.md +3 -3
  141. package/docs/best-practices/deployment.md +10 -4
  142. package/docs/best-practices/performance.md +11 -3
  143. package/docs/core-concepts/organisations.md +8 -8
  144. package/docs/core-concepts/permissions.md +133 -72
  145. package/docs/migration/rbac-migration.md +65 -66
  146. package/docs/rbac/advanced-patterns.md +15 -22
  147. package/docs/rbac/examples.md +12 -12
  148. package/docs/rbac/getting-started.md +3 -3
  149. package/docs/rbac/troubleshooting.md +2 -1
  150. package/package.json +1 -1
  151. package/src/components/DataTable/DataTable.test.tsx +405 -154
  152. package/src/components/DataTable/components/DataTableCore.tsx +6 -1
  153. package/src/components/DataTable/components/__tests__/ActionButtons.test.tsx +913 -0
  154. package/src/components/DataTable/components/__tests__/ColumnFilter.test.tsx +609 -0
  155. package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +434 -0
  156. package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +120 -0
  157. package/src/components/DataTable/components/__tests__/PaginationControls.test.tsx +519 -0
  158. package/src/components/DataTable/examples/__tests__/HierarchicalActionsExample.test.tsx +316 -0
  159. package/src/components/DataTable/examples/__tests__/InitialPageSizeExample.test.tsx +211 -0
  160. package/src/components/EventSelector/EventSelector.tsx +32 -2
  161. package/src/components/FileUpload/FileUpload.tsx +2 -8
  162. package/src/components/NavigationMenu/NavigationMenu.test.tsx +56 -8
  163. package/src/components/NavigationMenu/NavigationMenu.tsx +75 -12
  164. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +193 -63
  165. package/src/components/PaceAppLayout/PaceAppLayout.tsx +102 -135
  166. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +41 -2
  167. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +61 -6
  168. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +71 -21
  169. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +113 -41
  170. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +155 -45
  171. package/src/components/PaceLoginPage/PaceLoginPage.tsx +30 -1
  172. package/src/hooks/__tests__/usePermissionCache.simple.test.ts +63 -5
  173. package/src/hooks/__tests__/usePermissionCache.unit.test.ts +156 -72
  174. package/src/hooks/__tests__/useRBAC.unit.test.ts +4 -38
  175. package/src/hooks/index.ts +1 -1
  176. package/src/hooks/useFileDisplay.ts +51 -0
  177. package/src/hooks/usePermissionCache.test.ts +112 -68
  178. package/src/hooks/usePermissionCache.ts +55 -15
  179. package/src/rbac/README.md +81 -39
  180. package/src/rbac/__tests__/adapters.comprehensive.test.tsx +3 -3
  181. package/src/rbac/__tests__/engine.comprehensive.test.ts +15 -6
  182. package/src/rbac/__tests__/rbac-core.test.tsx +1 -1
  183. package/src/rbac/__tests__/rbac-engine-core-logic.test.ts +57 -4
  184. package/src/rbac/__tests__/rbac-engine-simplified.test.ts +3 -2
  185. package/src/rbac/adapters.tsx +4 -4
  186. package/src/rbac/api.test.ts +37 -13
  187. package/src/rbac/api.ts +25 -8
  188. package/src/rbac/audit-enhanced.ts +14 -2
  189. package/src/rbac/audit.test.ts +18 -8
  190. package/src/rbac/audit.ts +25 -6
  191. package/src/rbac/cache.test.ts +12 -0
  192. package/src/rbac/cache.ts +29 -9
  193. package/src/rbac/components/EnhancedNavigationMenu.test.tsx +1 -1
  194. package/src/rbac/components/NavigationGuard.tsx +14 -14
  195. package/src/rbac/components/NavigationProvider.test.tsx +1 -1
  196. package/src/rbac/components/PagePermissionGuard.tsx +4 -3
  197. package/src/rbac/components/PagePermissionProvider.test.tsx +1 -1
  198. package/src/rbac/components/PermissionEnforcer.tsx +19 -15
  199. package/src/rbac/components/RoleBasedRouter.tsx +16 -9
  200. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +123 -107
  201. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1 -1
  202. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +121 -103
  203. package/src/rbac/docs/event-based-apps.md +6 -6
  204. package/src/rbac/engine.ts +12 -2
  205. package/src/rbac/hooks/useCan.test.ts +29 -2
  206. package/src/rbac/hooks/usePermissions.test.ts +25 -25
  207. package/src/rbac/hooks/usePermissions.ts +65 -25
  208. package/src/rbac/hooks/useRBAC.simple.test.ts +1 -8
  209. package/src/rbac/hooks/useRBAC.test.ts +3 -40
  210. package/src/rbac/hooks/useRBAC.ts +0 -55
  211. package/src/rbac/hooks/useResolvedScope.ts +23 -31
  212. package/src/rbac/permissions.test.ts +11 -7
  213. package/src/rbac/security.test.ts +2 -2
  214. package/src/rbac/security.ts +22 -7
  215. package/src/rbac/types.test.ts +2 -2
  216. package/src/rbac/types.ts +1 -2
  217. package/src/services/EventService.ts +42 -13
  218. package/src/services/__tests__/EventService.test.ts +25 -4
  219. package/src/services/interfaces/IEventService.ts +1 -0
  220. package/src/utils/file-reference.ts +9 -0
  221. package/dist/chunk-2W4WKJVF.js.map +0 -1
  222. package/dist/chunk-3J5N2T2N.js.map +0 -1
  223. package/dist/chunk-7GBEBJLR.js.map +0 -1
  224. package/dist/chunk-AUXS7XSO.js.map +0 -1
  225. package/dist/chunk-HADXAZT3.js.map +0 -1
  226. package/dist/chunk-Q7APDV6H.js.map +0 -1
  227. package/dist/chunk-XRSP3H52.js.map +0 -1
  228. /package/dist/{DataTable-D3BK2FCN.js.map → DataTable-3D3BUZDV.js.map} +0 -0
  229. /package/dist/{UnifiedAuthProvider-A7I23UCN.js.map → UnifiedAuthProvider-KZZUO27W.js.map} +0 -0
  230. /package/dist/{api-PIE4JRFS.js.map → api-QPMBZZUZ.js.map} +0 -0
  231. /package/dist/{audit-65VNHEV2.js.map → audit-H4YJJF7R.js.map} +0 -0
  232. /package/dist/{chunk-EYSXQ756.js.map → chunk-7H75SHXZ.js.map} +0 -0
  233. /package/dist/{chunk-D6MEKC27.js.map → chunk-BUN7NMV7.js.map} +0 -0
  234. /package/dist/{chunk-AWK2FAUN.js.map → chunk-C5RN4TE5.js.map} +0 -0
  235. /package/dist/{chunk-EZ64QG2I.js.map → chunk-L36JW4KV.js.map} +0 -0
  236. /package/dist/{chunk-YFMENCR4.js.map → chunk-NEONKMTU.js.map} +0 -0
@@ -487,11 +487,25 @@ describe('NavigationMenu Component', () => {
487
487
  mockUsePermissions.mockReturnValue({
488
488
  permissions: {
489
489
  'dashboard:read': true,
490
+ // Add page permissions for items with hrefs (NavigationMenu checks these)
491
+ 'read:page.dashboard': true,
492
+ 'read:page.users': true,
493
+ 'read:page.settings': true,
490
494
  } as any,
491
495
  isLoading: false,
492
496
  error: null,
493
- hasPermission: vi.fn((p: any) => p === 'dashboard:read'),
494
- hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => p === 'dashboard:read')),
497
+ hasPermission: vi.fn((p: any) => {
498
+ return p === 'dashboard:read' ||
499
+ p === 'read:page.dashboard' ||
500
+ p === 'read:page.users' ||
501
+ p === 'read:page.settings';
502
+ }),
503
+ hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => {
504
+ return p === 'dashboard:read' ||
505
+ p === 'read:page.dashboard' ||
506
+ p === 'read:page.users' ||
507
+ p === 'read:page.settings';
508
+ })),
495
509
  hasAllPermissions: vi.fn(() => true),
496
510
  refetch: vi.fn(),
497
511
  });
@@ -636,11 +650,25 @@ describe('NavigationMenu Component', () => {
636
650
  mockUsePermissions.mockReturnValue({
637
651
  permissions: {
638
652
  'dashboard:read': true,
653
+ // Add page permissions for items with hrefs (NavigationMenu checks these)
654
+ 'read:page.dashboard': true,
655
+ 'read:page.users': true,
656
+ 'read:page.settings': true,
639
657
  } as any,
640
658
  isLoading: false,
641
659
  error: null,
642
- hasPermission: vi.fn((p: any) => p === 'dashboard:read'),
643
- hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => p === 'dashboard:read')),
660
+ hasPermission: vi.fn((p: any) => {
661
+ return p === 'dashboard:read' ||
662
+ p === 'read:page.dashboard' ||
663
+ p === 'read:page.users' ||
664
+ p === 'read:page.settings';
665
+ }),
666
+ hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => {
667
+ return p === 'dashboard:read' ||
668
+ p === 'read:page.dashboard' ||
669
+ p === 'read:page.users' ||
670
+ p === 'read:page.settings';
671
+ })),
644
672
  hasAllPermissions: vi.fn(() => true),
645
673
  refetch: vi.fn(),
646
674
  });
@@ -700,11 +728,25 @@ describe('NavigationMenu Component', () => {
700
728
  mockUsePermissions.mockReturnValue({
701
729
  permissions: {
702
730
  'dashboard:read': true,
731
+ // Add page permissions for items with hrefs (NavigationMenu checks these)
732
+ 'read:page.dashboard': true,
733
+ 'read:page.users': true,
734
+ 'read:page.settings': true,
703
735
  } as any,
704
736
  isLoading: false,
705
737
  error: null,
706
- hasPermission: vi.fn((p: any) => p === 'dashboard:read'),
707
- hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => p === 'dashboard:read')),
738
+ hasPermission: vi.fn((p: any) => {
739
+ return p === 'dashboard:read' ||
740
+ p === 'read:page.dashboard' ||
741
+ p === 'read:page.users' ||
742
+ p === 'read:page.settings';
743
+ }),
744
+ hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => {
745
+ return p === 'dashboard:read' ||
746
+ p === 'read:page.dashboard' ||
747
+ p === 'read:page.users' ||
748
+ p === 'read:page.settings';
749
+ })),
708
750
  hasAllPermissions: vi.fn(() => true),
709
751
  refetch: vi.fn(),
710
752
  });
@@ -894,11 +936,17 @@ describe('NavigationMenu Component', () => {
894
936
  mockUsePermissions.mockReturnValue({
895
937
  permissions: {
896
938
  'valid-permission': true,
939
+ // Add page permission for /test href (NavigationMenu checks this)
940
+ 'read:page.test': true,
897
941
  } as any,
898
942
  isLoading: false,
899
943
  error: null,
900
- hasPermission: vi.fn((p: any) => p === 'valid-permission'),
901
- hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => typeof p === 'string' && p === 'valid-permission')),
944
+ hasPermission: vi.fn((p: any) => {
945
+ return p === 'valid-permission' || p === 'read:page.test';
946
+ }),
947
+ hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => {
948
+ return (typeof p === 'string' && p === 'valid-permission') || p === 'read:page.test';
949
+ })),
902
950
  hasAllPermissions: vi.fn(() => true),
903
951
  refetch: vi.fn(),
904
952
  });
@@ -446,19 +446,71 @@ export const NavigationMenu = React.forwardRef<
446
446
  selectedEventId: selectedEvent?.event_id || null
447
447
  });
448
448
 
449
+ // Stabilize scope object to prevent unnecessary permission refetches
450
+ // This prevents the permission map from being cleared when scope object reference changes
451
+ const stableScopeRef = React.useRef<{ organisationId: string; eventId?: string; appId?: string }>({
452
+ organisationId: '',
453
+ eventId: undefined,
454
+ appId: undefined
455
+ });
456
+
457
+ // Only update stable scope if values actually changed
458
+ if (resolvedScope?.organisationId) {
459
+ const newOrgId = resolvedScope.organisationId;
460
+ const newEventId = resolvedScope.eventId;
461
+ const newAppId = resolvedScope.appId;
462
+
463
+ if (stableScopeRef.current.organisationId !== newOrgId ||
464
+ stableScopeRef.current.eventId !== newEventId ||
465
+ stableScopeRef.current.appId !== newAppId) {
466
+ stableScopeRef.current = {
467
+ organisationId: newOrgId,
468
+ eventId: newEventId,
469
+ appId: newAppId
470
+ };
471
+ }
472
+ } else if (!resolvedScope) {
473
+ // Only reset if we had a previous value - don't clear on initial render
474
+ if (stableScopeRef.current.organisationId !== '') {
475
+ stableScopeRef.current = {
476
+ organisationId: '',
477
+ eventId: undefined,
478
+ appId: undefined
479
+ };
480
+ }
481
+ }
482
+
483
+ const stableScope = stableScopeRef.current;
484
+
449
485
  // Get permissions map for synchronous permission checks
450
486
  const userId = authContext?.user?.id || '';
451
- const scope = resolvedScope || { organisationId: '', eventId: undefined, appId: undefined };
452
- const { permissions: permissionMap, hasAnyPermission, isLoading: permissionsLoading } = usePermissions(
487
+ const { permissions: permissionMap, hasAnyPermission, isLoading: permissionsLoading, error: permissionsError } = usePermissions(
453
488
  userId as any,
454
- scope as any
489
+ stableScope as any
455
490
  );
456
491
 
457
492
  // NEW: Phase 2 - Enhanced Security Features
458
493
  // Filter navigation items based on permissions using RBAC hooks
459
494
  const filteredItems = React.useMemo(() => {
460
- // If filtering disabled, auth unavailable, RBAC unavailable, scope/permissions loading, or no org context: show all items (except hidden)
461
- if (!filterByPermissions || !authContext || !rbacContext || scopeLoading || permissionsLoading || !resolvedScope?.organisationId) {
495
+ // Security: If filtering is enabled but we're missing required context or still loading, show NO items
496
+ // This prevents security risk of showing items before permissions are verified
497
+ if (filterByPermissions) {
498
+ if (!authContext || !rbacContext || scopeLoading || permissionsLoading || !resolvedScope?.organisationId) {
499
+ // Still loading - show nothing to prevent security risk
500
+ return [];
501
+ }
502
+
503
+ // If there's an error or empty permission map after loading, show nothing
504
+ // Security: Better to show nothing than risk showing unauthorized items
505
+ if (permissionsError || !permissionMap || Object.keys(permissionMap).length === 0) {
506
+ console.warn('[NavigationMenu] Permission map is empty or has error - showing no items for security', {
507
+ permissionsError: permissionsError?.message,
508
+ permissionMapSize: permissionMap ? Object.keys(permissionMap).length : 0
509
+ });
510
+ return [];
511
+ }
512
+ } else {
513
+ // Filtering disabled - only filter out hidden items
462
514
  return (items || []).filter(item => !item.meta?.hidden);
463
515
  }
464
516
 
@@ -553,25 +605,32 @@ export const NavigationMenu = React.forwardRef<
553
605
  }
554
606
  }
555
607
 
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) {
608
+ // NEW: Auto-check page permissions for items with href
609
+ // Always check page permissions for items with href, even if they have explicit permissions/roles/accessLevel
610
+ // This ensures that items are filtered out if the user doesn't have access to the page itself
611
+ if (item.href) {
559
612
  const pageId = item.pageId || getPageIdFromHref(item.href);
560
613
  if (pageId) {
561
614
  // Check for read permission on the page
562
615
  const pagePermission: Permission = `read:page.${pageId}` as Permission;
563
616
 
564
617
  // Check permission map (super admin has access to everything via '*' key)
565
- const hasPagePermission = permissionMap['*'] === true || permissionMap[pagePermission] === true;
618
+ // Only allow if permission is explicitly true (undefined/false means no access)
619
+ const isSuperAdmin = permissionMap['*'] === true;
620
+ const hasPagePermission = permissionMap[pagePermission] === true;
621
+ const finalHasPermission = isSuperAdmin || hasPagePermission;
566
622
 
567
- if (!hasPagePermission) {
623
+ if (!finalHasPermission) {
568
624
  if (auditLog) {
569
625
  console.log(`[NavigationMenu] Filtering out navigation item "${item.label}" - no page permission:`, {
570
626
  itemId: item.id,
571
627
  href: item.href,
572
628
  pageId,
573
629
  permission: pagePermission,
574
- hasPermission: hasPagePermission
630
+ hasPermission: finalHasPermission,
631
+ isSuperAdmin,
632
+ permissionMapValue: permissionMap[pagePermission],
633
+ permissionMapKeys: Object.keys(permissionMap).slice(0, 10) // Show first 10 keys for debugging
575
634
  });
576
635
  }
577
636
  return false;
@@ -610,9 +669,13 @@ export const NavigationMenu = React.forwardRef<
610
669
  };
611
670
  };
612
671
 
613
- return (items || [])
672
+ // Filter items based on permissions - only show items with explicit permission
673
+ // Security: No fallback - if items don't have permission, they are hidden
674
+ const filtered = (items || [])
614
675
  .map(item => filterItem(item))
615
676
  .filter((item): item is NavigationItem => item !== null);
677
+
678
+ return filtered;
616
679
  }, [
617
680
  items,
618
681
  filterByPermissions,
@@ -54,13 +54,91 @@ vi.mock('../../providers/UnifiedAuthProvider', () => ({
54
54
  useUnifiedAuth: vi.fn(() => mockUnifiedAuth),
55
55
  }));
56
56
 
57
+ // Mock useOrganisations hook
58
+ const mockSelectedOrganisation = {
59
+ id: 'org-123',
60
+ name: 'Test Organisation',
61
+ display_name: 'Test Organisation',
62
+ slug: 'test-org',
63
+ description: 'Test organisation',
64
+ subscription_tier: 'basic',
65
+ settings: {},
66
+ is_active: true,
67
+ created_at: '2023-01-01T00:00:00Z',
68
+ updated_at: '2023-01-01T00:00:00Z',
69
+ };
70
+
71
+ vi.mock('../../hooks/useOrganisations', () => ({
72
+ useOrganisations: vi.fn(() => ({
73
+ selectedOrganisation: mockSelectedOrganisation,
74
+ organisations: [mockSelectedOrganisation],
75
+ userMemberships: [],
76
+ isLoading: false,
77
+ error: null,
78
+ hasValidOrganisationContext: true,
79
+ setSelectedOrganisation: vi.fn(),
80
+ switchOrganisation: vi.fn().mockResolvedValue(undefined),
81
+ getUserRole: vi.fn().mockReturnValue('member'),
82
+ validateOrganisationAccess: vi.fn().mockReturnValue(true),
83
+ ensureOrganisationContext: vi.fn().mockReturnValue(mockSelectedOrganisation),
84
+ refreshOrganisations: vi.fn().mockResolvedValue(undefined),
85
+ getPrimaryOrganisation: vi.fn().mockReturnValue(mockSelectedOrganisation),
86
+ isOrganisationSecure: vi.fn().mockReturnValue(true),
87
+ })),
88
+ }));
89
+
90
+ // Mock useEvents hook (optional - wrapped in try/catch in component)
91
+ vi.mock('../../providers/EventsProvider', () => ({
92
+ useEvents: vi.fn(() => ({
93
+ selectedEvent: { event_id: 'event-123' },
94
+ events: [],
95
+ isLoading: false,
96
+ error: null,
97
+ })),
98
+ }));
99
+
57
100
  // Mock RBAC functions
101
+ const mockIsPermitted = vi.fn().mockResolvedValue(true);
102
+ const mockIsPermittedCached = vi.fn().mockResolvedValue(true);
103
+
58
104
  vi.mock('../../rbac/api', () => ({
59
- isPermitted: vi.fn().mockResolvedValue(true),
105
+ isPermitted: vi.fn(),
106
+ isPermittedCached: vi.fn(),
60
107
  isSuperAdmin: vi.fn().mockResolvedValue(false),
61
108
  setupRBAC: vi.fn(),
62
109
  }));
63
110
 
111
+ // Mock useCan hook - this is what PaceAppLayout actually uses
112
+ const mockUseCan = vi.fn(() => ({
113
+ can: true,
114
+ isLoading: false,
115
+ error: null,
116
+ refetch: vi.fn().mockResolvedValue(undefined),
117
+ }));
118
+
119
+ // Mock RBAC hooks
120
+ const mockHasPermissionFn = vi.fn().mockResolvedValue(true);
121
+ vi.mock('../../rbac/hooks', () => ({
122
+ useRBAC: vi.fn(() => ({
123
+ hasPermission: mockHasPermissionFn,
124
+ isLoading: false,
125
+ error: null,
126
+ hasGlobalPermission: vi.fn().mockResolvedValue(true),
127
+ hasOrganisationPermission: vi.fn().mockResolvedValue(true),
128
+ hasEventPermission: vi.fn().mockResolvedValue(true),
129
+ globalRole: null,
130
+ organisationRoles: [],
131
+ eventRoles: [],
132
+ permissionMap: {},
133
+ })),
134
+ useCan: (...args: any[]) => mockUseCan(...args),
135
+ useResolvedScope: vi.fn(() => ({
136
+ resolvedScope: { organisationId: 'org-123', eventId: 'event-123', appId: 'app-123' },
137
+ isLoading: false,
138
+ error: null,
139
+ })),
140
+ }));
141
+
64
142
  // Mock Header component
65
143
  vi.mock('../Header', () => ({
66
144
  Header: ({
@@ -151,10 +229,21 @@ describe('PaceAppLayout Component', () => {
151
229
  vi.clearAllMocks();
152
230
  // Reset location mock
153
231
  mockLocation.pathname = '/dashboard';
154
- // Reset RBAC mocks
155
- const { isPermitted, isSuperAdmin } = await import('../../rbac/api');
232
+ // Reset RBAC hook mocks
233
+ mockHasPermissionFn.mockClear();
234
+ mockHasPermissionFn.mockResolvedValue(true);
235
+ mockUseCan.mockReturnValue({
236
+ can: true,
237
+ isLoading: false,
238
+ error: null,
239
+ refetch: vi.fn().mockResolvedValue(undefined),
240
+ });
241
+ // Reset RBAC API mocks
242
+ const { isPermitted, isPermittedCached, isSuperAdmin } = await import('../../rbac/api');
156
243
  vi.mocked(isPermitted).mockReset();
157
244
  vi.mocked(isPermitted).mockResolvedValue(true);
245
+ vi.mocked(isPermittedCached).mockReset();
246
+ vi.mocked(isPermittedCached).mockResolvedValue(true);
158
247
  vi.mocked(isSuperAdmin).mockReset();
159
248
  vi.mocked(isSuperAdmin).mockResolvedValue(false);
160
249
  });
@@ -360,8 +449,13 @@ describe('PaceAppLayout Component', () => {
360
449
  });
361
450
 
362
451
  it('shows loading state when checking permissions', async () => {
363
- const { isPermitted } = await import('../../rbac/api');
364
- vi.mocked(isPermitted).mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(true), 100)));
452
+ // Mock useCan to return loading state
453
+ mockUseCan.mockReturnValueOnce({
454
+ can: false,
455
+ isLoading: true,
456
+ error: null,
457
+ refetch: vi.fn().mockResolvedValue(undefined),
458
+ });
365
459
 
366
460
  renderWithProviders(
367
461
  <TestWrapper>
@@ -374,8 +468,13 @@ describe('PaceAppLayout Component', () => {
374
468
  });
375
469
 
376
470
  it('shows permission error when check fails', async () => {
377
- const { isPermitted } = await import('../../rbac/api');
378
- vi.mocked(isPermitted).mockRejectedValue(new Error('Permission check failed'));
471
+ // Mock useCan to return an error state
472
+ mockUseCan.mockReturnValueOnce({
473
+ can: false,
474
+ isLoading: false,
475
+ error: new Error('Permission check failed'),
476
+ refetch: vi.fn().mockResolvedValue(undefined),
477
+ });
379
478
 
380
479
  renderWithProviders(
381
480
  <TestWrapper>
@@ -390,8 +489,13 @@ describe('PaceAppLayout Component', () => {
390
489
  });
391
490
 
392
491
  it('shows access denied when user lacks permission', async () => {
393
- const { isPermitted } = await import('../../rbac/api');
394
- vi.mocked(isPermitted).mockResolvedValue(false);
492
+ // Mock useCan to return false (no permission)
493
+ mockUseCan.mockReturnValueOnce({
494
+ can: false,
495
+ isLoading: false,
496
+ error: null,
497
+ refetch: vi.fn().mockResolvedValue(undefined),
498
+ });
395
499
 
396
500
  renderWithProviders(
397
501
  <TestWrapper>
@@ -406,11 +510,16 @@ describe('PaceAppLayout Component', () => {
406
510
  });
407
511
 
408
512
  it('shows custom permission fallback when provided', async () => {
409
- const { isPermitted } = await import('../../rbac/api');
410
- vi.mocked(isPermitted).mockResolvedValue(false);
411
-
513
+ // Arrange
514
+ mockUseCan.mockReturnValueOnce({
515
+ can: false,
516
+ isLoading: false,
517
+ error: null,
518
+ refetch: vi.fn().mockResolvedValue(undefined),
519
+ });
412
520
  const customFallback = <div data-testid="custom-fallback">Custom Access Denied</div>;
413
521
 
522
+ // Act
414
523
  renderWithProviders(
415
524
  <TestWrapper>
416
525
  <PaceAppLayout
@@ -421,17 +530,23 @@ describe('PaceAppLayout Component', () => {
421
530
  </TestWrapper>
422
531
  );
423
532
 
533
+ // Assert - No need to wait for loading since mock returns immediately
424
534
  await waitFor(() => {
425
535
  expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
426
- }, { timeout: 3000 });
536
+ });
427
537
  });
428
538
 
429
539
  it('shows page permission fallback when enforcePagePermissions is true', async () => {
430
- const { isPermitted } = await import('../../rbac/api');
431
- vi.mocked(isPermitted).mockResolvedValue(false);
432
-
540
+ // Arrange
541
+ mockUseCan.mockReturnValueOnce({
542
+ can: false,
543
+ isLoading: false,
544
+ error: null,
545
+ refetch: vi.fn().mockResolvedValue(undefined),
546
+ });
433
547
  const pageFallback = <div data-testid="page-fallback">Page Access Denied</div>;
434
548
 
549
+ // Act
435
550
  renderWithProviders(
436
551
  <TestWrapper>
437
552
  <PaceAppLayout
@@ -443,9 +558,10 @@ describe('PaceAppLayout Component', () => {
443
558
  </TestWrapper>
444
559
  );
445
560
 
561
+ // Assert - No need to wait for loading since mock returns immediately
446
562
  await waitFor(() => {
447
563
  expect(screen.getByTestId('page-fallback')).toBeInTheDocument();
448
- }, { timeout: 3000 });
564
+ });
449
565
  });
450
566
  });
451
567
 
@@ -514,9 +630,6 @@ describe('PaceAppLayout Component', () => {
514
630
 
515
631
  describe('Route-Specific Permissions', () => {
516
632
  it('uses route-specific permissions when provided', async () => {
517
- const { isPermitted } = await import('../../rbac/api');
518
- vi.mocked(isPermitted).mockResolvedValue(true);
519
-
520
633
  renderWithProviders(
521
634
  <TestWrapper>
522
635
  <PaceAppLayout
@@ -533,23 +646,18 @@ describe('PaceAppLayout Component', () => {
533
646
  );
534
647
 
535
648
  await waitFor(() => {
536
- expect(vi.mocked(isPermitted)).toHaveBeenCalledWith({
537
- userId: 'user-123',
538
- scope: {
539
- organisationId: 'org-123',
540
- eventId: 'event-123',
541
- appId: 'app-123',
542
- },
543
- permission: 'update:page.dashboard-page',
544
- pageId: 'dashboard-page',
545
- });
546
- }, { timeout: 3000 });
547
- });
649
+ // useCan is called with userId, scope, permission, pageId, useCache
650
+ expect(mockUseCan).toHaveBeenCalledWith(
651
+ 'user-123',
652
+ expect.objectContaining({ organisationId: 'org-123' }),
653
+ 'update:page.dashboard-page',
654
+ 'dashboard-page',
655
+ true
656
+ );
657
+ }, { timeout: 5000 });
658
+ }, { timeout: 6000 });
548
659
 
549
660
  it('uses default permission when route not in routePermissions', async () => {
550
- const { isPermitted } = await import('../../rbac/api');
551
- vi.mocked(isPermitted).mockResolvedValue(true);
552
-
553
661
  renderWithProviders(
554
662
  <TestWrapper>
555
663
  <PaceAppLayout
@@ -564,18 +672,17 @@ describe('PaceAppLayout Component', () => {
564
672
  );
565
673
 
566
674
  await waitFor(() => {
567
- expect(vi.mocked(isPermitted)).toHaveBeenCalledWith({
568
- userId: 'user-123',
569
- scope: {
570
- organisationId: 'org-123',
571
- eventId: 'event-123',
572
- appId: 'app-123',
573
- },
574
- permission: 'create:page.dashboard',
575
- pageId: 'dashboard',
576
- });
577
- }, { timeout: 3000 });
578
- });
675
+ // useCan is called with userId, scope, permission, pageId, useCache
676
+ // Uses defaultPermission "create" since /dashboard is not in routePermissions
677
+ expect(mockUseCan).toHaveBeenCalledWith(
678
+ 'user-123',
679
+ expect.objectContaining({ organisationId: 'org-123' }),
680
+ 'create:page.dashboard',
681
+ 'dashboard',
682
+ true
683
+ );
684
+ }, { timeout: 5000 });
685
+ }, { timeout: 6000 });
579
686
  });
580
687
 
581
688
  describe('Super Admin Bypass', () => {
@@ -601,11 +708,16 @@ describe('PaceAppLayout Component', () => {
601
708
 
602
709
  describe('Callbacks and Event Handling', () => {
603
710
  it('calls onPageAccessDenied when access is denied', async () => {
604
- const { isPermitted } = await import('../../rbac/api');
605
- vi.mocked(isPermitted).mockResolvedValue(false);
606
-
711
+ // Arrange
607
712
  const onPageAccessDenied = vi.fn();
713
+ mockUseCan.mockReturnValueOnce({
714
+ can: false,
715
+ isLoading: false,
716
+ error: null,
717
+ refetch: vi.fn().mockResolvedValue(undefined),
718
+ });
608
719
 
720
+ // Act
609
721
  renderWithProviders(
610
722
  <TestWrapper>
611
723
  <PaceAppLayout
@@ -616,17 +728,23 @@ describe('PaceAppLayout Component', () => {
616
728
  </TestWrapper>
617
729
  );
618
730
 
731
+ // Assert - Callback should be called immediately when can is false
619
732
  await waitFor(() => {
620
733
  expect(onPageAccessDenied).toHaveBeenCalledWith('dashboard', 'read');
621
- }, { timeout: 3000 });
734
+ });
622
735
  });
623
736
 
624
737
  it('calls onStrictModeViolation when strict mode is violated', async () => {
625
- const { isPermitted } = await import('../../rbac/api');
626
- vi.mocked(isPermitted).mockResolvedValue(false);
627
-
738
+ // Arrange
628
739
  const onStrictModeViolation = vi.fn();
740
+ mockUseCan.mockReturnValueOnce({
741
+ can: false,
742
+ isLoading: false,
743
+ error: null,
744
+ refetch: vi.fn().mockResolvedValue(undefined),
745
+ });
629
746
 
747
+ // Act
630
748
  renderWithProviders(
631
749
  <TestWrapper>
632
750
  <PaceAppLayout
@@ -638,9 +756,10 @@ describe('PaceAppLayout Component', () => {
638
756
  </TestWrapper>
639
757
  );
640
758
 
759
+ // Assert - Callback should be called immediately when can is false
641
760
  await waitFor(() => {
642
761
  expect(onStrictModeViolation).toHaveBeenCalledWith('dashboard', 'read');
643
- }, { timeout: 3000 });
762
+ });
644
763
  });
645
764
  });
646
765
 
@@ -714,8 +833,14 @@ describe('PaceAppLayout Component', () => {
714
833
  // Mock the useUnifiedAuth hook to return null user
715
834
  vi.mocked(useUnifiedAuth).mockReturnValue(mockAuthWithoutUser);
716
835
 
717
- const { isPermitted } = await import('../../rbac/api');
718
- vi.mocked(isPermitted).mockResolvedValue(false);
836
+ // When there's no user, useCan is called with empty string userId
837
+ // and will return false (no permission)
838
+ mockUseCan.mockReturnValueOnce({
839
+ can: false,
840
+ isLoading: false,
841
+ error: null,
842
+ refetch: vi.fn().mockResolvedValue(undefined),
843
+ });
719
844
 
720
845
  renderWithProviders(
721
846
  <TestWrapper>
@@ -732,6 +857,7 @@ describe('PaceAppLayout Component', () => {
732
857
  });
733
858
 
734
859
  it('handles missing organisation context', async () => {
860
+ // Arrange
735
861
  const mockUserWithoutOrg = {
736
862
  ...mockUser,
737
863
  user_metadata: {},
@@ -743,12 +869,15 @@ describe('PaceAppLayout Component', () => {
743
869
  user: mockUserWithoutOrg,
744
870
  };
745
871
 
746
- // Mock the useUnifiedAuth hook to return user without org context
747
872
  vi.mocked(useUnifiedAuth).mockReturnValue(mockAuthWithoutOrg);
873
+ mockUseCan.mockReturnValueOnce({
874
+ can: false,
875
+ isLoading: false,
876
+ error: null,
877
+ refetch: vi.fn().mockResolvedValue(undefined),
878
+ });
748
879
 
749
- const { isPermitted } = await import('../../rbac/api');
750
- vi.mocked(isPermitted).mockResolvedValue(false);
751
-
880
+ // Act
752
881
  renderWithProviders(
753
882
  <TestWrapper>
754
883
  <PaceAppLayout
@@ -758,9 +887,10 @@ describe('PaceAppLayout Component', () => {
758
887
  </TestWrapper>
759
888
  );
760
889
 
890
+ // Assert - Component should show access denied
761
891
  await waitFor(() => {
762
- expect(screen.getByText('Access Denied')).toBeInTheDocument();
763
- }, { timeout: 3000 });
892
+ expect(screen.getByRole('heading', { name: 'Access Denied' })).toBeInTheDocument();
893
+ });
764
894
  });
765
895
  });
766
896