@jmruthers/pace-core 0.5.108 → 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 (177) 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-WFCHVWTY.js → DataTable-5HITILXS.js} +7 -7
  4. package/dist/{UnifiedAuthProvider-XU4BHFXZ.js → UnifiedAuthProvider-A7I23UCN.js} +3 -3
  5. package/dist/{api-KG4A2X7P.js → api-5I3E47G2.js} +2 -2
  6. package/dist/{chunk-DMNMZKWS.js → chunk-2W4WKJVF.js} +4 -4
  7. package/dist/{chunk-MOMYOQMC.js → chunk-3TKTL5AZ.js} +13 -13
  8. package/dist/{chunk-X4FRXJV6.js → chunk-AUXS7XSO.js} +57 -6
  9. package/dist/{chunk-X4FRXJV6.js.map → chunk-AUXS7XSO.js.map} +1 -1
  10. package/dist/{chunk-LT6RKRA7.js → chunk-D6MEKC27.js} +2 -2
  11. package/dist/{chunk-KBG34SVL.js → chunk-EYSXQ756.js} +2 -2
  12. package/dist/{chunk-ZXY5NTJB.js → chunk-EZ64QG2I.js} +2 -2
  13. package/dist/{chunk-S63MFSY6.js → chunk-F6TSYCKP.js} +4 -2
  14. package/dist/{chunk-S63MFSY6.js.map → chunk-F6TSYCKP.js.map} +1 -1
  15. package/dist/chunk-GZRXOUBE.js +176 -0
  16. package/dist/chunk-GZRXOUBE.js.map +1 -0
  17. package/dist/{chunk-B3QX32P5.js → chunk-P72NKAT5.js} +41 -24
  18. package/dist/chunk-P72NKAT5.js.map +1 -0
  19. package/dist/{chunk-VJ7MPS2K.js → chunk-S4D3Z723.js} +6 -6
  20. package/dist/{chunk-IMZGJ2X7.js → chunk-UW2DE6JX.js} +4 -4
  21. package/dist/{chunk-QDDUU625.js → chunk-WWNOVFDC.js} +4 -4
  22. package/dist/{chunk-GVRSXXAA.js → chunk-YFMENCR4.js} +3 -3
  23. package/dist/components.js +9 -9
  24. package/dist/{database-BXAfr2Y_.d.ts → database-C6jy7EOu.d.ts} +21 -9
  25. package/dist/{formatting-BiEv5oEk.d.ts → formatting-B1jSqgl-.d.ts} +16 -1
  26. package/dist/hooks.d.ts +2 -2
  27. package/dist/hooks.js +7 -7
  28. package/dist/index.d.ts +6 -6
  29. package/dist/index.js +16 -14
  30. package/dist/index.js.map +1 -1
  31. package/dist/providers.d.ts +4 -3
  32. package/dist/providers.js +2 -2
  33. package/dist/rbac/index.d.ts +1 -1
  34. package/dist/rbac/index.js +8 -8
  35. package/dist/types.d.ts +2 -2
  36. package/dist/{usePublicRouteParams-CnM-IK2I.d.ts → usePublicRouteParams-BdF8bZgs.d.ts} +1 -1
  37. package/dist/utils.d.ts +2 -15
  38. package/dist/utils.js +4 -145
  39. package/dist/utils.js.map +1 -1
  40. package/dist/validation.d.ts +1 -1
  41. package/docs/api/classes/ColumnFactory.md +1 -1
  42. package/docs/api/classes/ErrorBoundary.md +1 -1
  43. package/docs/api/classes/InvalidScopeError.md +1 -1
  44. package/docs/api/classes/MissingUserContextError.md +1 -1
  45. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  46. package/docs/api/classes/PermissionDeniedError.md +1 -1
  47. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  48. package/docs/api/classes/RBACAuditManager.md +1 -1
  49. package/docs/api/classes/RBACCache.md +1 -1
  50. package/docs/api/classes/RBACEngine.md +1 -1
  51. package/docs/api/classes/RBACError.md +1 -1
  52. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  53. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  54. package/docs/api/classes/StorageUtils.md +1 -1
  55. package/docs/api/enums/FileCategory.md +1 -1
  56. package/docs/api/interfaces/AggregateConfig.md +1 -1
  57. package/docs/api/interfaces/ButtonProps.md +1 -1
  58. package/docs/api/interfaces/CardProps.md +1 -1
  59. package/docs/api/interfaces/ColorPalette.md +1 -1
  60. package/docs/api/interfaces/ColorShade.md +1 -1
  61. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  62. package/docs/api/interfaces/DataRecord.md +1 -1
  63. package/docs/api/interfaces/DataTableAction.md +1 -1
  64. package/docs/api/interfaces/DataTableColumn.md +3 -3
  65. package/docs/api/interfaces/DataTableProps.md +1 -1
  66. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  67. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  68. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  69. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  70. package/docs/api/interfaces/FileMetadata.md +1 -1
  71. package/docs/api/interfaces/FileReference.md +1 -1
  72. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  73. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  74. package/docs/api/interfaces/FileUploadProps.md +1 -1
  75. package/docs/api/interfaces/FooterProps.md +1 -1
  76. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  77. package/docs/api/interfaces/InputProps.md +1 -1
  78. package/docs/api/interfaces/LabelProps.md +1 -1
  79. package/docs/api/interfaces/LoginFormProps.md +1 -1
  80. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  81. package/docs/api/interfaces/NavigationContextType.md +1 -1
  82. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  83. package/docs/api/interfaces/NavigationItem.md +1 -1
  84. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  85. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  86. package/docs/api/interfaces/Organisation.md +1 -1
  87. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  88. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  89. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  90. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  91. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  92. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  93. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  94. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  95. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  96. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  97. package/docs/api/interfaces/PaletteData.md +1 -1
  98. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  99. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  100. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  101. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  102. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  103. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  104. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  105. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  106. package/docs/api/interfaces/RBACConfig.md +1 -1
  107. package/docs/api/interfaces/RBACLogger.md +1 -1
  108. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  109. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  110. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  111. package/docs/api/interfaces/RouteConfig.md +1 -1
  112. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  113. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  114. package/docs/api/interfaces/StorageConfig.md +1 -1
  115. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  116. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  117. package/docs/api/interfaces/StorageListOptions.md +1 -1
  118. package/docs/api/interfaces/StorageListResult.md +1 -1
  119. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  120. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  121. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  122. package/docs/api/interfaces/StyleImport.md +1 -1
  123. package/docs/api/interfaces/SwitchProps.md +1 -1
  124. package/docs/api/interfaces/ToastActionElement.md +1 -1
  125. package/docs/api/interfaces/ToastProps.md +1 -1
  126. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  127. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  128. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  129. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  130. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  131. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  132. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  133. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  134. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  135. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  136. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  137. package/docs/api/interfaces/UserEventAccess.md +1 -1
  138. package/docs/api/interfaces/UserMenuProps.md +1 -1
  139. package/docs/api/interfaces/UserProfile.md +1 -1
  140. package/docs/api/modules.md +37 -3
  141. package/docs/api-reference/hooks.md +53 -0
  142. package/docs/api-reference/providers.md +60 -0
  143. package/docs/core-concepts/authentication.md +2 -0
  144. package/docs/implementation-guides/authentication.md +1 -0
  145. package/docs/security/README.md +59 -0
  146. package/package.json +1 -1
  147. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +2 -2
  148. package/src/components/PaceAppLayout/PaceAppLayout.tsx +48 -16
  149. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +2 -1
  150. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +9 -9
  151. package/src/index.ts +3 -0
  152. package/src/providers/services/AuthServiceProvider.tsx +4 -3
  153. package/src/providers/services/UnifiedAuthProvider.tsx +1 -1
  154. package/src/rbac/engine.ts +2 -0
  155. package/src/services/AuthService.ts +79 -1
  156. package/src/services/__tests__/AuthService.test.ts +184 -0
  157. package/src/types/database.ts +21 -9
  158. package/src/types/rbac-functions.ts +2 -1
  159. package/src/utils/__tests__/sessionTracking.unit.test.ts +6 -171
  160. package/src/utils/sessionTracking.ts +7 -81
  161. package/dist/chunk-B3QX32P5.js.map +0 -1
  162. package/dist/chunk-NFPV7MRN.js +0 -94
  163. package/dist/chunk-NFPV7MRN.js.map +0 -1
  164. package/src/providers/AuthProvider.simplified.tsx +0 -974
  165. package/dist/{DataTable-WFCHVWTY.js.map → DataTable-5HITILXS.js.map} +0 -0
  166. package/dist/{UnifiedAuthProvider-XU4BHFXZ.js.map → UnifiedAuthProvider-A7I23UCN.js.map} +0 -0
  167. package/dist/{api-KG4A2X7P.js.map → api-5I3E47G2.js.map} +0 -0
  168. package/dist/{chunk-DMNMZKWS.js.map → chunk-2W4WKJVF.js.map} +0 -0
  169. package/dist/{chunk-MOMYOQMC.js.map → chunk-3TKTL5AZ.js.map} +0 -0
  170. package/dist/{chunk-LT6RKRA7.js.map → chunk-D6MEKC27.js.map} +0 -0
  171. package/dist/{chunk-KBG34SVL.js.map → chunk-EYSXQ756.js.map} +0 -0
  172. package/dist/{chunk-ZXY5NTJB.js.map → chunk-EZ64QG2I.js.map} +0 -0
  173. package/dist/{chunk-VJ7MPS2K.js.map → chunk-S4D3Z723.js.map} +0 -0
  174. package/dist/{chunk-IMZGJ2X7.js.map → chunk-UW2DE6JX.js.map} +0 -0
  175. package/dist/{chunk-QDDUU625.js.map → chunk-WWNOVFDC.js.map} +0 -0
  176. package/dist/{chunk-GVRSXXAA.js.map → chunk-YFMENCR4.js.map} +0 -0
  177. package/dist/{validation-D8VcbTzC.d.ts → validation-DnhrNMju.d.ts} +2 -2
