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