@jmruthers/pace-core 0.5.111 → 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 (155) hide show
  1. package/dist/{DataTable-5W2HVLLV.js → DataTable-3D3BUZDV.js} +8 -8
  2. package/dist/{UnifiedAuthProvider-LUM3QLS5.js → UnifiedAuthProvider-KZZUO27W.js} +3 -3
  3. package/dist/{api-SIZPFBFX.js → api-QPMBZZUZ.js} +3 -3
  4. package/dist/{audit-5JI5T3SL.js → audit-H4YJJF7R.js} +2 -2
  5. package/dist/{chunk-IWJYNWXN.js → chunk-3OGQLOJM.js} +11 -3
  6. package/dist/chunk-3OGQLOJM.js.map +1 -0
  7. package/dist/{chunk-TDFBX7KJ.js → chunk-7H75SHXZ.js} +2 -2
  8. package/dist/{chunk-EFVQBYFN.js → chunk-BUN7NMV7.js} +2 -2
  9. package/dist/{chunk-ACYQNYHB.js → chunk-C5RN4TE5.js} +7 -7
  10. package/dist/{chunk-2BIDKXQU.js → chunk-EKVVTPIF.js} +82 -23
  11. package/dist/chunk-EKVVTPIF.js.map +1 -0
  12. package/dist/{chunk-X7SPKHYZ.js → chunk-F6QB26OS.js} +4 -4
  13. package/dist/{chunk-UGVU7L7N.js → chunk-I7JC7PTJ.js} +6 -6
  14. package/dist/chunk-I7JC7PTJ.js.map +1 -0
  15. package/dist/{chunk-I5YM5BGS.js → chunk-L36JW4KV.js} +2 -2
  16. package/dist/{chunk-ZL45MG76.js → chunk-MNSGWRPB.js} +15 -15
  17. package/dist/{chunk-JE2GFA3O.js → chunk-NEONKMTU.js} +3 -3
  18. package/dist/{chunk-MW73E7SP.js → chunk-OO3V7W4H.js} +2 -2
  19. package/dist/chunk-OO3V7W4H.js.map +1 -0
  20. package/dist/{chunk-PXXS26G5.js → chunk-TAJRS6YB.js} +2 -2
  21. package/dist/{chunk-TD4BXGPE.js → chunk-WMPZY26G.js} +8 -4
  22. package/dist/{chunk-TD4BXGPE.js.map → chunk-WMPZY26G.js.map} +1 -1
  23. package/dist/components.js +10 -10
  24. package/dist/hooks.js +7 -7
  25. package/dist/index.js +13 -13
  26. package/dist/providers.js +2 -2
  27. package/dist/rbac/index.js +9 -9
  28. package/dist/utils.js +1 -1
  29. package/docs/api/classes/ColumnFactory.md +1 -1
  30. package/docs/api/classes/ErrorBoundary.md +1 -1
  31. package/docs/api/classes/InvalidScopeError.md +1 -1
  32. package/docs/api/classes/MissingUserContextError.md +1 -1
  33. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  34. package/docs/api/classes/PermissionDeniedError.md +1 -1
  35. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  36. package/docs/api/classes/RBACAuditManager.md +8 -8
  37. package/docs/api/classes/RBACCache.md +1 -1
  38. package/docs/api/classes/RBACEngine.md +1 -1
  39. package/docs/api/classes/RBACError.md +1 -1
  40. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  41. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  42. package/docs/api/classes/StorageUtils.md +1 -1
  43. package/docs/api/enums/FileCategory.md +1 -1
  44. package/docs/api/interfaces/AggregateConfig.md +1 -1
  45. package/docs/api/interfaces/ButtonProps.md +1 -1
  46. package/docs/api/interfaces/CardProps.md +1 -1
  47. package/docs/api/interfaces/ColorPalette.md +1 -1
  48. package/docs/api/interfaces/ColorShade.md +1 -1
  49. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  50. package/docs/api/interfaces/DataRecord.md +1 -1
  51. package/docs/api/interfaces/DataTableAction.md +1 -1
  52. package/docs/api/interfaces/DataTableColumn.md +1 -1
  53. package/docs/api/interfaces/DataTableProps.md +1 -1
  54. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  55. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  56. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  57. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  58. package/docs/api/interfaces/FileMetadata.md +1 -1
  59. package/docs/api/interfaces/FileReference.md +1 -1
  60. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  61. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  62. package/docs/api/interfaces/FileUploadProps.md +1 -1
  63. package/docs/api/interfaces/FooterProps.md +1 -1
  64. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  65. package/docs/api/interfaces/InputProps.md +1 -1
  66. package/docs/api/interfaces/LabelProps.md +1 -1
  67. package/docs/api/interfaces/LoginFormProps.md +1 -1
  68. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  69. package/docs/api/interfaces/NavigationContextType.md +1 -1
  70. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  71. package/docs/api/interfaces/NavigationItem.md +1 -1
  72. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  73. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  74. package/docs/api/interfaces/Organisation.md +1 -1
  75. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  76. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  77. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  78. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  79. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  80. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  81. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  82. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  83. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  84. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  85. package/docs/api/interfaces/PaletteData.md +1 -1
  86. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  87. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  88. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  89. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  90. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  91. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  92. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  93. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  94. package/docs/api/interfaces/RBACConfig.md +1 -1
  95. package/docs/api/interfaces/RBACLogger.md +1 -1
  96. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  97. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  98. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  99. package/docs/api/interfaces/RouteConfig.md +1 -1
  100. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  101. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  102. package/docs/api/interfaces/StorageConfig.md +1 -1
  103. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  104. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  105. package/docs/api/interfaces/StorageListOptions.md +1 -1
  106. package/docs/api/interfaces/StorageListResult.md +1 -1
  107. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  108. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  109. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  110. package/docs/api/interfaces/StyleImport.md +1 -1
  111. package/docs/api/interfaces/SwitchProps.md +1 -1
  112. package/docs/api/interfaces/ToastActionElement.md +1 -1
  113. package/docs/api/interfaces/ToastProps.md +1 -1
  114. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  115. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  116. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  117. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  118. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  119. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  120. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  121. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  122. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  123. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  124. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  125. package/docs/api/interfaces/UserEventAccess.md +1 -1
  126. package/docs/api/interfaces/UserMenuProps.md +1 -1
  127. package/docs/api/interfaces/UserProfile.md +1 -1
  128. package/docs/api/modules.md +12 -12
  129. package/package.json +1 -1
  130. package/src/components/DataTable/DataTable.test.tsx +405 -154
  131. package/src/components/DataTable/components/DataTableCore.tsx +6 -1
  132. package/src/components/EventSelector/EventSelector.tsx +32 -2
  133. package/src/components/NavigationMenu/NavigationMenu.test.tsx +56 -8
  134. package/src/components/NavigationMenu/NavigationMenu.tsx +75 -12
  135. package/src/rbac/audit-enhanced.ts +14 -2
  136. package/src/rbac/audit.test.ts +16 -6
  137. package/src/rbac/audit.ts +11 -1
  138. package/src/rbac/hooks/usePermissions.ts +18 -2
  139. package/src/services/EventService.ts +3 -2
  140. package/dist/chunk-2BIDKXQU.js.map +0 -1
  141. package/dist/chunk-IWJYNWXN.js.map +0 -1
  142. package/dist/chunk-MW73E7SP.js.map +0 -1
  143. package/dist/chunk-UGVU7L7N.js.map +0 -1
  144. /package/dist/{DataTable-5W2HVLLV.js.map → DataTable-3D3BUZDV.js.map} +0 -0
  145. /package/dist/{UnifiedAuthProvider-LUM3QLS5.js.map → UnifiedAuthProvider-KZZUO27W.js.map} +0 -0
  146. /package/dist/{api-SIZPFBFX.js.map → api-QPMBZZUZ.js.map} +0 -0
  147. /package/dist/{audit-5JI5T3SL.js.map → audit-H4YJJF7R.js.map} +0 -0
  148. /package/dist/{chunk-TDFBX7KJ.js.map → chunk-7H75SHXZ.js.map} +0 -0
  149. /package/dist/{chunk-EFVQBYFN.js.map → chunk-BUN7NMV7.js.map} +0 -0
  150. /package/dist/{chunk-ACYQNYHB.js.map → chunk-C5RN4TE5.js.map} +0 -0
  151. /package/dist/{chunk-X7SPKHYZ.js.map → chunk-F6QB26OS.js.map} +0 -0
  152. /package/dist/{chunk-I5YM5BGS.js.map → chunk-L36JW4KV.js.map} +0 -0
  153. /package/dist/{chunk-ZL45MG76.js.map → chunk-MNSGWRPB.js.map} +0 -0
  154. /package/dist/{chunk-JE2GFA3O.js.map → chunk-NEONKMTU.js.map} +0 -0
  155. /package/dist/{chunk-PXXS26G5.js.map → chunk-TAJRS6YB.js.map} +0 -0
