@jmruthers/pace-core 0.5.106 → 0.5.108
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-BE0OXZKQ.d.ts → DataTable-D5cBRca8.d.ts} +1 -1
- package/dist/{DataTable-LWHFLTEW.js → DataTable-WFCHVWTY.js} +3 -3
- package/dist/{PublicLoadingSpinner-48ewSMKK.d.ts → PublicLoadingSpinner-DgDWTFqn.d.ts} +4 -2
- package/dist/{chunk-QPCAGLUS.js → chunk-4OX5PXHX.js} +5 -2
- package/dist/chunk-4OX5PXHX.js.map +1 -0
- package/dist/{chunk-IBZBNBTE.js → chunk-B3QX32P5.js} +177 -54
- package/dist/chunk-B3QX32P5.js.map +1 -0
- package/dist/{chunk-75G3NZWN.js → chunk-IMZGJ2X7.js} +373 -95
- package/dist/chunk-IMZGJ2X7.js.map +1 -0
- package/dist/{chunk-4BWGRQBG.js → chunk-NFPV7MRN.js} +22 -2
- package/dist/chunk-NFPV7MRN.js.map +1 -0
- package/dist/components.d.ts +4 -4
- package/dist/components.js +3 -3
- package/dist/{formatting-BfDeV-ja.d.ts → formatting-BiEv5oEk.d.ts} +32 -2
- package/dist/hooks.d.ts +2 -2
- package/dist/hooks.js +1 -1
- package/dist/index.d.ts +6 -6
- package/dist/index.js +4 -4
- package/dist/{types-BDg1mAGG.d.ts → types-D4TVpDa1.d.ts} +24 -1
- package/dist/{useToast-Bm6TnSK-.d.ts → useToast-DRah6K-g.d.ts} +5 -2
- package/dist/utils.d.ts +3 -3
- package/dist/utils.js +2 -2
- 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 +1 -1
- 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 +4 -4
- 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 +18 -18
- package/docs/api/interfaces/DataTableColumn.md +115 -10
- package/docs/api/interfaces/DataTableProps.md +38 -38
- package/docs/api/interfaces/DataTableToolbarButton.md +7 -7
- package/docs/api/interfaces/EmptyStateConfig.md +5 -5
- 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 +42 -19
- package/docs/api-reference/utilities.md +26 -3
- package/docs/implementation-guides/data-tables.md +390 -0
- package/package.json +1 -1
- package/src/components/DataTable/DataTable.tsx +4 -0
- package/src/components/DataTable/__tests__/DataTableCore.test.tsx +25 -10
- package/src/components/DataTable/components/ColumnFilter.tsx +2 -1
- package/src/components/DataTable/components/EditableRow.tsx +179 -16
- package/src/components/DataTable/components/FilterRow.tsx +22 -11
- package/src/components/DataTable/components/PaginationControls.tsx +1 -1
- package/src/components/DataTable/components/UnifiedTableBody.tsx +231 -32
- package/src/components/DataTable/types.ts +34 -4
- package/src/components/FileDisplay/FileDisplay.test.tsx +184 -201
- package/src/components/FileDisplay/FileDisplay.tsx +40 -39
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +189 -13
- package/src/components/NavigationMenu/NavigationMenu.tsx +142 -35
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +79 -10
- package/src/components/PublicLayout/__tests__/PublicPageHeader.test.tsx +4 -4
- package/src/components/Toast/Toast.tsx +1 -1
- package/src/hooks/useEventTheme.test.ts +11 -0
- package/src/hooks/useSecureDataAccess.test.ts +22 -5
- package/src/hooks/useToast.ts +11 -2
- package/src/providers/UnifiedAuthProvider.smoke.test.tsx +67 -3
- package/src/providers/__tests__/ProviderLifecycle.test.tsx +72 -4
- package/src/services/__tests__/OrganisationService.pagination.test.ts +10 -2
- package/src/styles/core.css +11 -0
- package/src/utils/__tests__/formatting.unit.test.ts +33 -0
- package/src/utils/file-reference.test.ts +44 -5
- package/src/utils/formatting.ts +57 -2
- package/src/validation/__tests__/passwordSchema.unit.test.ts +3 -3
- package/dist/chunk-4BWGRQBG.js.map +0 -1
- package/dist/chunk-75G3NZWN.js.map +0 -1
- package/dist/chunk-IBZBNBTE.js.map +0 -1
- package/dist/chunk-QPCAGLUS.js.map +0 -1
- /package/dist/{DataTable-LWHFLTEW.js.map → DataTable-WFCHVWTY.js.map} +0 -0
|
@@ -21,9 +21,11 @@ const mockAuthContext = {
|
|
|
21
21
|
refreshSession: vi.fn(),
|
|
22
22
|
appName: 'Test App',
|
|
23
23
|
hasErrors: false,
|
|
24
|
-
selectedOrganisation: null,
|
|
24
|
+
selectedOrganisation: { id: 'test-org-1', name: 'Test Org', display_name: 'Test Organisation', subscription_tier: 'basic', settings: {}, is_active: true, parent_id: null, created_at: '', updated_at: '' },
|
|
25
25
|
organisations: [],
|
|
26
|
+
selectedEvent: null,
|
|
26
27
|
events: [],
|
|
28
|
+
supabase: {},
|
|
27
29
|
// Note: hasPermission, hasRole, hasAccessLevel, permissions, roles, and accessLevel
|
|
28
30
|
// were removed from UnifiedAuthProvider. Use useRBAC() hook for permissions instead.
|
|
29
31
|
// Inactivity context
|
|
@@ -39,6 +41,50 @@ vi.mock('../../providers/UnifiedAuthProvider', () => ({
|
|
|
39
41
|
useUnifiedAuth: vi.fn(() => mockAuthContext),
|
|
40
42
|
}));
|
|
41
43
|
|
|
44
|
+
// Mock RBAC hooks with hoisted mocks so they can be controlled per test
|
|
45
|
+
const mockUseRBAC = vi.hoisted(() => vi.fn(() => ({
|
|
46
|
+
user: { id: 'test-user', email: 'test@example.com' },
|
|
47
|
+
globalRole: null,
|
|
48
|
+
organisationRole: null,
|
|
49
|
+
eventAppRole: null,
|
|
50
|
+
hasPermission: vi.fn(),
|
|
51
|
+
hasGlobalPermission: vi.fn(),
|
|
52
|
+
isSuperAdmin: false,
|
|
53
|
+
isOrgAdmin: false,
|
|
54
|
+
isEventAdmin: false,
|
|
55
|
+
canManageOrganisation: false,
|
|
56
|
+
canManageEvent: false,
|
|
57
|
+
isLoading: false,
|
|
58
|
+
error: null,
|
|
59
|
+
})));
|
|
60
|
+
|
|
61
|
+
const mockUseResolvedScope = vi.hoisted(() => vi.fn(() => ({
|
|
62
|
+
resolvedScope: { organisationId: 'test-org-1', eventId: undefined, appId: undefined },
|
|
63
|
+
isLoading: false,
|
|
64
|
+
})));
|
|
65
|
+
|
|
66
|
+
const mockUsePermissions = vi.hoisted(() => vi.fn(() => ({
|
|
67
|
+
permissions: {} as any,
|
|
68
|
+
isLoading: false,
|
|
69
|
+
error: null,
|
|
70
|
+
hasPermission: vi.fn(() => false),
|
|
71
|
+
hasAnyPermission: vi.fn(() => false),
|
|
72
|
+
hasAllPermissions: vi.fn(() => false),
|
|
73
|
+
refetch: vi.fn(),
|
|
74
|
+
})));
|
|
75
|
+
|
|
76
|
+
vi.mock('../../rbac/hooks/useRBAC', () => ({
|
|
77
|
+
useRBAC: mockUseRBAC,
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
vi.mock('../../rbac/hooks', () => ({
|
|
81
|
+
useResolvedScope: mockUseResolvedScope,
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
vi.mock('../../rbac/hooks/usePermissions', () => ({
|
|
85
|
+
usePermissions: mockUsePermissions,
|
|
86
|
+
}));
|
|
87
|
+
|
|
42
88
|
// Mock console methods to avoid noise in tests
|
|
43
89
|
const originalConsoleLog = console.log;
|
|
44
90
|
const originalConsoleWarn = console.warn;
|
|
@@ -381,15 +427,41 @@ describe('NavigationMenu Component', () => {
|
|
|
381
427
|
// Permission-based filtering tests
|
|
382
428
|
describe('Permission-Based Filtering', () => {
|
|
383
429
|
beforeEach(() => {
|
|
384
|
-
// Note: Permission checks are currently disabled in NavigationMenu
|
|
385
|
-
// until migrated to useRBAC() hook. See NavigationMenu.tsx for TODOs.
|
|
386
430
|
vi.clearAllMocks();
|
|
387
431
|
});
|
|
388
432
|
|
|
389
|
-
it
|
|
390
|
-
// TODO: This test is skipped because NavigationMenu permission filtering is temporarily disabled
|
|
391
|
-
// until migrated to useRBAC() hook. See NavigationMenu.tsx for TODOs.
|
|
433
|
+
it('renders items with permission requirements', async () => {
|
|
392
434
|
const user = userEvent.setup();
|
|
435
|
+
|
|
436
|
+
// Mock RBAC hooks to grant permissions and roles
|
|
437
|
+
mockUseRBAC.mockReturnValue({
|
|
438
|
+
user: { id: 'test-user', email: 'test@example.com' },
|
|
439
|
+
globalRole: null,
|
|
440
|
+
organisationRole: 'org_admin' as any,
|
|
441
|
+
eventAppRole: 'event_admin' as any,
|
|
442
|
+
hasPermission: vi.fn(),
|
|
443
|
+
hasGlobalPermission: vi.fn(),
|
|
444
|
+
isSuperAdmin: false,
|
|
445
|
+
isOrgAdmin: true,
|
|
446
|
+
isEventAdmin: true,
|
|
447
|
+
canManageOrganisation: true,
|
|
448
|
+
canManageEvent: true,
|
|
449
|
+
isLoading: false,
|
|
450
|
+
error: null,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
mockUsePermissions.mockReturnValue({
|
|
454
|
+
permissions: {
|
|
455
|
+
'dashboard:read': true,
|
|
456
|
+
} as any,
|
|
457
|
+
isLoading: false,
|
|
458
|
+
error: null,
|
|
459
|
+
hasPermission: vi.fn((p: any) => p === 'dashboard:read'),
|
|
460
|
+
hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => p === 'dashboard:read')),
|
|
461
|
+
hasAllPermissions: vi.fn(() => true),
|
|
462
|
+
refetch: vi.fn(),
|
|
463
|
+
});
|
|
464
|
+
|
|
393
465
|
renderWithProviders(
|
|
394
466
|
<NavigationMenu
|
|
395
467
|
items={permissionBasedNavItems}
|
|
@@ -507,11 +579,38 @@ describe('NavigationMenu Component', () => {
|
|
|
507
579
|
expect(window.location.href).toBe('/');
|
|
508
580
|
});
|
|
509
581
|
|
|
510
|
-
it
|
|
511
|
-
// TODO: This test is skipped because NavigationMenu permission filtering is temporarily disabled
|
|
512
|
-
// until migrated to useRBAC() hook. See NavigationMenu.tsx for TODOs.
|
|
582
|
+
it('handles permission-based navigation', async () => {
|
|
513
583
|
const user = userEvent.setup();
|
|
514
584
|
|
|
585
|
+
// Mock RBAC hooks to grant permissions and roles
|
|
586
|
+
mockUseRBAC.mockReturnValue({
|
|
587
|
+
user: { id: 'test-user', email: 'test@example.com' },
|
|
588
|
+
globalRole: null,
|
|
589
|
+
organisationRole: 'org_admin' as any,
|
|
590
|
+
eventAppRole: 'event_admin' as any,
|
|
591
|
+
hasPermission: vi.fn(),
|
|
592
|
+
hasGlobalPermission: vi.fn(),
|
|
593
|
+
isSuperAdmin: false,
|
|
594
|
+
isOrgAdmin: true,
|
|
595
|
+
isEventAdmin: true,
|
|
596
|
+
canManageOrganisation: true,
|
|
597
|
+
canManageEvent: true,
|
|
598
|
+
isLoading: false,
|
|
599
|
+
error: null,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
mockUsePermissions.mockReturnValue({
|
|
603
|
+
permissions: {
|
|
604
|
+
'dashboard:read': true,
|
|
605
|
+
} as any,
|
|
606
|
+
isLoading: false,
|
|
607
|
+
error: null,
|
|
608
|
+
hasPermission: vi.fn((p: any) => p === 'dashboard:read'),
|
|
609
|
+
hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => p === 'dashboard:read')),
|
|
610
|
+
hasAllPermissions: vi.fn(() => true),
|
|
611
|
+
refetch: vi.fn(),
|
|
612
|
+
});
|
|
613
|
+
|
|
515
614
|
renderWithProviders(
|
|
516
615
|
<NavigationMenu
|
|
517
616
|
items={permissionBasedNavItems}
|
|
@@ -530,12 +629,52 @@ describe('NavigationMenu Component', () => {
|
|
|
530
629
|
expect(screen.getByText('Users')).toBeInTheDocument();
|
|
531
630
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
532
631
|
}, { interval: 10 });
|
|
632
|
+
|
|
633
|
+
// Click on an item to verify navigation works
|
|
634
|
+
const dashboardItem = screen.getByText('Dashboard');
|
|
635
|
+
await user.click(dashboardItem);
|
|
636
|
+
|
|
637
|
+
expect(mockNavigate).toHaveBeenCalledWith(
|
|
638
|
+
expect.objectContaining({
|
|
639
|
+
id: 'dashboard',
|
|
640
|
+
label: 'Dashboard',
|
|
641
|
+
href: '/dashboard',
|
|
642
|
+
})
|
|
643
|
+
);
|
|
533
644
|
});
|
|
534
645
|
|
|
535
|
-
it
|
|
536
|
-
// TODO: This test is skipped because NavigationMenu permission filtering is temporarily disabled
|
|
537
|
-
// until migrated to useRBAC() hook. See NavigationMenu.tsx for TODOs.
|
|
646
|
+
it('handles strict mode configuration', async () => {
|
|
538
647
|
const user = userEvent.setup();
|
|
648
|
+
|
|
649
|
+
// Mock RBAC hooks to grant permissions and roles
|
|
650
|
+
mockUseRBAC.mockReturnValue({
|
|
651
|
+
user: { id: 'test-user', email: 'test@example.com' },
|
|
652
|
+
globalRole: null,
|
|
653
|
+
organisationRole: 'org_admin' as any,
|
|
654
|
+
eventAppRole: 'event_admin' as any,
|
|
655
|
+
hasPermission: vi.fn(),
|
|
656
|
+
hasGlobalPermission: vi.fn(),
|
|
657
|
+
isSuperAdmin: false,
|
|
658
|
+
isOrgAdmin: true,
|
|
659
|
+
isEventAdmin: true,
|
|
660
|
+
canManageOrganisation: true,
|
|
661
|
+
canManageEvent: true,
|
|
662
|
+
isLoading: false,
|
|
663
|
+
error: null,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
mockUsePermissions.mockReturnValue({
|
|
667
|
+
permissions: {
|
|
668
|
+
'dashboard:read': true,
|
|
669
|
+
} as any,
|
|
670
|
+
isLoading: false,
|
|
671
|
+
error: null,
|
|
672
|
+
hasPermission: vi.fn((p: any) => p === 'dashboard:read'),
|
|
673
|
+
hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => p === 'dashboard:read')),
|
|
674
|
+
hasAllPermissions: vi.fn(() => true),
|
|
675
|
+
refetch: vi.fn(),
|
|
676
|
+
});
|
|
677
|
+
|
|
539
678
|
renderWithProviders(
|
|
540
679
|
<NavigationMenu
|
|
541
680
|
items={permissionBasedNavItems}
|
|
@@ -555,6 +694,14 @@ describe('NavigationMenu Component', () => {
|
|
|
555
694
|
expect(screen.getByText('Users')).toBeInTheDocument();
|
|
556
695
|
expect(screen.getByText('Settings')).toBeInTheDocument();
|
|
557
696
|
}, { interval: 10 });
|
|
697
|
+
|
|
698
|
+
// Verify strict mode is working - navigation should proceed with permissions
|
|
699
|
+
const dashboardItem = screen.getByText('Dashboard');
|
|
700
|
+
await user.click(dashboardItem);
|
|
701
|
+
|
|
702
|
+
expect(mockNavigate).toHaveBeenCalled();
|
|
703
|
+
// Should not trigger strict mode violation since user has permissions
|
|
704
|
+
expect(mockOnStrictModeViolation).not.toHaveBeenCalled();
|
|
558
705
|
});
|
|
559
706
|
});
|
|
560
707
|
|
|
@@ -693,6 +840,35 @@ describe('NavigationMenu Component', () => {
|
|
|
693
840
|
},
|
|
694
841
|
];
|
|
695
842
|
|
|
843
|
+
// Mock RBAC to grant the valid permission
|
|
844
|
+
mockUseRBAC.mockReturnValue({
|
|
845
|
+
user: { id: 'test-user', email: 'test@example.com' },
|
|
846
|
+
globalRole: null,
|
|
847
|
+
organisationRole: null,
|
|
848
|
+
eventAppRole: null,
|
|
849
|
+
hasPermission: vi.fn(),
|
|
850
|
+
hasGlobalPermission: vi.fn(),
|
|
851
|
+
isSuperAdmin: false,
|
|
852
|
+
isOrgAdmin: false,
|
|
853
|
+
isEventAdmin: false,
|
|
854
|
+
canManageOrganisation: false,
|
|
855
|
+
canManageEvent: false,
|
|
856
|
+
isLoading: false,
|
|
857
|
+
error: null,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
mockUsePermissions.mockReturnValue({
|
|
861
|
+
permissions: {
|
|
862
|
+
'valid-permission': true,
|
|
863
|
+
} as any,
|
|
864
|
+
isLoading: false,
|
|
865
|
+
error: null,
|
|
866
|
+
hasPermission: vi.fn((p: any) => p === 'valid-permission'),
|
|
867
|
+
hasAnyPermission: vi.fn((ps: any[]) => ps.some((p: any) => typeof p === 'string' && p === 'valid-permission')),
|
|
868
|
+
hasAllPermissions: vi.fn(() => true),
|
|
869
|
+
refetch: vi.fn(),
|
|
870
|
+
});
|
|
871
|
+
|
|
696
872
|
renderWithProviders(
|
|
697
873
|
<NavigationMenu
|
|
698
874
|
items={invalidPermissionItems}
|
|
@@ -706,7 +882,7 @@ describe('NavigationMenu Component', () => {
|
|
|
706
882
|
await user.click(trigger);
|
|
707
883
|
|
|
708
884
|
await waitFor(() => {
|
|
709
|
-
// Should not crash and should show the item
|
|
885
|
+
// Should not crash and should show the item (invalid types filtered out, valid permission granted)
|
|
710
886
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
|
711
887
|
}, { interval: 10 });
|
|
712
888
|
});
|
|
@@ -198,7 +198,10 @@ import { NavigationMenuProps, NavigationItem } from "./types";
|
|
|
198
198
|
import { Button } from "../Button";
|
|
199
199
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../Select";
|
|
200
200
|
import { useUnifiedAuth } from "../../providers/UnifiedAuthProvider";
|
|
201
|
-
import {
|
|
201
|
+
import { useRBAC } from "../../rbac/hooks/useRBAC";
|
|
202
|
+
import { useResolvedScope } from "../../rbac/hooks";
|
|
203
|
+
import { usePermissions } from "../../rbac/hooks/usePermissions";
|
|
204
|
+
import type { Permission, AccessLevel as RBACAccessLevel } from "../../rbac/types";
|
|
202
205
|
|
|
203
206
|
/**
|
|
204
207
|
* Unified NavigationMenu component that supports both dropdown and hierarchical navigation modes.
|
|
@@ -424,10 +427,40 @@ export const NavigationMenu = React.forwardRef<
|
|
|
424
427
|
console.warn('[NavigationMenu] useUnifiedAuth not available, running in unauthenticated mode');
|
|
425
428
|
}
|
|
426
429
|
|
|
430
|
+
// Get RBAC context for permission and role checks
|
|
431
|
+
let rbacContext = null;
|
|
432
|
+
try {
|
|
433
|
+
rbacContext = useRBAC();
|
|
434
|
+
} catch (error) {
|
|
435
|
+
// RBAC not available - permission filtering will be disabled
|
|
436
|
+
console.warn('[NavigationMenu] useRBAC not available, permission filtering disabled');
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Get resolved scope for permission checks
|
|
440
|
+
const { supabase } = authContext || {};
|
|
441
|
+
const { selectedOrganisation } = authContext || {};
|
|
442
|
+
const selectedEvent = authContext?.selectedEvent || null;
|
|
443
|
+
const { resolvedScope, isLoading: scopeLoading } = useResolvedScope({
|
|
444
|
+
supabase: supabase || null,
|
|
445
|
+
selectedOrganisationId: selectedOrganisation?.id || null,
|
|
446
|
+
selectedEventId: selectedEvent?.event_id || null
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Get permissions map for synchronous permission checks
|
|
450
|
+
const userId = authContext?.user?.id || '';
|
|
451
|
+
const scope = resolvedScope || { organisationId: '', eventId: undefined, appId: undefined };
|
|
452
|
+
const { permissions: permissionMap, hasAnyPermission, isLoading: permissionsLoading } = usePermissions(
|
|
453
|
+
userId as any,
|
|
454
|
+
scope as any
|
|
455
|
+
);
|
|
456
|
+
|
|
427
457
|
// NEW: Phase 2 - Enhanced Security Features
|
|
428
|
-
// Filter navigation items based on permissions
|
|
458
|
+
// Filter navigation items based on permissions using RBAC hooks
|
|
429
459
|
const filteredItems = React.useMemo(() => {
|
|
430
|
-
|
|
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) {
|
|
462
|
+
return (items || []).filter(item => !item.meta?.hidden);
|
|
463
|
+
}
|
|
431
464
|
|
|
432
465
|
return (items || []).filter(item => {
|
|
433
466
|
// Check if item should be hidden
|
|
@@ -435,44 +468,98 @@ export const NavigationMenu = React.forwardRef<
|
|
|
435
468
|
|
|
436
469
|
// Check permissions if available
|
|
437
470
|
if (item.permissions && item.permissions.length > 0) {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
471
|
+
// Convert string permissions to Permission type and check
|
|
472
|
+
const permissions = item.permissions
|
|
473
|
+
.filter((p): p is string => typeof p === 'string')
|
|
474
|
+
.map(p => p as Permission);
|
|
475
|
+
|
|
476
|
+
if (permissions.length > 0) {
|
|
477
|
+
const hasPermission = hasAnyPermission(permissions);
|
|
478
|
+
if (!hasPermission) return false;
|
|
479
|
+
}
|
|
446
480
|
}
|
|
447
481
|
|
|
448
482
|
// Check roles if available
|
|
449
483
|
if (item.roles && item.roles.length > 0) {
|
|
450
484
|
const hasRole = item.roles.some(role => {
|
|
451
|
-
// Only check string roles, ignore invalid types
|
|
452
485
|
if (typeof role !== 'string') return true;
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
|
|
486
|
+
|
|
487
|
+
// Map role strings to RBAC role checks
|
|
488
|
+
switch (role.toLowerCase()) {
|
|
489
|
+
case 'super_admin':
|
|
490
|
+
case 'super admin':
|
|
491
|
+
return rbacContext.isSuperAdmin;
|
|
492
|
+
case 'org_admin':
|
|
493
|
+
case 'org admin':
|
|
494
|
+
case 'admin':
|
|
495
|
+
return rbacContext.isOrgAdmin || rbacContext.isSuperAdmin;
|
|
496
|
+
case 'event_admin':
|
|
497
|
+
case 'event admin':
|
|
498
|
+
return rbacContext.isEventAdmin || rbacContext.isSuperAdmin;
|
|
499
|
+
default:
|
|
500
|
+
// For other roles, check against organisationRole or eventAppRole
|
|
501
|
+
return (
|
|
502
|
+
rbacContext.organisationRole === role ||
|
|
503
|
+
rbacContext.eventAppRole === role ||
|
|
504
|
+
rbacContext.isSuperAdmin
|
|
505
|
+
);
|
|
506
|
+
}
|
|
456
507
|
});
|
|
457
508
|
if (!hasRole) return false;
|
|
458
509
|
}
|
|
459
510
|
|
|
460
511
|
// Check access level if available
|
|
461
512
|
if (item.accessLevel) {
|
|
462
|
-
// Only check string access levels, ignore invalid types
|
|
463
513
|
if (typeof item.accessLevel === 'string') {
|
|
464
|
-
//
|
|
465
|
-
const accessLevel = item.accessLevel as
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
if (
|
|
514
|
+
// Map access level string to RBAC access level
|
|
515
|
+
const accessLevel = item.accessLevel.toLowerCase() as RBACAccessLevel;
|
|
516
|
+
const userEventRole = rbacContext.eventAppRole;
|
|
517
|
+
|
|
518
|
+
// If user is super admin, they have all access levels
|
|
519
|
+
if (rbacContext.isSuperAdmin) {
|
|
520
|
+
// Super admin has access
|
|
521
|
+
} else {
|
|
522
|
+
// Map eventAppRole to access level for comparison
|
|
523
|
+
// eventAppRole: 'viewer' | 'participant' | 'planner' | 'event_admin'
|
|
524
|
+
const roleToAccessLevel: Record<string, RBACAccessLevel> = {
|
|
525
|
+
'viewer': 'viewer',
|
|
526
|
+
'participant': 'participant',
|
|
527
|
+
'planner': 'planner',
|
|
528
|
+
'event_admin': 'admin',
|
|
529
|
+
};
|
|
530
|
+
const userAccessLevel = userEventRole ? (roleToAccessLevel[userEventRole] || 'viewer') : null;
|
|
531
|
+
|
|
532
|
+
// Check if user's access level meets the required access level
|
|
533
|
+
const levelHierarchy: Record<RBACAccessLevel, number> = {
|
|
534
|
+
viewer: 1,
|
|
535
|
+
participant: 2,
|
|
536
|
+
planner: 3,
|
|
537
|
+
admin: 4,
|
|
538
|
+
super: 5
|
|
539
|
+
};
|
|
540
|
+
const requiredLevel = levelHierarchy[accessLevel] || 0;
|
|
541
|
+
const userLevel = userAccessLevel ? levelHierarchy[userAccessLevel] || 0 : 0;
|
|
542
|
+
|
|
543
|
+
if (userLevel < requiredLevel) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
470
547
|
}
|
|
471
548
|
}
|
|
472
549
|
|
|
473
550
|
return true;
|
|
474
551
|
});
|
|
475
|
-
}, [
|
|
552
|
+
}, [
|
|
553
|
+
items,
|
|
554
|
+
filterByPermissions,
|
|
555
|
+
authContext,
|
|
556
|
+
rbacContext,
|
|
557
|
+
permissionMap,
|
|
558
|
+
hasAnyPermission,
|
|
559
|
+
scopeLoading,
|
|
560
|
+
permissionsLoading,
|
|
561
|
+
resolvedScope
|
|
562
|
+
]);
|
|
476
563
|
|
|
477
564
|
// Log navigation access attempts for debugging
|
|
478
565
|
React.useEffect(() => {
|
|
@@ -552,26 +639,46 @@ export const NavigationMenu = React.forwardRef<
|
|
|
552
639
|
// Check if item should be visible (already filtered)
|
|
553
640
|
const isItemVisible = filteredItems.some(filtered => filtered.id === item.id);
|
|
554
641
|
|
|
555
|
-
// Check permissions if the item requires them
|
|
642
|
+
// Check permissions if the item requires them using RBAC hooks
|
|
556
643
|
let hasPermission = true; // Default to true if no permission requirements
|
|
557
644
|
|
|
558
|
-
if (item.permissions && item.permissions.length > 0) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
645
|
+
if (item.permissions && item.permissions.length > 0 && rbacContext && hasAnyPermission) {
|
|
646
|
+
// Convert string permissions to Permission type and check
|
|
647
|
+
const permissions = item.permissions
|
|
648
|
+
.filter((p): p is string => typeof p === 'string')
|
|
649
|
+
.map(p => p as Permission);
|
|
650
|
+
|
|
651
|
+
if (permissions.length > 0) {
|
|
652
|
+
hasPermission = hasAnyPermission(permissions);
|
|
653
|
+
}
|
|
565
654
|
}
|
|
566
655
|
|
|
567
|
-
if (!hasPermission) {
|
|
656
|
+
if (!hasPermission && rbacContext) {
|
|
568
657
|
// Check roles if permissions check failed or item has role requirements
|
|
569
658
|
if (item.roles && item.roles.length > 0) {
|
|
570
659
|
hasPermission = item.roles.some(role => {
|
|
571
660
|
if (typeof role !== 'string') return true;
|
|
572
|
-
|
|
573
|
-
// RBAC
|
|
574
|
-
|
|
661
|
+
|
|
662
|
+
// Map role strings to RBAC role checks (same logic as filtering)
|
|
663
|
+
switch (role.toLowerCase()) {
|
|
664
|
+
case 'super_admin':
|
|
665
|
+
case 'super admin':
|
|
666
|
+
return rbacContext.isSuperAdmin;
|
|
667
|
+
case 'org_admin':
|
|
668
|
+
case 'org admin':
|
|
669
|
+
case 'admin':
|
|
670
|
+
return rbacContext.isOrgAdmin || rbacContext.isSuperAdmin;
|
|
671
|
+
case 'event_admin':
|
|
672
|
+
case 'event admin':
|
|
673
|
+
return rbacContext.isEventAdmin || rbacContext.isSuperAdmin;
|
|
674
|
+
default:
|
|
675
|
+
// For other roles, check against organisationRole or eventAppRole
|
|
676
|
+
return (
|
|
677
|
+
rbacContext.organisationRole === role ||
|
|
678
|
+
rbacContext.eventAppRole === role ||
|
|
679
|
+
rbacContext.isSuperAdmin
|
|
680
|
+
);
|
|
681
|
+
}
|
|
575
682
|
});
|
|
576
683
|
}
|
|
577
684
|
}
|
|
@@ -243,7 +243,7 @@ export interface PaceAppLayoutProps {
|
|
|
243
243
|
*
|
|
244
244
|
*
|
|
245
245
|
* @example
|
|
246
|
-
* Custom navigation items with permission filtering:
|
|
246
|
+
* Custom navigation items with permission filtering (works independently of route enforcement):
|
|
247
247
|
* ```tsx
|
|
248
248
|
* import { NavigationItem } from '@jmruthers/pace-core';
|
|
249
249
|
*
|
|
@@ -261,13 +261,15 @@ export interface PaceAppLayoutProps {
|
|
|
261
261
|
* <PaceAppLayout
|
|
262
262
|
* appName="My Custom App"
|
|
263
263
|
* navItems={customNavItems}
|
|
264
|
-
* enforcePermissions
|
|
264
|
+
* // Navigation filtering works independently - no need for enforcePermissions
|
|
265
265
|
* filterNavigationByPermissions={true}
|
|
266
266
|
* routePermissions={{
|
|
267
267
|
* '/components': 'read',
|
|
268
268
|
* '/styles': 'read',
|
|
269
269
|
* '/meals': 'read'
|
|
270
270
|
* }}
|
|
271
|
+
* // Optionally enable route-level enforcement (separate from navigation filtering)
|
|
272
|
+
* // enforcePermissions={true}
|
|
271
273
|
* />
|
|
272
274
|
* }>
|
|
273
275
|
* <Route path="components" element={<ComponentsPage />} />
|
|
@@ -367,12 +369,24 @@ export function PaceAppLayout({
|
|
|
367
369
|
|
|
368
370
|
// Check if user is super admin first - super admins can access everything
|
|
369
371
|
// regardless of organisation context
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
372
|
+
// Gracefully handle RBAC not being initialized (e.g., in tests)
|
|
373
|
+
try {
|
|
374
|
+
const { isSuperAdmin } = await import('../../rbac/api');
|
|
375
|
+
const isSuper = await isSuperAdmin(user.id);
|
|
376
|
+
|
|
377
|
+
if (isSuper) {
|
|
378
|
+
// Super admin bypass - allow access regardless of organisation context
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
// If RBAC is not initialized (e.g., in tests), continue with normal permission check
|
|
383
|
+
// This prevents errors from breaking permission checks when RBAC isn't available
|
|
384
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'RBAC_NOT_INITIALIZED') {
|
|
385
|
+
// RBAC not available - proceed with normal permission check
|
|
386
|
+
} else {
|
|
387
|
+
// Re-throw unexpected errors
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
376
390
|
}
|
|
377
391
|
|
|
378
392
|
// For non-super admins, ensure we have at least organisationId for RBAC
|
|
@@ -498,10 +512,12 @@ export function PaceAppLayout({
|
|
|
498
512
|
}, [enforcePermissions, currentRoutePermission, currentPageId, strictMode, user?.id]);
|
|
499
513
|
|
|
500
514
|
// Filter navigation items based on permissions
|
|
515
|
+
// This works independently of route enforcement - navigation filtering doesn't require enforcePermissions
|
|
501
516
|
const [filteredMenuItems, setFilteredMenuItems] = useState<NavigationItem[]>(baseMenuItems);
|
|
502
517
|
|
|
503
518
|
useEffect(() => {
|
|
504
|
-
|
|
519
|
+
// Allow navigation filtering without route enforcement
|
|
520
|
+
if (!filterNavigationByPermissions) {
|
|
505
521
|
setFilteredMenuItems(baseMenuItems);
|
|
506
522
|
return;
|
|
507
523
|
}
|
|
@@ -509,6 +525,58 @@ export function PaceAppLayout({
|
|
|
509
525
|
let isMounted = true;
|
|
510
526
|
|
|
511
527
|
const filterItems = async () => {
|
|
528
|
+
// Wait for organisation context to be ready before filtering
|
|
529
|
+
// This prevents blocking navigation while context is loading
|
|
530
|
+
if (!user?.id) {
|
|
531
|
+
// User not loaded yet - show all items until context is ready
|
|
532
|
+
if (isMounted) {
|
|
533
|
+
setFilteredMenuItems(baseMenuItems);
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Check if organisation context is available
|
|
539
|
+
const scope = {
|
|
540
|
+
organisationId: user.user_metadata?.organisationId || user.app_metadata?.organisationId,
|
|
541
|
+
eventId: user.user_metadata?.eventId || user.app_metadata?.eventId,
|
|
542
|
+
appId: user.user_metadata?.appId || user.app_metadata?.appId,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// For super admins, show all items (they bypass permission checks)
|
|
546
|
+
// Gracefully handle RBAC not being initialized (e.g., in tests)
|
|
547
|
+
try {
|
|
548
|
+
const { isSuperAdmin } = await import('../../rbac/api');
|
|
549
|
+
const isSuper = await isSuperAdmin(user.id);
|
|
550
|
+
|
|
551
|
+
if (isSuper) {
|
|
552
|
+
// Super admins see all navigation items
|
|
553
|
+
if (isMounted) {
|
|
554
|
+
setFilteredMenuItems(baseMenuItems);
|
|
555
|
+
}
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
} catch (error) {
|
|
559
|
+
// If RBAC is not initialized (e.g., in tests), continue with normal filtering
|
|
560
|
+
// This prevents errors from breaking navigation when RBAC isn't available
|
|
561
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'RBAC_NOT_INITIALIZED') {
|
|
562
|
+
// RBAC not available - proceed with normal filtering without super admin check
|
|
563
|
+
// In this case, we'll filter items normally based on permissions
|
|
564
|
+
} else {
|
|
565
|
+
// Re-throw unexpected errors
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// If no organisation context yet, show all items until context is ready
|
|
571
|
+
// This prevents navigation from being empty while context loads
|
|
572
|
+
if (!scope.organisationId) {
|
|
573
|
+
if (isMounted) {
|
|
574
|
+
setFilteredMenuItems(baseMenuItems);
|
|
575
|
+
}
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Organisation context is ready - now filter items based on permissions
|
|
512
580
|
const filtered = await Promise.all(
|
|
513
581
|
baseMenuItems.map(async (item) => {
|
|
514
582
|
if (!item.href) return { item, hasAccess: true };
|
|
@@ -520,6 +588,7 @@ export function PaceAppLayout({
|
|
|
520
588
|
const hasAccess = await checkPermission(permission, pageId);
|
|
521
589
|
return { item, hasAccess };
|
|
522
590
|
} catch {
|
|
591
|
+
// On error, default to hiding the item (fail-safe)
|
|
523
592
|
return { item, hasAccess: false };
|
|
524
593
|
}
|
|
525
594
|
})
|
|
@@ -539,7 +608,7 @@ export function PaceAppLayout({
|
|
|
539
608
|
return () => {
|
|
540
609
|
isMounted = false;
|
|
541
610
|
};
|
|
542
|
-
}, [baseMenuItems, filterNavigationByPermissions,
|
|
611
|
+
}, [baseMenuItems, filterNavigationByPermissions, pageIdMapping, routePermissions, defaultPermission, checkPermission, user?.id, user?.user_metadata, user?.app_metadata]);
|
|
543
612
|
|
|
544
613
|
// NEW: Phase 2 - Enhanced Routing Features
|
|
545
614
|
// Check route access for role-based routing
|
|
@@ -48,12 +48,12 @@ vi.mock('../../../hooks/useAppConfig', () => ({
|
|
|
48
48
|
|
|
49
49
|
// Mock the FileDisplay component
|
|
50
50
|
vi.mock('../../FileDisplay/FileDisplay', () => ({
|
|
51
|
-
FileDisplay: vi.fn(({ table_name, record_id, organisation_id, category, className }) => (
|
|
51
|
+
FileDisplay: vi.fn(({ table_name, record_id, organisation_id, category, className, size = 'md' }) => (
|
|
52
52
|
<div
|
|
53
53
|
data-testid="event-logo"
|
|
54
|
-
data-
|
|
55
|
-
data-
|
|
56
|
-
data-organisation-id={
|
|
54
|
+
data-table-name={table_name}
|
|
55
|
+
data-record-id={record_id}
|
|
56
|
+
data-organisation-id={organisation_id}
|
|
57
57
|
data-size={size}
|
|
58
58
|
className={className}
|
|
59
59
|
>
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - Customizable positioning and styling
|
|
13
13
|
* - Swipe gestures for dismissal
|
|
14
14
|
* - Keyboard navigation support
|
|
15
|
-
* - Auto-dismiss with
|
|
15
|
+
* - Auto-dismiss with default 10 second duration (configurable)
|
|
16
16
|
* - Action buttons and close functionality
|
|
17
17
|
* - Responsive design
|
|
18
18
|
* - Accessibility compliant
|