@@ -76,6 +76,59 @@ function App() {
76
76
  }
77
77
  ```
78
78
 
79
+ > **Note**: Login and logout tracking is automatically handled by `UnifiedAuthProvider`. No manual intervention is required.
80
+
81
+ ### useSessionTracking
82
+
83
+ Utility hook for manual session tracking of event switches and session expiration. **Note**: Login and logout are automatically tracked by `UnifiedAuthProvider`, so those methods are not available here.
84
+
85
+ ```typescript
86
+ function useSessionTracking(
87
+ supabaseClient: SupabaseClient,
88
+ appName?: string
89
+ ): {
90
+ trackEventSwitch: (eventId: string) => Promise<void>;
91
+ trackSessionExpired: () => Promise<void>;
92
+ }
93
+ ```
94
+
95
+ #### Usage
96
+
97
+ ```tsx
98
+ import { useSessionTracking } from '@jmruthers/pace-core';
99
+ import { supabase } from './lib/supabase';
100
+
101
+ function MyComponent() {
102
+ const { trackEventSwitch, trackSessionExpired } = useSessionTracking(
103
+ supabase,
104
+ 'MY_APP'
105
+ );
106
+
107
+ const handleEventSwitch = async (eventId: string) => {
108
+ await trackEventSwitch(eventId);
109
+ // Event switch logic...
110
+ };
111
+
112
+ const handleSessionExpiration = async () => {
113
+ await trackSessionExpired();
114
+ // Session expiration logic...
115
+ };
116
+
117
+ return (
118
+ // Component JSX
119
+ );
120
+ }
121
+ ```
122
+
123
+ #### Methods
124
+
125
+ | Method | Description |
126
+ |--------|-------------|
127
+ | `trackEventSwitch(eventId)` | Track when a user switches to a different event. |
128
+ | `trackSessionExpired()` | Track when a session expires. |
129
+
130
+ > **Automatic Tracking**: When using `UnifiedAuthProvider`, login and logout events are **automatically tracked**. You only need to use this hook for event switches or session expirations, which are not automatically tracked.
131
+
79
132
 
80
133
  ## Event Management Hooks
81
134
 
@@ -206,6 +206,66 @@ The inactivity tracker monitors the following user interactions:
206
206
  - **Automatic cleanup**: All timers and listeners are properly cleaned up
207
207
  - **Error handling**: Graceful fallback if localStorage or BroadcastChannel fail
208
208
 
209
+ #### Automatic Login History Tracking
210
+
211
+ The `UnifiedAuthProvider` automatically tracks all user logins for security auditing and compliance:
212
+
213
+ - **Automatic Tracking** - No manual intervention required, tracking happens automatically on login/logout
214
+ - **Complete Audit Trail** - Records user ID, email, timestamp, IP address, user agent, and application context
215
+ - **Database Storage** - All login events are stored in `rbac_user_login_history` table
216
+ - **Application Context** - Tracks which application the user logged into (when `appName` is provided)
217
+ - **Non-Blocking** - Tracking failures don't prevent authentication from succeeding
218
+ - **Privacy Compliant** - Users can only view their own login history (RLS enforced)
219
+
220
+ Login history is tracked automatically when you use `UnifiedAuthProvider`. No additional configuration is required:
221
+
222
+ ```tsx
223
+ <UnifiedAuthProvider
224
+ supabaseClient={supabase}
225
+ appName="MY_APP" // Enables app-specific tracking in login history
226
+ // ... other props
227
+ >
228
+ <AppContent />
229
+ </UnifiedAuthProvider>
230
+ ```
231
+
232
+ **What Gets Tracked:**
233
+
234
+ - User ID and email
235
+ - Login timestamp
236
+ - Session ID
237
+ - IP address (if available)
238
+ - User agent string
239
+ - Application ID (if `appName` is provided)
240
+ - Organisation ID
241
+ - Event ID (if applicable)
242
+
243
+ **Querying Login History:**
244
+
245
+ Login history can be queried directly from the database using RLS-protected queries:
246
+
247
+ ```sql
248
+ -- Get user's login history
249
+ SELECT
250
+ login_timestamp,
251
+ email,
252
+ ip_address,
253
+ user_agent,
254
+ app_id,
255
+ event_id
256
+ FROM rbac_user_login_history
257
+ WHERE user_id = auth.uid()
258
+ ORDER BY login_timestamp DESC
259
+ LIMIT 100;
260
+ ```
261
+
262
+ **Security Notes:**
263
+
264
+ - Login history insertion uses `SECURITY DEFINER` functions (bypasses RLS)
265
+ - RLS policies ensure users can only view their own login history
266
+ - Failed tracking attempts are logged as warnings but don't break authentication
267
+ - All tracking is asynchronous and non-blocking
268
+
209
269
  ## OrganisationProvider
210
270
 
211
271
  Manages multi-tenant organisation context and user organisation memberships. **Automatically sets database organisation context** to ensure RLS policies work correctly.
@@ -77,6 +77,7 @@ sequenceDiagram
77
77
  - **Persistent State** - Authentication state persists across page reloads
78
78
  - **Multi-Tab Support** - Authentication state synchronized across tabs
79
79
  - **Graceful Degradation** - Handles network issues and token expiry
80
+ - **Automatic Login History** - User login events are automatically tracked in `rbac_user_login_history` table
80
81
 
81
82
  ### Security Features
82
83
 
@@ -84,6 +85,7 @@ sequenceDiagram
84
85
  - **JWT Tokens** - Secure, stateless authentication
85
86
  - **CSRF Protection** - Cross-site request forgery prevention
86
87
  - **Audit Logging** - Complete action tracking for compliance
88
+ - **Login History Tracking** - Automatic tracking of all user logins with timestamps, IP addresses, user agents, and application context
87
89
 
88
90
  ## Multi-Tenancy
89
91
 
@@ -23,6 +23,7 @@ PACE Core provides a comprehensive authentication system built on Supabase that
23
23
  - **🔒 Session Persistence** - Secure session management with auto-refresh
24
24
  - **🎯 Permission Integration** - Built-in RBAC integration
25
25
  - **📊 Debug Support** - Comprehensive debugging and monitoring
26
+ - **📝 Automatic Login History** - All user logins automatically tracked for audit trails
26
27
 
27
28
  ## Quick Start
28
29
 
@@ -16,6 +16,7 @@ PACE Core is designed with security as a first-class concern, providing:
16
16
  - **Comprehensive RBAC system** with fine-grained permissions
17
17
  - **Secure data access** with row-level security
18
18
  - **Audit logging** for compliance and monitoring
19
+ - **Automatic login history tracking** for security audits and compliance
19
20
  - **Input validation** and sanitization
20
21
  - **XSS protection** and secure coding practices
21
22
  - **Auto-logout on inactivity** for enhanced security
@@ -59,6 +60,64 @@ Sessions are automatically managed by PACE Core:
59
60
  - **Session validation** on every request
60
61
  - **Automatic logout** on token expiration
61
62
  - **Inactivity auto-logout** after 30 minutes of inactivity (configurable)
63
+ - **Automatic login history tracking** - All user logins are automatically recorded
64
+
65
+ ### 2.1 Login History Tracking
66
+
67
+ PACE Core **automatically tracks all user logins** for security auditing and compliance - no manual intervention required.
68
+
69
+ - **Fully Automatic** - Simply use `UnifiedAuthProvider` with `appName` prop - tracking happens automatically
70
+ - **No Configuration Needed** - No calls to tracking functions, no setup code, no manual intervention
71
+ - **Complete Audit Trail** - Records user ID, email, timestamp, IP address, user agent, and application context
72
+ - **Database Storage** - All login events are stored in `rbac_user_login_history` table automatically
73
+ - **Application Context** - Tracks which application the user logged into (when `appName` is provided)
74
+ - **Non-Blocking** - Tracking failures don't prevent authentication from succeeding
75
+ - **Privacy Compliant** - Users can only view their own login history (RLS enforced)
76
+
77
+ **How It Works:**
78
+
79
+ Login history tracking is **completely automatic** when you use `UnifiedAuthProvider`. Simply provide the `appName` prop (which is already required) and tracking happens automatically:
80
+
81
+ ```tsx
82
+ import { UnifiedAuthProvider } from '@jmruthers/pace-core';
83
+
84
+ function App() {
85
+ return (
86
+ <UnifiedAuthProvider
87
+ supabaseClient={supabaseClient}
88
+ appName="MY_APP" // Optional: Enables app-specific tracking
89
+ // ... other props
90
+ >
91
+ <YourApp />
92
+ </UnifiedAuthProvider>
93
+ );
94
+ }
95
+ ```
96
+
97
+ **Querying Login History:**
98
+
99
+ Login history can be queried directly from the database:
100
+
101
+ ```sql
102
+ -- Get user's login history
103
+ SELECT
104
+ login_timestamp,
105
+ email,
106
+ ip_address,
107
+ user_agent,
108
+ app_id
109
+ FROM rbac_user_login_history
110
+ WHERE user_id = auth.uid()
111
+ ORDER BY login_timestamp DESC
112
+ LIMIT 100;
113
+ ```
114
+
115
+ **Security Notes:**
116
+
117
+ - Login history insertion uses `SECURITY DEFINER` functions (bypasses RLS)
118
+ - RLS policies ensure users can only view their own login history
119
+ - Failed tracking attempts are logged but don't break authentication
120
+ - All tracking is asynchronous and non-blocking
62
121
 
63
122
  ```tsx
64
123
  import { useUnifiedAuth } from '@jmruthers/pace-core';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmruthers/pace-core",
3
- "version": "0.5.108",
3
+ "version": "0.5.109",
4
4
  "description": "Clean, modern React component library with Tailwind v4 styling and native utilities",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -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: permission as 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
- const filtered = await Promise.all(
581
- baseMenuItems.map(async (item) => {
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
- try {
588
- const hasAccess = await checkPermission(permission, pageId);
589
- return { item, hasAccess };
590
- } catch {
591
- // On error, default to hiding the item (fail-safe)
592
- return { item, hasAccess: false };
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
- if (!isMounted) return;
621
+ if (!isMounted) return;
598
622
 
599
- const accessibleItems = filtered
600
- .filter(({ hasAccess }) => hasAccess)
601
- .map(({ item }) => item);
623
+ const accessibleItems = filtered
624
+ .filter(({ hasAccess }) => hasAccess)
625
+ .map(({ item }) => item);
602
626
 
603
- 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
+ }
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
- 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