@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +75 -177
  2. package/dist/{AuthService-1D2ifNfa.d.ts → AuthService-DrHrvXNZ.d.ts} +8 -1
  3. package/dist/{DataTable-H2WIR2DN.js → DataTable-5HITILXS.js} +7 -7
  4. package/dist/{PublicLoadingSpinner-48ewSMKK.d.ts → PublicLoadingSpinner-DgDWTFqn.d.ts} +4 -2
  5. package/dist/{UnifiedAuthProvider-XU4BHFXZ.js → UnifiedAuthProvider-A7I23UCN.js} +3 -3
  6. package/dist/{api-KG4A2X7P.js → api-5I3E47G2.js} +2 -2
  7. package/dist/{chunk-DMNMZKWS.js → chunk-2W4WKJVF.js} +4 -4
  8. package/dist/{chunk-MOMYOQMC.js → chunk-3TKTL5AZ.js} +13 -13
  9. package/dist/{chunk-X4FRXJV6.js → chunk-AUXS7XSO.js} +57 -6
  10. package/dist/{chunk-X4FRXJV6.js.map → chunk-AUXS7XSO.js.map} +1 -1
  11. package/dist/{chunk-LT6RKRA7.js → chunk-D6MEKC27.js} +2 -2
  12. package/dist/{chunk-KBG34SVL.js → chunk-EYSXQ756.js} +2 -2
  13. package/dist/{chunk-ZXY5NTJB.js → chunk-EZ64QG2I.js} +2 -2
  14. package/dist/{chunk-S63MFSY6.js → chunk-F6TSYCKP.js} +4 -2
  15. package/dist/{chunk-S63MFSY6.js.map → chunk-F6TSYCKP.js.map} +1 -1
  16. package/dist/chunk-GZRXOUBE.js +176 -0
  17. package/dist/chunk-GZRXOUBE.js.map +1 -0
  18. package/dist/{chunk-EWKCROSF.js → chunk-P72NKAT5.js} +84 -28
  19. package/dist/chunk-P72NKAT5.js.map +1 -0
  20. package/dist/{chunk-VJ7MPS2K.js → chunk-S4D3Z723.js} +6 -6
  21. package/dist/{chunk-5JJCXTVE.js → chunk-UW2DE6JX.js} +108 -86
  22. package/dist/{chunk-5JJCXTVE.js.map → chunk-UW2DE6JX.js.map} +1 -1
  23. package/dist/{chunk-QDDUU625.js → chunk-WWNOVFDC.js} +4 -4
  24. package/dist/{chunk-GVRSXXAA.js → chunk-YFMENCR4.js} +3 -3
  25. package/dist/components.d.ts +1 -1
  26. package/dist/components.js +9 -9
  27. package/dist/{database-BXAfr2Y_.d.ts → database-C6jy7EOu.d.ts} +21 -9
  28. package/dist/{formatting-BiEv5oEk.d.ts → formatting-B1jSqgl-.d.ts} +16 -1
  29. package/dist/hooks.d.ts +2 -2
  30. package/dist/hooks.js +7 -7
  31. package/dist/index.d.ts +7 -7
  32. package/dist/index.js +16 -14
  33. package/dist/index.js.map +1 -1
  34. package/dist/providers.d.ts +4 -3
  35. package/dist/providers.js +2 -2
  36. package/dist/rbac/index.d.ts +1 -1
  37. package/dist/rbac/index.js +8 -8
  38. package/dist/types.d.ts +2 -2
  39. package/dist/{usePublicRouteParams-CnM-IK2I.d.ts → usePublicRouteParams-BdF8bZgs.d.ts} +1 -1
  40. package/dist/utils.d.ts +2 -15
  41. package/dist/utils.js +4 -145
  42. package/dist/utils.js.map +1 -1
  43. package/dist/validation.d.ts +1 -1
  44. package/docs/api/classes/ColumnFactory.md +1 -1
  45. package/docs/api/classes/ErrorBoundary.md +1 -1
  46. package/docs/api/classes/InvalidScopeError.md +1 -1
  47. package/docs/api/classes/MissingUserContextError.md +1 -1
  48. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  49. package/docs/api/classes/PermissionDeniedError.md +1 -1
  50. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  51. package/docs/api/classes/RBACAuditManager.md +1 -1
  52. package/docs/api/classes/RBACCache.md +1 -1
  53. package/docs/api/classes/RBACEngine.md +1 -1
  54. package/docs/api/classes/RBACError.md +1 -1
  55. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  56. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  57. package/docs/api/classes/StorageUtils.md +1 -1
  58. package/docs/api/enums/FileCategory.md +1 -1
  59. package/docs/api/interfaces/AggregateConfig.md +1 -1
  60. package/docs/api/interfaces/ButtonProps.md +1 -1
  61. package/docs/api/interfaces/CardProps.md +1 -1
  62. package/docs/api/interfaces/ColorPalette.md +1 -1
  63. package/docs/api/interfaces/ColorShade.md +1 -1
  64. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  65. package/docs/api/interfaces/DataRecord.md +1 -1
  66. package/docs/api/interfaces/DataTableAction.md +1 -1
  67. package/docs/api/interfaces/DataTableColumn.md +3 -3
  68. package/docs/api/interfaces/DataTableProps.md +1 -1
  69. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  70. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  71. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  72. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  73. package/docs/api/interfaces/FileMetadata.md +1 -1
  74. package/docs/api/interfaces/FileReference.md +1 -1
  75. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  76. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  77. package/docs/api/interfaces/FileUploadProps.md +1 -1
  78. package/docs/api/interfaces/FooterProps.md +1 -1
  79. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  80. package/docs/api/interfaces/InputProps.md +1 -1
  81. package/docs/api/interfaces/LabelProps.md +1 -1
  82. package/docs/api/interfaces/LoginFormProps.md +1 -1
  83. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  84. package/docs/api/interfaces/NavigationContextType.md +1 -1
  85. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  86. package/docs/api/interfaces/NavigationItem.md +1 -1
  87. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  88. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  89. package/docs/api/interfaces/Organisation.md +1 -1
  90. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  91. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  92. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  93. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  94. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  95. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  96. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  97. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  98. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  99. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  100. package/docs/api/interfaces/PaletteData.md +1 -1
  101. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  102. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  103. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  104. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  105. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  106. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  107. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  108. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  109. package/docs/api/interfaces/RBACConfig.md +1 -1
  110. package/docs/api/interfaces/RBACLogger.md +1 -1
  111. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  112. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  113. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  114. package/docs/api/interfaces/RouteConfig.md +1 -1
  115. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  116. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  117. package/docs/api/interfaces/StorageConfig.md +1 -1
  118. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  119. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  120. package/docs/api/interfaces/StorageListOptions.md +1 -1
  121. package/docs/api/interfaces/StorageListResult.md +1 -1
  122. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  123. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  124. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  125. package/docs/api/interfaces/StyleImport.md +1 -1
  126. package/docs/api/interfaces/SwitchProps.md +1 -1
  127. package/docs/api/interfaces/ToastActionElement.md +1 -1
  128. package/docs/api/interfaces/ToastProps.md +1 -1
  129. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  130. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  131. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  132. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  133. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  134. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  135. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  136. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  137. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  138. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  139. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  140. package/docs/api/interfaces/UserEventAccess.md +1 -1
  141. package/docs/api/interfaces/UserMenuProps.md +1 -1
  142. package/docs/api/interfaces/UserProfile.md +1 -1
  143. package/docs/api/modules.md +42 -6
  144. package/docs/api-reference/hooks.md +53 -0
  145. package/docs/api-reference/providers.md +60 -0
  146. package/docs/core-concepts/authentication.md +2 -0
  147. package/docs/implementation-guides/authentication.md +1 -0
  148. package/docs/security/README.md +59 -0
  149. package/package.json +1 -1
  150. package/src/components/DataTable/components/ColumnFilter.tsx +2 -1
  151. package/src/components/DataTable/components/EditableRow.tsx +7 -2
  152. package/src/components/DataTable/components/FilterRow.tsx +22 -11
  153. package/src/components/DataTable/components/PaginationControls.tsx +1 -1
  154. package/src/components/DataTable/components/UnifiedTableBody.tsx +39 -10
  155. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +2 -2
  156. package/src/components/PaceAppLayout/PaceAppLayout.tsx +126 -25
  157. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +2 -1
  158. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +9 -9
  159. package/src/index.ts +3 -0
  160. package/src/providers/services/AuthServiceProvider.tsx +4 -3
  161. package/src/providers/services/UnifiedAuthProvider.tsx +1 -1
  162. package/src/rbac/engine.ts +2 -0
  163. package/src/services/AuthService.ts +79 -1
  164. package/src/services/__tests__/AuthService.test.ts +184 -0
  165. package/src/types/database.ts +21 -9
  166. package/src/types/rbac-functions.ts +2 -1
  167. package/src/utils/__tests__/sessionTracking.unit.test.ts +6 -171
  168. package/src/utils/sessionTracking.ts +7 -81
  169. package/dist/chunk-EWKCROSF.js.map +0 -1
  170. package/dist/chunk-NFPV7MRN.js +0 -94
  171. package/dist/chunk-NFPV7MRN.js.map +0 -1
  172. package/src/providers/AuthProvider.simplified.tsx +0 -974
  173. package/dist/{DataTable-H2WIR2DN.js.map → DataTable-5HITILXS.js.map} +0 -0
  174. package/dist/{UnifiedAuthProvider-XU4BHFXZ.js.map → UnifiedAuthProvider-A7I23UCN.js.map} +0 -0
  175. package/dist/{api-KG4A2X7P.js.map → api-5I3E47G2.js.map} +0 -0
  176. package/dist/{chunk-DMNMZKWS.js.map → chunk-2W4WKJVF.js.map} +0 -0
  177. package/dist/{chunk-MOMYOQMC.js.map → chunk-3TKTL5AZ.js.map} +0 -0
  178. package/dist/{chunk-LT6RKRA7.js.map → chunk-D6MEKC27.js.map} +0 -0
  179. package/dist/{chunk-KBG34SVL.js.map → chunk-EYSXQ756.js.map} +0 -0
  180. package/dist/{chunk-ZXY5NTJB.js.map → chunk-EZ64QG2I.js.map} +0 -0
  181. package/dist/{chunk-VJ7MPS2K.js.map → chunk-S4D3Z723.js.map} +0 -0
  182. package/dist/{chunk-QDDUU625.js.map → chunk-WWNOVFDC.js.map} +0 -0
  183. package/dist/{chunk-GVRSXXAA.js.map → chunk-YFMENCR4.js.map} +0 -0
  184. 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={true}
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
- const { isSuperAdmin } = await import('../../rbac/api');
371
- const isSuper = await isSuperAdmin(user.id);
372
-
373
- if (isSuper) {
374
- // Super admin bypass - allow access regardless of organisation context
375
- return true;
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: permission as 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
- if (!filterNavigationByPermissions || !enforcePermissions) {
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
- const filtered = await Promise.all(
513
- baseMenuItems.map(async (item) => {
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
- try {
520
- const hasAccess = await checkPermission(permission, pageId);
521
- return { item, hasAccess };
522
- } catch {
523
- return { item, hasAccess: false };
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
- if (!isMounted) return;
621
+ if (!isMounted) return;
529
622
 
530
- const accessibleItems = filtered
531
- .filter(({ hasAccess }) => hasAccess)
532
- .map(({ item }) => item);
623
+ const accessibleItems = filtered
624
+ .filter(({ hasAccess }) => hasAccess)
625
+ .map(({ item }) => item);
533
626
 
534
- setFilteredMenuItems(accessibleItems);
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, enforcePermissions, pageIdMapping, routePermissions, defaultPermission]);
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
- if (pageId === 'admin' && permission === 'read') {
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
- Object.defineProperty(window, 'location', {
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: 'test-path'
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}
@@ -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
  });