@@ -911,8 +911,13 @@ function DataTableInternal<TData extends DataRecord>({
911
911
  throw new Error('DataTable requires authenticated user for RBAC');
912
912
  }
913
913
 
914
+ // Wait for permission check to complete before making access decisions
915
+ if (permissions.canRead.isLoading) {
916
+ console.log('[DataTable] ⏳ Permission check in progress - showing loading state');
917
+ return <LoadingComponent />;
918
+ }
914
919
 
915
- // MANDATORY: No data access without read permission
920
+ // MANDATORY: No data access without read permission (only check after loading completes)
916
921
  if (!permissions.canRead.can) {
917
922
  console.warn('[DataTable] 🚫 Access denied - no read permission:', {
918
923
  canRead: permissions.canRead,
@@ -215,8 +215,38 @@ export function EventSelector({
215
215
  }, [events]);
216
216
 
217
217
  // Default to the next upcoming event if none selected, fallback to most recent past event
218
+ // IMPORTANT: Only auto-select if there's no persisted event being restored
218
219
  useEffect(() => {
219
- if (!selectedEvent && events.length > 0) {
220
+ // Only auto-select if events are loaded, no event is selected, and not currently loading
221
+ if (!selectedEvent && events.length > 0 && !isLoading) {
222
+ // Check if there's a persisted event that should be loaded
223
+ // If persisted event exists in storage, don't auto-select - let it load first
224
+ const persistedEventId = localStorage.getItem('pace-core-selected-event') || sessionStorage.getItem('pace-core-selected-event');
225
+
226
+ if (persistedEventId) {
227
+ // Check if the persisted event is in the available events list
228
+ const persistedEvent = events.find(e => e.event_id === persistedEventId);
229
+
230
+ if (persistedEvent) {
231
+ // Persisted event exists in available events - it should have been loaded by EventService
232
+ // If it's still not selected, there might be an issue, but don't auto-select as a fallback
233
+ // The EventService should have handled this during initialization
234
+ console.debug('[EventSelector] Persisted event found in storage but not selected:', persistedEventId);
235
+ return;
236
+ } else {
237
+ // Persisted event ID exists but event is not in available events list
238
+ // This means the user no longer has access to that event - clear it and auto-select
239
+ localStorage.removeItem('pace-core-selected-event');
240
+ sessionStorage.removeItem('pace-core-selected-event');
241
+ autoSelectEvent();
242
+ }
243
+ } else {
244
+ // No persisted event - safe to auto-select (new user or first visit)
245
+ autoSelectEvent();
246
+ }
247
+ }
248
+
249
+ function autoSelectEvent() {
220
250
  const today = new Date();
221
251
  const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime();
222
252
 
@@ -245,7 +275,7 @@ export function EventSelector({
245
275
  }
246
276
  }
247
277
  }
248
- }, [events, selectedEvent, setSelectedEvent, onEventChange]);
278
+ }, [events, selectedEvent, setSelectedEvent, onEventChange, isLoading]);
249
279
 
250
280
  // Loading state
251
281
  if (isLoading) {
@@ -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,
@@ -163,19 +163,31 @@ export class EnhancedRBACAuditManager {
163
163
  }
164
164
 
165
165
  try {
166
+ // Validate pageId: only include in page_id column if it's a valid UUID
167
+ // Otherwise, store it in metadata to avoid database errors
168
+ const rawPageId = 'pageId' in event ? event.pageId : undefined;
169
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
170
+ const isValidPageIdUuid = rawPageId && uuidRegex.test(rawPageId);
171
+ const pageIdUuid: UUID | undefined = isValidPageIdUuid ? (rawPageId as UUID) : undefined;
172
+ const pageIdName: string | undefined = rawPageId && !isValidPageIdUuid ? rawPageId : undefined;
173
+
166
174
  const auditEvent: Omit<RBACAuditEvent, 'id' | 'created_at'> = {
167
175
  event_type: event.type,
168
176
  user_id: event.userId,
169
177
  organisation_id: event.organisationId,
170
178
  event_id: 'eventId' in event ? event.eventId : undefined,
171
179
  app_id: 'appId' in event ? event.appId : undefined,
172
- page_id: 'pageId' in event ? event.pageId : undefined,
180
+ page_id: pageIdUuid, // Only set if it's a valid UUID
173
181
  permission: 'permission' in event ? event.permission : undefined,
174
182
  decision: 'decision' in event ? event.decision : undefined,
175
183
  source: 'source' in event ? event.source : 'api', // Default to 'api' if not provided
176
184
  bypass: 'bypass' in event ? event.bypass : undefined,
177
185
  duration_ms: 'duration_ms' in event ? event.duration_ms : undefined,
178
- metadata: event.metadata || {},
186
+ metadata: {
187
+ ...(event.metadata || {}),
188
+ // Store page name/identifier in metadata if it's not a UUID
189
+ page_name: pageIdName,
190
+ },
179
191
  };
180
192
 
181
193
  const { error } = await (this.supabase as any)
@@ -68,13 +68,15 @@ describe('RBACAuditManager', () => {
68
68
 
69
69
  describe('Permission Check Events', () => {
70
70
  it('emits permission check events correctly', async () => {
71
+ // Use a valid UUID format for pageId
72
+ const validPageId = '01234567-89ab-cdef-0123-456789abcdef' as UUID;
71
73
  const event: PermissionCheckAuditEvent = {
72
74
  type: 'permission_check',
73
75
  userId: 'user-123' as UUID,
74
76
  organisationId: 'org-456' as UUID,
75
77
  eventId: 'event-789',
76
78
  appId: 'app-101' as UUID,
77
- pageId: 'page-202' as UUID,
79
+ pageId: validPageId,
78
80
  permission: 'read:users',
79
81
  decision: true,
80
82
  source: 'api' as AuditEventSource,
@@ -93,13 +95,16 @@ describe('RBACAuditManager', () => {
93
95
  organisation_id: 'org-456',
94
96
  event_id: 'event-789',
95
97
  app_id: 'app-101',
96
- page_id: 'page-202',
98
+ page_id: validPageId,
97
99
  permission: 'read:users',
98
100
  decision: true,
99
101
  source: 'api',
100
102
  bypass: false,
101
103
  duration_ms: 150,
102
- metadata: expect.objectContaining({ ip: '192.168.1.1' })
104
+ metadata: expect.objectContaining({
105
+ ip: '192.168.1.1',
106
+ no_organisation_context: false
107
+ })
103
108
  })
104
109
  ]
105
110
  );
@@ -136,13 +141,15 @@ describe('RBACAuditManager', () => {
136
141
 
137
142
  describe('Permission Denied Events', () => {
138
143
  it('emits permission denied events correctly', async () => {
144
+ // Use a valid UUID format for pageId
145
+ const validPageId = '01234567-89ab-cdef-0123-456789abcdef' as UUID;
139
146
  const event: PermissionDeniedAuditEvent = {
140
147
  type: 'permission_denied',
141
148
  userId: 'user-123' as UUID,
142
149
  organisationId: 'org-456' as UUID,
143
150
  eventId: 'event-789',
144
151
  appId: 'app-101' as UUID,
145
- pageId: 'page-202' as UUID,
152
+ pageId: validPageId,
146
153
  permission: 'update:users',
147
154
  source: 'api' as AuditEventSource,
148
155
  metadata: { reason: 'Insufficient role' }
@@ -158,10 +165,13 @@ describe('RBACAuditManager', () => {
158
165
  organisation_id: 'org-456',
159
166
  event_id: 'event-789',
160
167
  app_id: 'app-101',
161
- page_id: 'page-202',
168
+ page_id: validPageId,
162
169
  permission: 'update:users',
163
170
  source: 'api',
164
- metadata: expect.objectContaining({ reason: 'Insufficient role' })
171
+ metadata: expect.objectContaining({
172
+ reason: 'Insufficient role',
173
+ no_organisation_context: false
174
+ })
165
175
  })
166
176
  ]
167
177
  );
package/src/rbac/audit.ts CHANGED
@@ -173,6 +173,14 @@ export class RBACAuditManager {
173
173
  });
174
174
  }
175
175
 
176
+ // Validate pageId: only include in page_id column if it's a valid UUID
177
+ // Otherwise, store it in metadata to avoid database errors
178
+ const rawPageId = 'pageId' in event ? event.pageId : undefined;
179
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
180
+ const isValidPageIdUuid = rawPageId && uuidRegex.test(rawPageId);
181
+ const pageIdUuid: UUID | undefined = isValidPageIdUuid ? (rawPageId as UUID) : undefined;
182
+ const pageIdName: string | undefined = rawPageId && !isValidPageIdUuid ? rawPageId : undefined;
183
+
176
184
  const auditEvent: Omit<RBACAuditEvent, 'id' | 'created_at'> = {
177
185
  event_type: event.type,
178
186
  user_id: event.userId,
@@ -181,7 +189,7 @@ export class RBACAuditManager {
181
189
  organisation_id: event.organisationId || null, // Explicitly null if missing
182
190
  event_id: 'eventId' in event ? event.eventId : undefined,
183
191
  app_id: 'appId' in event ? event.appId : undefined,
184
- page_id: 'pageId' in event ? event.pageId : undefined,
192
+ page_id: pageIdUuid, // Only set if it's a valid UUID
185
193
  permission: 'permission' in event ? event.permission : undefined,
186
194
  decision: 'decision' in event ? event.decision : undefined,
187
195
  source: 'source' in event ? event.source : 'api', // Default to 'api' if not provided
@@ -193,6 +201,8 @@ export class RBACAuditManager {
193
201
  cache_source: 'cache_source' in event ? event.cache_source : undefined,
194
202
  // Explicit flag indicating this event had no organisation context
195
203
  no_organisation_context: !event.organisationId,
204
+ // Store page name/identifier in metadata if it's not a UUID
205
+ page_name: pageIdName,
196
206
  },
197
207
  };
198
208
 
@@ -83,8 +83,9 @@ export function usePermissions(userId: UUID, scope: Scope) {
83
83
 
84
84
  // Don't fetch permissions if scope is invalid (e.g., organisationId is null/empty)
85
85
  // Wait for organisation context to resolve
86
+ // IMPORTANT: Don't clear existing permissions here - keep them until we have new ones
86
87
  if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
87
- setPermissions({} as PermissionMap);
88
+ // Keep existing permissions, just mark as loading
88
89
  setIsLoading(true);
89
90
  setError(null);
90
91
  return;
@@ -95,10 +96,17 @@ export function usePermissions(userId: UUID, scope: Scope) {
95
96
  setIsLoading(true);
96
97
  setError(null);
97
98
 
99
+ // Fetch new permissions - don't clear old ones until we have new ones
98
100
  const permissionMap = await getPermissionMap({ userId, scope });
101
+
102
+ // Only update permissions if fetch was successful
99
103
  setPermissions(permissionMap);
100
104
  } catch (err) {
105
+ // On error, keep existing permissions but set error state
106
+ // This prevents the UI from losing all items when there's a transient error
107
+ console.error('[usePermissions] Failed to fetch permissions:', err);
101
108
  setError(err instanceof Error ? err : new Error('Failed to fetch permissions'));
109
+ // Don't clear permissions on error - keep what we had
102
110
  } finally {
103
111
  setIsLoading(false);
104
112
  isFetchingRef.current = false;
@@ -142,8 +150,9 @@ export function usePermissions(userId: UUID, scope: Scope) {
142
150
  }
143
151
 
144
152
  // Don't fetch permissions if scope is invalid (e.g., organisationId is null/empty)
153
+ // IMPORTANT: Don't clear existing permissions - keep them until we have new ones
145
154
  if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
146
- setPermissions({} as PermissionMap);
155
+ // Keep existing permissions, just mark as loading
147
156
  setIsLoading(true);
148
157
  setError(null);
149
158
  return;
@@ -154,10 +163,17 @@ export function usePermissions(userId: UUID, scope: Scope) {
154
163
  setIsLoading(true);
155
164
  setError(null);
156
165
 
166
+ // Fetch new permissions - don't clear old ones until we have new ones
157
167
  const permissionMap = await getPermissionMap({ userId, scope });
168
+
169
+ // Only update permissions if fetch was successful
158
170
  setPermissions(permissionMap);
159
171
  } catch (err) {
172
+ // On error, keep existing permissions but set error state
173
+ // This prevents the UI from losing all items when there's a transient error
174
+ console.error('[usePermissions] Failed to refetch permissions:', err);
160
175
  setError(err instanceof Error ? err : new Error('Failed to fetch permissions'));
176
+ // Don't clear permissions on error - keep what we had
161
177
  } finally {
162
178
  setIsLoading(false);
163
179
  isFetchingRef.current = false;
@@ -248,8 +248,9 @@ export class EventService extends BaseService implements IEventService {
248
248
  // Lifecycle methods
249
249
  async initialize(): Promise<void> {
250
250
  await super.initialize();
251
- // Skip loading persisted event initially - it will be called explicitly after login screen renders
252
- await this.fetchEvents(true);
251
+ // Load persisted event during initialization (don't skip)
252
+ // This ensures the last viewed event is restored before auto-selection happens
253
+ await this.fetchEvents(false);
253
254
  }
254
255
 
255
256
  cleanup(): void {