@jmruthers/pace-core 0.5.107 → 0.5.109
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 +75 -177
- package/dist/{AuthService-1D2ifNfa.d.ts → AuthService-DrHrvXNZ.d.ts} +8 -1
- package/dist/{DataTable-H2WIR2DN.js → DataTable-5HITILXS.js} +7 -7
- package/dist/{PublicLoadingSpinner-48ewSMKK.d.ts → PublicLoadingSpinner-DgDWTFqn.d.ts} +4 -2
- package/dist/{UnifiedAuthProvider-XU4BHFXZ.js → UnifiedAuthProvider-A7I23UCN.js} +3 -3
- package/dist/{api-KG4A2X7P.js → api-5I3E47G2.js} +2 -2
- package/dist/{chunk-DMNMZKWS.js → chunk-2W4WKJVF.js} +4 -4
- package/dist/{chunk-MOMYOQMC.js → chunk-3TKTL5AZ.js} +13 -13
- 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-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-S63MFSY6.js → chunk-F6TSYCKP.js} +4 -2
- package/dist/{chunk-S63MFSY6.js.map → chunk-F6TSYCKP.js.map} +1 -1
- package/dist/chunk-GZRXOUBE.js +176 -0
- package/dist/chunk-GZRXOUBE.js.map +1 -0
- package/dist/{chunk-EWKCROSF.js → chunk-P72NKAT5.js} +84 -28
- package/dist/chunk-P72NKAT5.js.map +1 -0
- package/dist/{chunk-VJ7MPS2K.js → chunk-S4D3Z723.js} +6 -6
- package/dist/{chunk-5JJCXTVE.js → chunk-UW2DE6JX.js} +108 -86
- package/dist/{chunk-5JJCXTVE.js.map → chunk-UW2DE6JX.js.map} +1 -1
- package/dist/{chunk-QDDUU625.js → chunk-WWNOVFDC.js} +4 -4
- package/dist/{chunk-GVRSXXAA.js → chunk-YFMENCR4.js} +3 -3
- package/dist/components.d.ts +1 -1
- 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 +7 -7
- 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 +1 -1
- 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 +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 +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 +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 -6
- 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/implementation-guides/authentication.md +1 -0
- package/docs/security/README.md +59 -0
- package/package.json +1 -1
- package/src/components/DataTable/components/ColumnFilter.tsx +2 -1
- package/src/components/DataTable/components/EditableRow.tsx +7 -2
- 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 +39 -10
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +2 -2
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +126 -25
- 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/engine.ts +2 -0
- 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-EWKCROSF.js.map +0 -1
- package/dist/chunk-NFPV7MRN.js +0 -94
- package/dist/chunk-NFPV7MRN.js.map +0 -1
- package/src/providers/AuthProvider.simplified.tsx +0 -974
- package/dist/{DataTable-H2WIR2DN.js.map → DataTable-5HITILXS.js.map} +0 -0
- package/dist/{UnifiedAuthProvider-XU4BHFXZ.js.map → UnifiedAuthProvider-A7I23UCN.js.map} +0 -0
- package/dist/{api-KG4A2X7P.js.map → api-5I3E47G2.js.map} +0 -0
- package/dist/{chunk-DMNMZKWS.js.map → chunk-2W4WKJVF.js.map} +0 -0
- package/dist/{chunk-MOMYOQMC.js.map → chunk-3TKTL5AZ.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-VJ7MPS2K.js.map → chunk-S4D3Z723.js.map} +0 -0
- package/dist/{chunk-QDDUU625.js.map → chunk-WWNOVFDC.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
|
@@ -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
|
|
@@ -381,10 +395,16 @@ export function PaceAppLayout({
|
|
|
381
395
|
return false;
|
|
382
396
|
}
|
|
383
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
|
+
|
|
384
404
|
return await isPermitted({
|
|
385
405
|
userId: user.id,
|
|
386
406
|
scope,
|
|
387
|
-
permission:
|
|
407
|
+
permission: fullPermission,
|
|
388
408
|
pageId
|
|
389
409
|
});
|
|
390
410
|
} catch (error) {
|
|
@@ -498,10 +518,12 @@ export function PaceAppLayout({
|
|
|
498
518
|
}, [enforcePermissions, currentRoutePermission, currentPageId, strictMode, user?.id]);
|
|
499
519
|
|
|
500
520
|
// Filter navigation items based on permissions
|
|
521
|
+
// This works independently of route enforcement - navigation filtering doesn't require enforcePermissions
|
|
501
522
|
const [filteredMenuItems, setFilteredMenuItems] = useState<NavigationItem[]>(baseMenuItems);
|
|
502
523
|
|
|
503
524
|
useEffect(() => {
|
|
504
|
-
|
|
525
|
+
// Allow navigation filtering without route enforcement
|
|
526
|
+
if (!filterNavigationByPermissions) {
|
|
505
527
|
setFilteredMenuItems(baseMenuItems);
|
|
506
528
|
return;
|
|
507
529
|
}
|
|
@@ -509,29 +531,108 @@ export function PaceAppLayout({
|
|
|
509
531
|
let isMounted = true;
|
|
510
532
|
|
|
511
533
|
const filterItems = async () => {
|
|
512
|
-
|
|
513
|
-
|
|
534
|
+
// Wait for organisation context to be ready before filtering
|
|
535
|
+
// This prevents blocking navigation while context is loading
|
|
536
|
+
if (!user?.id) {
|
|
537
|
+
// User not loaded yet - show all items until context is ready
|
|
538
|
+
if (isMounted) {
|
|
539
|
+
setFilteredMenuItems(baseMenuItems);
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Check if organisation context is available
|
|
545
|
+
const scope = {
|
|
546
|
+
organisationId: user.user_metadata?.organisationId || user.app_metadata?.organisationId,
|
|
547
|
+
eventId: user.user_metadata?.eventId || user.app_metadata?.eventId,
|
|
548
|
+
appId: user.user_metadata?.appId || user.app_metadata?.appId,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// For super admins, show all items (they bypass permission checks)
|
|
552
|
+
// Gracefully handle RBAC not being initialized (e.g., in tests)
|
|
553
|
+
try {
|
|
554
|
+
const { isSuperAdmin } = await import('../../rbac/api');
|
|
555
|
+
const isSuper = await isSuperAdmin(user.id);
|
|
556
|
+
|
|
557
|
+
if (isSuper) {
|
|
558
|
+
// Super admins see all navigation items
|
|
559
|
+
if (isMounted) {
|
|
560
|
+
setFilteredMenuItems(baseMenuItems);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
} catch (error) {
|
|
565
|
+
// If RBAC is not initialized (e.g., in tests), continue with normal filtering
|
|
566
|
+
// This prevents errors from breaking navigation when RBAC isn't available
|
|
567
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'RBAC_NOT_INITIALIZED') {
|
|
568
|
+
// RBAC not available - proceed with normal filtering without super admin check
|
|
569
|
+
// In this case, we'll filter items normally based on permissions
|
|
570
|
+
} else {
|
|
571
|
+
// Re-throw unexpected errors
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// If no organisation context yet, show all items until context is ready
|
|
577
|
+
// This prevents navigation from being empty while context loads
|
|
578
|
+
if (!scope.organisationId) {
|
|
579
|
+
if (isMounted) {
|
|
580
|
+
setFilteredMenuItems(baseMenuItems);
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Organisation context is ready - now filter items based on permissions
|
|
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) => {
|
|
514
597
|
if (!item.href) return { item, hasAccess: true };
|
|
515
598
|
|
|
516
599
|
const pageId = pageIdMapping[item.href] || item.href.slice(1) || 'home';
|
|
517
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;
|
|
604
|
+
|
|
605
|
+
// Check permission map (super admin check already handled in getPermissionMap)
|
|
606
|
+
const hasAccess = permissionMap['*'] === true || permissionMap[fullPermission] === true;
|
|
518
607
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
+
});
|
|
524
616
|
}
|
|
525
|
-
|
|
526
|
-
|
|
617
|
+
|
|
618
|
+
return { item, hasAccess };
|
|
619
|
+
});
|
|
527
620
|
|
|
528
|
-
|
|
621
|
+
if (!isMounted) return;
|
|
529
622
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
623
|
+
const accessibleItems = filtered
|
|
624
|
+
.filter(({ hasAccess }) => hasAccess)
|
|
625
|
+
.map(({ item }) => item);
|
|
533
626
|
|
|
534
|
-
|
|
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
|
+
}
|
|
535
636
|
};
|
|
536
637
|
|
|
537
638
|
filterItems();
|
|
@@ -539,7 +640,7 @@ export function PaceAppLayout({
|
|
|
539
640
|
return () => {
|
|
540
641
|
isMounted = false;
|
|
541
642
|
};
|
|
542
|
-
}, [baseMenuItems, filterNavigationByPermissions,
|
|
643
|
+
}, [baseMenuItems, filterNavigationByPermissions, pageIdMapping, routePermissions, defaultPermission, checkPermission, user?.id, user?.user_metadata, user?.app_metadata]);
|
|
543
644
|
|
|
544
645
|
// NEW: Phase 2 - Enhanced Routing Features
|
|
545
646
|
// Check route access for role-based routing
|
|
@@ -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/engine.ts
CHANGED
|
@@ -474,11 +474,13 @@ export class RBACEngine {
|
|
|
474
474
|
|
|
475
475
|
try {
|
|
476
476
|
const { userId, scope } = input;
|
|
477
|
+
// Call unified function (tech debt removed: consolidated from 2 overloaded versions)
|
|
477
478
|
const { data, error } = await (this.supabase as any).rpc('rbac_permissions_get', {
|
|
478
479
|
p_user_id: userId,
|
|
479
480
|
p_organisation_id: scope.organisationId || null,
|
|
480
481
|
p_event_id: scope.eventId || null,
|
|
481
482
|
p_app_id: scope.appId || null,
|
|
483
|
+
p_page_id: null, // Optional: can filter to specific page if needed
|
|
482
484
|
});
|
|
483
485
|
|
|
484
486
|
if (error) {
|
|
@@ -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
|
|
|
@@ -640,4 +640,188 @@ describe('AuthService', () => {
|
|
|
640
640
|
expect(authService.isAuthenticated()).toBe(false);
|
|
641
641
|
});
|
|
642
642
|
});
|
|
643
|
+
|
|
644
|
+
describe('Automatic Session Tracking', () => {
|
|
645
|
+
beforeEach(() => {
|
|
646
|
+
// Mock rpc function for session tracking
|
|
647
|
+
(mockSupabase as any).rpc = vi.fn();
|
|
648
|
+
// Mock from().select() chain for app ID resolution
|
|
649
|
+
(mockSupabase as any).from = vi.fn().mockReturnValue({
|
|
650
|
+
select: vi.fn().mockReturnValue({
|
|
651
|
+
eq: vi.fn().mockReturnValue({
|
|
652
|
+
eq: vi.fn().mockReturnValue({
|
|
653
|
+
single: vi.fn().mockResolvedValue({
|
|
654
|
+
data: { id: 'app-id-123' },
|
|
655
|
+
error: null
|
|
656
|
+
})
|
|
657
|
+
})
|
|
658
|
+
})
|
|
659
|
+
})
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('should track login session automatically on SIGNED_IN event', async () => {
|
|
664
|
+
const mockUser = { id: 'user-123', email: 'test@example.com' };
|
|
665
|
+
const mockSession = { access_token: 'token', user: mockUser };
|
|
666
|
+
|
|
667
|
+
(mockSupabase as any).rpc.mockResolvedValue({ error: null });
|
|
668
|
+
|
|
669
|
+
let authStateCallback: any;
|
|
670
|
+
mockSupabase.auth.onAuthStateChange.mockImplementation((callback) => {
|
|
671
|
+
authStateCallback = callback;
|
|
672
|
+
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// Initialize with appName to test app ID resolution
|
|
676
|
+
const authServiceWithApp = new AuthService(mockSupabase as any, 'TEST_APP');
|
|
677
|
+
await authServiceWithApp.initialize();
|
|
678
|
+
|
|
679
|
+
// Simulate SIGNED_IN event
|
|
680
|
+
if (authStateCallback) {
|
|
681
|
+
authStateCallback('SIGNED_IN', mockSession);
|
|
682
|
+
|
|
683
|
+
// Wait a bit for async tracking to complete
|
|
684
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
685
|
+
|
|
686
|
+
// Verify rbac_session_track was called with correct parameters
|
|
687
|
+
expect((mockSupabase as any).rpc).toHaveBeenCalledWith('rbac_session_track', expect.objectContaining({
|
|
688
|
+
p_user_id: 'user-123',
|
|
689
|
+
p_session_type: 'login',
|
|
690
|
+
p_event_id: null,
|
|
691
|
+
p_app_id: 'app-id-123', // Should be resolved from appName
|
|
692
|
+
p_user_agent: expect.any(String), // navigator.userAgent
|
|
693
|
+
}));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
authServiceWithApp.cleanup();
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
it('should track logout session automatically on SIGNED_OUT event', async () => {
|
|
700
|
+
const mockUser = { id: 'user-123', email: 'test@example.com' };
|
|
701
|
+
const mockSession = { access_token: 'token', user: mockUser };
|
|
702
|
+
|
|
703
|
+
(mockSupabase as any).rpc.mockResolvedValue({ error: null });
|
|
704
|
+
|
|
705
|
+
let authStateCallback: any;
|
|
706
|
+
mockSupabase.auth.onAuthStateChange.mockImplementation((callback) => {
|
|
707
|
+
authStateCallback = callback;
|
|
708
|
+
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const authServiceWithApp = new AuthService(mockSupabase as any, 'TEST_APP');
|
|
712
|
+
await authServiceWithApp.initialize();
|
|
713
|
+
|
|
714
|
+
// Simulate SIGNED_OUT event
|
|
715
|
+
if (authStateCallback) {
|
|
716
|
+
authStateCallback('SIGNED_OUT', mockSession);
|
|
717
|
+
|
|
718
|
+
// Wait a bit for async tracking to complete
|
|
719
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
720
|
+
|
|
721
|
+
// Verify rbac_session_track was called with logout type
|
|
722
|
+
expect((mockSupabase as any).rpc).toHaveBeenCalledWith('rbac_session_track', expect.objectContaining({
|
|
723
|
+
p_user_id: 'user-123',
|
|
724
|
+
p_session_type: 'logout',
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
authServiceWithApp.cleanup();
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('should NOT track session on TOKEN_REFRESHED event (to avoid duplicate login records)', async () => {
|
|
732
|
+
const mockUser = { id: 'user-123', email: 'test@example.com' };
|
|
733
|
+
const mockSession = { access_token: 'new_token', user: mockUser };
|
|
734
|
+
|
|
735
|
+
(mockSupabase as any).rpc.mockResolvedValue({ error: null });
|
|
736
|
+
|
|
737
|
+
let authStateCallback: any;
|
|
738
|
+
mockSupabase.auth.onAuthStateChange.mockImplementation((callback) => {
|
|
739
|
+
authStateCallback = callback;
|
|
740
|
+
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const authServiceWithApp = new AuthService(mockSupabase as any, 'TEST_APP');
|
|
744
|
+
await authServiceWithApp.initialize();
|
|
745
|
+
|
|
746
|
+
// Simulate TOKEN_REFRESHED event
|
|
747
|
+
if (authStateCallback) {
|
|
748
|
+
authStateCallback('TOKEN_REFRESHED', mockSession);
|
|
749
|
+
|
|
750
|
+
// Wait a bit for any async operations
|
|
751
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
752
|
+
|
|
753
|
+
// Verify rbac_session_track was NOT called
|
|
754
|
+
expect((mockSupabase as any).rpc).not.toHaveBeenCalled();
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
authServiceWithApp.cleanup();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('should handle tracking errors gracefully without breaking authentication', async () => {
|
|
761
|
+
const mockUser = { id: 'user-123', email: 'test@example.com' };
|
|
762
|
+
const mockSession = { access_token: 'token', user: mockUser };
|
|
763
|
+
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
764
|
+
|
|
765
|
+
(mockSupabase as any).rpc.mockRejectedValue(new Error('Tracking failed'));
|
|
766
|
+
|
|
767
|
+
let authStateCallback: any;
|
|
768
|
+
mockSupabase.auth.onAuthStateChange.mockImplementation((callback) => {
|
|
769
|
+
authStateCallback = callback;
|
|
770
|
+
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
const authServiceWithApp = new AuthService(mockSupabase as any, 'TEST_APP');
|
|
774
|
+
await authServiceWithApp.initialize();
|
|
775
|
+
|
|
776
|
+
// Simulate SIGNED_IN event
|
|
777
|
+
if (authStateCallback) {
|
|
778
|
+
authStateCallback('SIGNED_IN', mockSession);
|
|
779
|
+
|
|
780
|
+
// Wait a bit for async tracking to complete
|
|
781
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
782
|
+
|
|
783
|
+
// Verify error was logged but authentication still succeeded
|
|
784
|
+
// When rpc throws an exception, it goes to catch block which logs "Error tracking"
|
|
785
|
+
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
786
|
+
expect.stringContaining('Error tracking login session'),
|
|
787
|
+
expect.anything()
|
|
788
|
+
);
|
|
789
|
+
expect(authServiceWithApp.isAuthenticated()).toBe(true);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
consoleWarnSpy.mockRestore();
|
|
793
|
+
authServiceWithApp.cleanup();
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('should work without appName (app_id will be null)', async () => {
|
|
797
|
+
const mockUser = { id: 'user-123', email: 'test@example.com' };
|
|
798
|
+
const mockSession = { access_token: 'token', user: mockUser };
|
|
799
|
+
|
|
800
|
+
(mockSupabase as any).rpc.mockResolvedValue({ error: null });
|
|
801
|
+
|
|
802
|
+
let authStateCallback: any;
|
|
803
|
+
mockSupabase.auth.onAuthStateChange.mockImplementation((callback) => {
|
|
804
|
+
authStateCallback = callback;
|
|
805
|
+
return { data: { subscription: { unsubscribe: vi.fn() } } };
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
// Initialize without appName
|
|
809
|
+
await authService.initialize();
|
|
810
|
+
|
|
811
|
+
// Simulate SIGNED_IN event
|
|
812
|
+
if (authStateCallback) {
|
|
813
|
+
authStateCallback('SIGNED_IN', mockSession);
|
|
814
|
+
|
|
815
|
+
// Wait a bit for async tracking to complete
|
|
816
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
817
|
+
|
|
818
|
+
// Verify rbac_session_track was called with null app_id
|
|
819
|
+
expect((mockSupabase as any).rpc).toHaveBeenCalledWith('rbac_session_track', expect.objectContaining({
|
|
820
|
+
p_user_id: 'user-123',
|
|
821
|
+
p_session_type: 'login',
|
|
822
|
+
p_app_id: undefined, // Should be undefined when appName not provided
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
});
|
|
643
827
|
});
|