@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.
- package/dist/{DataTable-5W2HVLLV.js → DataTable-3D3BUZDV.js} +8 -8
- package/dist/{UnifiedAuthProvider-LUM3QLS5.js → UnifiedAuthProvider-KZZUO27W.js} +3 -3
- package/dist/{api-SIZPFBFX.js → api-QPMBZZUZ.js} +3 -3
- package/dist/{audit-5JI5T3SL.js → audit-H4YJJF7R.js} +2 -2
- package/dist/{chunk-IWJYNWXN.js → chunk-3OGQLOJM.js} +11 -3
- package/dist/chunk-3OGQLOJM.js.map +1 -0
- package/dist/{chunk-TDFBX7KJ.js → chunk-7H75SHXZ.js} +2 -2
- package/dist/{chunk-EFVQBYFN.js → chunk-BUN7NMV7.js} +2 -2
- package/dist/{chunk-ACYQNYHB.js → chunk-C5RN4TE5.js} +7 -7
- package/dist/{chunk-2BIDKXQU.js → chunk-EKVVTPIF.js} +82 -23
- package/dist/chunk-EKVVTPIF.js.map +1 -0
- package/dist/{chunk-X7SPKHYZ.js → chunk-F6QB26OS.js} +4 -4
- package/dist/{chunk-UGVU7L7N.js → chunk-I7JC7PTJ.js} +6 -6
- package/dist/chunk-I7JC7PTJ.js.map +1 -0
- package/dist/{chunk-I5YM5BGS.js → chunk-L36JW4KV.js} +2 -2
- package/dist/{chunk-ZL45MG76.js → chunk-MNSGWRPB.js} +15 -15
- package/dist/{chunk-JE2GFA3O.js → chunk-NEONKMTU.js} +3 -3
- package/dist/{chunk-MW73E7SP.js → chunk-OO3V7W4H.js} +2 -2
- package/dist/chunk-OO3V7W4H.js.map +1 -0
- package/dist/{chunk-PXXS26G5.js → chunk-TAJRS6YB.js} +2 -2
- package/dist/{chunk-TD4BXGPE.js → chunk-WMPZY26G.js} +8 -4
- package/dist/{chunk-TD4BXGPE.js.map → chunk-WMPZY26G.js.map} +1 -1
- package/dist/components.js +10 -10
- package/dist/hooks.js +7 -7
- package/dist/index.js +13 -13
- package/dist/providers.js +2 -2
- package/dist/rbac/index.js +9 -9
- package/dist/utils.js +1 -1
- package/docs/api/classes/ColumnFactory.md +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +1 -1
- package/docs/api/classes/MissingUserContextError.md +1 -1
- package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
- package/docs/api/classes/PermissionDeniedError.md +1 -1
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +8 -8
- package/docs/api/classes/RBACCache.md +1 -1
- package/docs/api/classes/RBACEngine.md +1 -1
- package/docs/api/classes/RBACError.md +1 -1
- package/docs/api/classes/RBACNotInitializedError.md +1 -1
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/classes/StorageUtils.md +1 -1
- package/docs/api/enums/FileCategory.md +1 -1
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +1 -1
- package/docs/api/interfaces/DataRecord.md +1 -1
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- package/docs/api/interfaces/FileDisplayProps.md +1 -1
- package/docs/api/interfaces/FileMetadata.md +1 -1
- package/docs/api/interfaces/FileReference.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadOptions.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +1 -1
- package/docs/api/interfaces/RBACLogger.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RouteAccessRecord.md +1 -1
- package/docs/api/interfaces/RouteConfig.md +1 -1
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/SwitchProps.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +12 -12
- package/package.json +1 -1
- package/src/components/DataTable/DataTable.test.tsx +405 -154
- package/src/components/DataTable/components/DataTableCore.tsx +6 -1
- package/src/components/EventSelector/EventSelector.tsx +32 -2
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +56 -8
- package/src/components/NavigationMenu/NavigationMenu.tsx +75 -12
- package/src/rbac/audit-enhanced.ts +14 -2
- package/src/rbac/audit.test.ts +16 -6
- package/src/rbac/audit.ts +11 -1
- package/src/rbac/hooks/usePermissions.ts +18 -2
- package/src/services/EventService.ts +3 -2
- package/dist/chunk-2BIDKXQU.js.map +0 -1
- package/dist/chunk-IWJYNWXN.js.map +0 -1
- package/dist/chunk-MW73E7SP.js.map +0 -1
- package/dist/chunk-UGVU7L7N.js.map +0 -1
- /package/dist/{DataTable-5W2HVLLV.js.map → DataTable-3D3BUZDV.js.map} +0 -0
- /package/dist/{UnifiedAuthProvider-LUM3QLS5.js.map → UnifiedAuthProvider-KZZUO27W.js.map} +0 -0
- /package/dist/{api-SIZPFBFX.js.map → api-QPMBZZUZ.js.map} +0 -0
- /package/dist/{audit-5JI5T3SL.js.map → audit-H4YJJF7R.js.map} +0 -0
- /package/dist/{chunk-TDFBX7KJ.js.map → chunk-7H75SHXZ.js.map} +0 -0
- /package/dist/{chunk-EFVQBYFN.js.map → chunk-BUN7NMV7.js.map} +0 -0
- /package/dist/{chunk-ACYQNYHB.js.map → chunk-C5RN4TE5.js.map} +0 -0
- /package/dist/{chunk-X7SPKHYZ.js.map → chunk-F6QB26OS.js.map} +0 -0
- /package/dist/{chunk-I5YM5BGS.js.map → chunk-L36JW4KV.js.map} +0 -0
- /package/dist/{chunk-ZL45MG76.js.map → chunk-MNSGWRPB.js.map} +0 -0
- /package/dist/{chunk-JE2GFA3O.js.map → chunk-NEONKMTU.js.map} +0 -0
- /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
|
-
|
|
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) =>
|
|
494
|
-
|
|
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) =>
|
|
643
|
-
|
|
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) =>
|
|
707
|
-
|
|
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) =>
|
|
901
|
-
|
|
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
|
|
452
|
-
const { permissions: permissionMap, hasAnyPermission, isLoading: permissionsLoading } = usePermissions(
|
|
487
|
+
const { permissions: permissionMap, hasAnyPermission, isLoading: permissionsLoading, error: permissionsError } = usePermissions(
|
|
453
488
|
userId as any,
|
|
454
|
-
|
|
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
|
|
461
|
-
|
|
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
|
|
557
|
-
//
|
|
558
|
-
if
|
|
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
|
-
|
|
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 (!
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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)
|
package/src/rbac/audit.test.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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({
|
|
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:
|
|
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:
|
|
168
|
+
page_id: validPageId,
|
|
162
169
|
permission: 'update:users',
|
|
163
170
|
source: 'api',
|
|
164
|
-
metadata: expect.objectContaining({
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
252
|
-
|
|
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 {
|