@jmruthers/pace-core 0.5.110 → 0.5.112

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 (236) hide show
  1. package/dist/{AuthService-DrHrvXNZ.d.ts → AuthService-CVgsgtaZ.d.ts} +8 -0
  2. package/dist/{DataTable-D3BK2FCN.js → DataTable-3D3BUZDV.js} +8 -8
  3. package/dist/{UnifiedAuthProvider-A7I23UCN.js → UnifiedAuthProvider-KZZUO27W.js} +3 -3
  4. package/dist/{api-PIE4JRFS.js → api-QPMBZZUZ.js} +5 -3
  5. package/dist/{audit-65VNHEV2.js → audit-H4YJJF7R.js} +2 -2
  6. package/dist/{chunk-Q7APDV6H.js → chunk-3OGQLOJM.js} +23 -7
  7. package/dist/chunk-3OGQLOJM.js.map +1 -0
  8. package/dist/{chunk-EYSXQ756.js → chunk-7H75SHXZ.js} +2 -2
  9. package/dist/{chunk-D6MEKC27.js → chunk-BUN7NMV7.js} +2 -2
  10. package/dist/{chunk-AWK2FAUN.js → chunk-C5RN4TE5.js} +7 -7
  11. package/dist/{chunk-3J5N2T2N.js → chunk-EKVVTPIF.js} +183 -127
  12. package/dist/chunk-EKVVTPIF.js.map +1 -0
  13. package/dist/{chunk-2W4WKJVF.js → chunk-F6QB26OS.js} +290 -255
  14. package/dist/chunk-F6QB26OS.js.map +1 -0
  15. package/dist/{chunk-HADXAZT3.js → chunk-I7JC7PTJ.js} +54 -92
  16. package/dist/chunk-I7JC7PTJ.js.map +1 -0
  17. package/dist/{chunk-EZ64QG2I.js → chunk-L36JW4KV.js} +2 -2
  18. package/dist/{chunk-7GBEBJLR.js → chunk-MNSGWRPB.js} +45 -37
  19. package/dist/chunk-MNSGWRPB.js.map +1 -0
  20. package/dist/{chunk-YFMENCR4.js → chunk-NEONKMTU.js} +3 -3
  21. package/dist/{chunk-AUXS7XSO.js → chunk-OO3V7W4H.js} +35 -11
  22. package/dist/chunk-OO3V7W4H.js.map +1 -0
  23. package/dist/{chunk-XRSP3H52.js → chunk-TAJRS6YB.js} +57 -23
  24. package/dist/chunk-TAJRS6YB.js.map +1 -0
  25. package/dist/{chunk-HGZSO43Y.js → chunk-WMPZY26G.js} +8 -4
  26. package/dist/{chunk-HGZSO43Y.js.map → chunk-WMPZY26G.js.map} +1 -1
  27. package/dist/components.js +10 -10
  28. package/dist/hooks.d.ts +11 -1
  29. package/dist/hooks.js +9 -7
  30. package/dist/hooks.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +13 -13
  33. package/dist/providers.d.ts +2 -2
  34. package/dist/providers.js +2 -2
  35. package/dist/rbac/index.d.ts +13 -8
  36. package/dist/rbac/index.js +9 -9
  37. package/dist/utils.js +1 -1
  38. package/docs/api/classes/ColumnFactory.md +1 -1
  39. package/docs/api/classes/ErrorBoundary.md +1 -1
  40. package/docs/api/classes/InvalidScopeError.md +4 -4
  41. package/docs/api/classes/MissingUserContextError.md +4 -4
  42. package/docs/api/classes/OrganisationContextRequiredError.md +4 -4
  43. package/docs/api/classes/PermissionDeniedError.md +4 -4
  44. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  45. package/docs/api/classes/RBACAuditManager.md +8 -8
  46. package/docs/api/classes/RBACCache.md +8 -8
  47. package/docs/api/classes/RBACEngine.md +4 -4
  48. package/docs/api/classes/RBACError.md +4 -4
  49. package/docs/api/classes/RBACNotInitializedError.md +4 -4
  50. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  51. package/docs/api/classes/StorageUtils.md +1 -1
  52. package/docs/api/enums/FileCategory.md +1 -1
  53. package/docs/api/interfaces/AggregateConfig.md +1 -1
  54. package/docs/api/interfaces/ButtonProps.md +1 -1
  55. package/docs/api/interfaces/CardProps.md +1 -1
  56. package/docs/api/interfaces/ColorPalette.md +1 -1
  57. package/docs/api/interfaces/ColorShade.md +1 -1
  58. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  59. package/docs/api/interfaces/DataRecord.md +1 -1
  60. package/docs/api/interfaces/DataTableAction.md +1 -1
  61. package/docs/api/interfaces/DataTableColumn.md +1 -1
  62. package/docs/api/interfaces/DataTableProps.md +1 -1
  63. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  64. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  65. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  66. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  67. package/docs/api/interfaces/FileMetadata.md +1 -1
  68. package/docs/api/interfaces/FileReference.md +1 -1
  69. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  70. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  71. package/docs/api/interfaces/FileUploadProps.md +1 -1
  72. package/docs/api/interfaces/FooterProps.md +1 -1
  73. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  74. package/docs/api/interfaces/InputProps.md +1 -1
  75. package/docs/api/interfaces/LabelProps.md +1 -1
  76. package/docs/api/interfaces/LoginFormProps.md +1 -1
  77. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  78. package/docs/api/interfaces/NavigationContextType.md +1 -1
  79. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  80. package/docs/api/interfaces/NavigationItem.md +1 -1
  81. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  82. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  83. package/docs/api/interfaces/Organisation.md +1 -1
  84. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  85. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  86. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  87. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  88. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  89. package/docs/api/interfaces/PaceLoginPageProps.md +4 -4
  90. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  91. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  92. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  93. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  94. package/docs/api/interfaces/PaletteData.md +1 -1
  95. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  96. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  97. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  98. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  99. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  100. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  101. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  102. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  103. package/docs/api/interfaces/RBACConfig.md +1 -1
  104. package/docs/api/interfaces/RBACLogger.md +1 -1
  105. package/docs/api/interfaces/RoleBasedRouterContextType.md +8 -8
  106. package/docs/api/interfaces/RoleBasedRouterProps.md +10 -10
  107. package/docs/api/interfaces/RouteAccessRecord.md +10 -10
  108. package/docs/api/interfaces/RouteConfig.md +19 -6
  109. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  110. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  111. package/docs/api/interfaces/StorageConfig.md +1 -1
  112. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  113. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  114. package/docs/api/interfaces/StorageListOptions.md +1 -1
  115. package/docs/api/interfaces/StorageListResult.md +1 -1
  116. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  117. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  118. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  119. package/docs/api/interfaces/StyleImport.md +1 -1
  120. package/docs/api/interfaces/SwitchProps.md +1 -1
  121. package/docs/api/interfaces/ToastActionElement.md +1 -1
  122. package/docs/api/interfaces/ToastProps.md +1 -1
  123. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  124. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  125. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  126. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  127. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  128. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  129. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  130. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  131. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  132. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  133. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  134. package/docs/api/interfaces/UserEventAccess.md +1 -1
  135. package/docs/api/interfaces/UserMenuProps.md +1 -1
  136. package/docs/api/interfaces/UserProfile.md +1 -1
  137. package/docs/api/modules.md +36 -36
  138. package/docs/api-reference/hooks.md +8 -4
  139. package/docs/architecture/rpc-function-standards.md +3 -1
  140. package/docs/best-practices/common-patterns.md +3 -3
  141. package/docs/best-practices/deployment.md +10 -4
  142. package/docs/best-practices/performance.md +11 -3
  143. package/docs/core-concepts/organisations.md +8 -8
  144. package/docs/core-concepts/permissions.md +133 -72
  145. package/docs/migration/rbac-migration.md +65 -66
  146. package/docs/rbac/advanced-patterns.md +15 -22
  147. package/docs/rbac/examples.md +12 -12
  148. package/docs/rbac/getting-started.md +3 -3
  149. package/docs/rbac/troubleshooting.md +2 -1
  150. package/package.json +1 -1
  151. package/src/components/DataTable/DataTable.test.tsx +405 -154
  152. package/src/components/DataTable/components/DataTableCore.tsx +6 -1
  153. package/src/components/DataTable/components/__tests__/ActionButtons.test.tsx +913 -0
  154. package/src/components/DataTable/components/__tests__/ColumnFilter.test.tsx +609 -0
  155. package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +434 -0
  156. package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +120 -0
  157. package/src/components/DataTable/components/__tests__/PaginationControls.test.tsx +519 -0
  158. package/src/components/DataTable/examples/__tests__/HierarchicalActionsExample.test.tsx +316 -0
  159. package/src/components/DataTable/examples/__tests__/InitialPageSizeExample.test.tsx +211 -0
  160. package/src/components/EventSelector/EventSelector.tsx +32 -2
  161. package/src/components/FileUpload/FileUpload.tsx +2 -8
  162. package/src/components/NavigationMenu/NavigationMenu.test.tsx +56 -8
  163. package/src/components/NavigationMenu/NavigationMenu.tsx +75 -12
  164. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +193 -63
  165. package/src/components/PaceAppLayout/PaceAppLayout.tsx +102 -135
  166. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +41 -2
  167. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +61 -6
  168. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +71 -21
  169. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +113 -41
  170. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +155 -45
  171. package/src/components/PaceLoginPage/PaceLoginPage.tsx +30 -1
  172. package/src/hooks/__tests__/usePermissionCache.simple.test.ts +63 -5
  173. package/src/hooks/__tests__/usePermissionCache.unit.test.ts +156 -72
  174. package/src/hooks/__tests__/useRBAC.unit.test.ts +4 -38
  175. package/src/hooks/index.ts +1 -1
  176. package/src/hooks/useFileDisplay.ts +51 -0
  177. package/src/hooks/usePermissionCache.test.ts +112 -68
  178. package/src/hooks/usePermissionCache.ts +55 -15
  179. package/src/rbac/README.md +81 -39
  180. package/src/rbac/__tests__/adapters.comprehensive.test.tsx +3 -3
  181. package/src/rbac/__tests__/engine.comprehensive.test.ts +15 -6
  182. package/src/rbac/__tests__/rbac-core.test.tsx +1 -1
  183. package/src/rbac/__tests__/rbac-engine-core-logic.test.ts +57 -4
  184. package/src/rbac/__tests__/rbac-engine-simplified.test.ts +3 -2
  185. package/src/rbac/adapters.tsx +4 -4
  186. package/src/rbac/api.test.ts +37 -13
  187. package/src/rbac/api.ts +25 -8
  188. package/src/rbac/audit-enhanced.ts +14 -2
  189. package/src/rbac/audit.test.ts +18 -8
  190. package/src/rbac/audit.ts +25 -6
  191. package/src/rbac/cache.test.ts +12 -0
  192. package/src/rbac/cache.ts +29 -9
  193. package/src/rbac/components/EnhancedNavigationMenu.test.tsx +1 -1
  194. package/src/rbac/components/NavigationGuard.tsx +14 -14
  195. package/src/rbac/components/NavigationProvider.test.tsx +1 -1
  196. package/src/rbac/components/PagePermissionGuard.tsx +4 -3
  197. package/src/rbac/components/PagePermissionProvider.test.tsx +1 -1
  198. package/src/rbac/components/PermissionEnforcer.tsx +19 -15
  199. package/src/rbac/components/RoleBasedRouter.tsx +16 -9
  200. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +123 -107
  201. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1 -1
  202. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +121 -103
  203. package/src/rbac/docs/event-based-apps.md +6 -6
  204. package/src/rbac/engine.ts +12 -2
  205. package/src/rbac/hooks/useCan.test.ts +29 -2
  206. package/src/rbac/hooks/usePermissions.test.ts +25 -25
  207. package/src/rbac/hooks/usePermissions.ts +65 -25
  208. package/src/rbac/hooks/useRBAC.simple.test.ts +1 -8
  209. package/src/rbac/hooks/useRBAC.test.ts +3 -40
  210. package/src/rbac/hooks/useRBAC.ts +0 -55
  211. package/src/rbac/hooks/useResolvedScope.ts +23 -31
  212. package/src/rbac/permissions.test.ts +11 -7
  213. package/src/rbac/security.test.ts +2 -2
  214. package/src/rbac/security.ts +22 -7
  215. package/src/rbac/types.test.ts +2 -2
  216. package/src/rbac/types.ts +1 -2
  217. package/src/services/EventService.ts +42 -13
  218. package/src/services/__tests__/EventService.test.ts +25 -4
  219. package/src/services/interfaces/IEventService.ts +1 -0
  220. package/src/utils/file-reference.ts +9 -0
  221. package/dist/chunk-2W4WKJVF.js.map +0 -1
  222. package/dist/chunk-3J5N2T2N.js.map +0 -1
  223. package/dist/chunk-7GBEBJLR.js.map +0 -1
  224. package/dist/chunk-AUXS7XSO.js.map +0 -1
  225. package/dist/chunk-HADXAZT3.js.map +0 -1
  226. package/dist/chunk-Q7APDV6H.js.map +0 -1
  227. package/dist/chunk-XRSP3H52.js.map +0 -1
  228. /package/dist/{DataTable-D3BK2FCN.js.map → DataTable-3D3BUZDV.js.map} +0 -0
  229. /package/dist/{UnifiedAuthProvider-A7I23UCN.js.map → UnifiedAuthProvider-KZZUO27W.js.map} +0 -0
  230. /package/dist/{api-PIE4JRFS.js.map → api-QPMBZZUZ.js.map} +0 -0
  231. /package/dist/{audit-65VNHEV2.js.map → audit-H4YJJF7R.js.map} +0 -0
  232. /package/dist/{chunk-EYSXQ756.js.map → chunk-7H75SHXZ.js.map} +0 -0
  233. /package/dist/{chunk-D6MEKC27.js.map → chunk-BUN7NMV7.js.map} +0 -0
  234. /package/dist/{chunk-AWK2FAUN.js.map → chunk-C5RN4TE5.js.map} +0 -0
  235. /package/dist/{chunk-EZ64QG2I.js.map → chunk-L36JW4KV.js.map} +0 -0
  236. /package/dist/{chunk-YFMENCR4.js.map → chunk-NEONKMTU.js.map} +0 -0
@@ -163,19 +163,31 @@ export class EnhancedRBACAuditManager {
163
163
  }
164
164
 
165
165
  try {
166
+ // Validate pageId: only include in page_id column if it's a valid UUID
167
+ // Otherwise, store it in metadata to avoid database errors
168
+ const rawPageId = 'pageId' in event ? event.pageId : undefined;
169
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
170
+ const isValidPageIdUuid = rawPageId && uuidRegex.test(rawPageId);
171
+ const pageIdUuid: UUID | undefined = isValidPageIdUuid ? (rawPageId as UUID) : undefined;
172
+ const pageIdName: string | undefined = rawPageId && !isValidPageIdUuid ? rawPageId : undefined;
173
+
166
174
  const auditEvent: Omit<RBACAuditEvent, 'id' | 'created_at'> = {
167
175
  event_type: event.type,
168
176
  user_id: event.userId,
169
177
  organisation_id: event.organisationId,
170
178
  event_id: 'eventId' in event ? event.eventId : undefined,
171
179
  app_id: 'appId' in event ? event.appId : undefined,
172
- page_id: 'pageId' in event ? event.pageId : undefined,
180
+ page_id: pageIdUuid, // Only set if it's a valid UUID
173
181
  permission: 'permission' in event ? event.permission : undefined,
174
182
  decision: 'decision' in event ? event.decision : undefined,
175
183
  source: 'source' in event ? event.source : 'api', // Default to 'api' if not provided
176
184
  bypass: 'bypass' in event ? event.bypass : undefined,
177
185
  duration_ms: 'duration_ms' in event ? event.duration_ms : undefined,
178
- metadata: event.metadata || {},
186
+ metadata: {
187
+ ...(event.metadata || {}),
188
+ // Store page name/identifier in metadata if it's not a UUID
189
+ page_name: pageIdName,
190
+ },
179
191
  };
180
192
 
181
193
  const { error } = await (this.supabase as any)
@@ -68,13 +68,15 @@ describe('RBACAuditManager', () => {
68
68
 
69
69
  describe('Permission Check Events', () => {
70
70
  it('emits permission check events correctly', async () => {
71
+ // Use a valid UUID format for pageId
72
+ const validPageId = '01234567-89ab-cdef-0123-456789abcdef' as UUID;
71
73
  const event: PermissionCheckAuditEvent = {
72
74
  type: 'permission_check',
73
75
  userId: 'user-123' as UUID,
74
76
  organisationId: 'org-456' as UUID,
75
77
  eventId: 'event-789',
76
78
  appId: 'app-101' as UUID,
77
- pageId: 'page-202' as UUID,
79
+ pageId: validPageId,
78
80
  permission: 'read:users',
79
81
  decision: true,
80
82
  source: 'api' as AuditEventSource,
@@ -93,13 +95,16 @@ describe('RBACAuditManager', () => {
93
95
  organisation_id: 'org-456',
94
96
  event_id: 'event-789',
95
97
  app_id: 'app-101',
96
- page_id: 'page-202',
98
+ page_id: validPageId,
97
99
  permission: 'read:users',
98
100
  decision: true,
99
101
  source: 'api',
100
102
  bypass: false,
101
103
  duration_ms: 150,
102
- metadata: expect.objectContaining({ ip: '192.168.1.1' })
104
+ metadata: expect.objectContaining({
105
+ ip: '192.168.1.1',
106
+ no_organisation_context: false
107
+ })
103
108
  })
104
109
  ]
105
110
  );
@@ -136,14 +141,16 @@ describe('RBACAuditManager', () => {
136
141
 
137
142
  describe('Permission Denied Events', () => {
138
143
  it('emits permission denied events correctly', async () => {
144
+ // Use a valid UUID format for pageId
145
+ const validPageId = '01234567-89ab-cdef-0123-456789abcdef' as UUID;
139
146
  const event: PermissionDeniedAuditEvent = {
140
147
  type: 'permission_denied',
141
148
  userId: 'user-123' as UUID,
142
149
  organisationId: 'org-456' as UUID,
143
150
  eventId: 'event-789',
144
151
  appId: 'app-101' as UUID,
145
- pageId: 'page-202' as UUID,
146
- permission: 'manage:users',
152
+ pageId: validPageId,
153
+ permission: 'update:users',
147
154
  source: 'api' as AuditEventSource,
148
155
  metadata: { reason: 'Insufficient role' }
149
156
  };
@@ -158,10 +165,13 @@ describe('RBACAuditManager', () => {
158
165
  organisation_id: 'org-456',
159
166
  event_id: 'event-789',
160
167
  app_id: 'app-101',
161
- page_id: 'page-202',
162
- permission: 'manage:users',
168
+ page_id: validPageId,
169
+ permission: 'update:users',
163
170
  source: 'api',
164
- metadata: expect.objectContaining({ reason: 'Insufficient role' })
171
+ metadata: expect.objectContaining({
172
+ reason: 'Insufficient role',
173
+ no_organisation_context: false
174
+ })
165
175
  })
166
176
  ]
167
177
  );
package/src/rbac/audit.ts CHANGED
@@ -163,16 +163,33 @@ export class RBACAuditManager {
163
163
  }
164
164
 
165
165
  try {
166
- // For events without organisationId, store in a special way
166
+ // Since organisationId is now required in SecurityContext, this should rarely happen
167
+ // But we still handle the edge case properly without masking it
168
+ if (!event.organisationId) {
169
+ console.warn('[RBAC Audit] Audit event without organisation context - this should be investigated:', {
170
+ userId: event.userId,
171
+ eventType: event.type,
172
+ note: 'Organisation context is required for RBAC operations. This may indicate a security issue or missing context derivation.'
173
+ });
174
+ }
175
+
176
+ // Validate pageId: only include in page_id column if it's a valid UUID
177
+ // Otherwise, store it in metadata to avoid database errors
178
+ const rawPageId = 'pageId' in event ? event.pageId : undefined;
179
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
180
+ const isValidPageIdUuid = rawPageId && uuidRegex.test(rawPageId);
181
+ const pageIdUuid: UUID | undefined = isValidPageIdUuid ? (rawPageId as UUID) : undefined;
182
+ const pageIdName: string | undefined = rawPageId && !isValidPageIdUuid ? rawPageId : undefined;
183
+
167
184
  const auditEvent: Omit<RBACAuditEvent, 'id' | 'created_at'> = {
168
185
  event_type: event.type,
169
186
  user_id: event.userId,
170
- // CRITICAL: Store organisationId even if null for auditing
171
- // Use a fallback UUID if organisation context is missing (for database constraint)
172
- organisation_id: event.organisationId || '00000000-0000-0000-0000-000000000000' as UUID,
187
+ // Store organisationId - nullable to properly track missing context cases
188
+ // Do NOT use fallback UUID as it masks security issues
189
+ organisation_id: event.organisationId || null, // Explicitly null if missing
173
190
  event_id: 'eventId' in event ? event.eventId : undefined,
174
191
  app_id: 'appId' in event ? event.appId : undefined,
175
- page_id: 'pageId' in event ? event.pageId : undefined,
192
+ page_id: pageIdUuid, // Only set if it's a valid UUID
176
193
  permission: 'permission' in event ? event.permission : undefined,
177
194
  decision: 'decision' in event ? event.decision : undefined,
178
195
  source: 'source' in event ? event.source : 'api', // Default to 'api' if not provided
@@ -182,8 +199,10 @@ export class RBACAuditManager {
182
199
  ...event.metadata,
183
200
  cache_hit: 'cache_hit' in event ? event.cache_hit : undefined,
184
201
  cache_source: 'cache_source' in event ? event.cache_source : undefined,
185
- // Store a flag indicating this event had no organisation context
202
+ // Explicit flag indicating this event had no organisation context
186
203
  no_organisation_context: !event.organisationId,
204
+ // Store page name/identifier in metadata if it's not a UUID
205
+ page_name: pageIdName,
187
206
  },
188
207
  };
189
208
 
@@ -129,6 +129,18 @@ describe('RBACCache', () => {
129
129
  expect(cache.get('org-789-data')).not.toBeNull();
130
130
  });
131
131
 
132
+ it('supports wildcard patterns spanning cache segments', () => {
133
+ cache.set('perm:user-123:org-789:null:null:view:dashboard', true);
134
+ cache.set('perm:user-456:org-789:null:null:view:dashboard', true);
135
+ cache.set('perm:user-123:org-000:null:null:view:dashboard', true);
136
+
137
+ cache.invalidate('perm:*:org-789:*');
138
+
139
+ expect(cache.get('perm:user-123:org-789:null:null:view:dashboard')).toBeNull();
140
+ expect(cache.get('perm:user-456:org-789:null:null:view:dashboard')).toBeNull();
141
+ expect(cache.get('perm:user-123:org-000:null:null:view:dashboard')).toBe(true);
142
+ });
143
+
132
144
  it('handles empty pattern gracefully', () => {
133
145
  expect(() => cache.invalidate('')).not.toThrow();
134
146
  });
package/src/rbac/cache.ts CHANGED
@@ -72,18 +72,38 @@ export class RBACCache {
72
72
  * @param pattern - Pattern to match against cache keys
73
73
  */
74
74
  invalidate(pattern: string): void {
75
+ const trimmedPattern = pattern?.trim();
76
+
77
+ if (!trimmedPattern) {
78
+ return;
79
+ }
80
+
81
+ const matcher = this.createMatcher(trimmedPattern);
75
82
  const keysToDelete: string[] = [];
76
-
83
+
77
84
  for (const key of this.cache.keys()) {
78
- if (key.includes(pattern)) {
85
+ if (matcher(key)) {
79
86
  keysToDelete.push(key);
80
87
  }
81
88
  }
82
89
 
83
90
  keysToDelete.forEach(key => this.cache.delete(key));
84
-
91
+
85
92
  // Notify invalidation callbacks
86
- this.invalidationCallbacks.forEach(callback => callback(pattern));
93
+ this.invalidationCallbacks.forEach(callback => callback(trimmedPattern));
94
+ }
95
+
96
+ private createMatcher(pattern: string): (key: string) => boolean {
97
+ if (pattern.includes('*')) {
98
+ const escapedSegments = pattern
99
+ .split('*')
100
+ .map(segment => segment.replace(/[|\\{}()[\]^$+?.-]/g, '\\$&'));
101
+ const regexPattern = escapedSegments.join('.*');
102
+ const regex = new RegExp(regexPattern);
103
+ return (key: string) => regex.test(key);
104
+ }
105
+
106
+ return (key: string) => key.includes(pattern);
87
107
  }
88
108
 
89
109
  /**
@@ -239,9 +259,9 @@ export const rbacCache = new RBACCache();
239
259
  * Cache key patterns for invalidation
240
260
  */
241
261
  export const CACHE_PATTERNS = {
242
- USER: (userId: UUID) => `user:${userId}`,
243
- ORGANISATION: (organisationId: UUID) => `org:${organisationId}`,
244
- EVENT: (eventId: string) => `event:${eventId}`,
245
- APP: (appId: UUID) => `app:${appId}`,
246
- PERMISSION: (userId: UUID, organisationId: UUID) => `perm:${userId}:${organisationId}`,
262
+ USER: (userId: UUID) => `:${userId}:`,
263
+ ORGANISATION: (organisationId: UUID) => `:${organisationId}:`,
264
+ EVENT: (eventId: string) => `:${eventId}:`,
265
+ APP: (appId: UUID) => `:${appId}`,
266
+ PERMISSION: (userId: UUID, organisationId: UUID) => `perm:${userId}:${organisationId}:`,
247
267
  } as const;
@@ -74,7 +74,7 @@ const mockNavigationItems: NavigationItem[] = [
74
74
  id: 'admin',
75
75
  label: 'Admin',
76
76
  path: '/admin',
77
- permissions: ['manage:admin'] as Permission[],
77
+ permissions: ['update:admin'] as Permission[],
78
78
  pageId: 'page-admin',
79
79
  accessLevel: 'admin',
80
80
  meta: {
@@ -65,7 +65,7 @@
65
65
  */
66
66
 
67
67
  import React, { useMemo, useCallback, useEffect, useState } from 'react';
68
- import { useCan } from '../hooks';
68
+ import { useMultiplePermissions } from '../hooks/usePermissions';
69
69
  import { useUnifiedAuth } from '../../providers/UnifiedAuthProvider';
70
70
  import { UUID, Permission, Scope } from '../types';
71
71
  import { createScopeFromEvent } from '../utils/eventContext';
@@ -176,26 +176,26 @@ export function NavigationGuard({
176
176
  resolveScope();
177
177
  }, [scope, selectedOrganisation, selectedEvent, supabase]);
178
178
 
179
- // Check permissions using the first permission as a representative
180
- // For multiple permissions, we'll check them sequentially
181
- const representativePermission = navigationItem.permissions[0];
182
- const { can, isLoading, error } = useCan(
179
+ // Check all permissions using useMultiplePermissions hook
180
+ const { results: permissionResults, isLoading, error } = useMultiplePermissions(
183
181
  user?.id || '',
184
182
  resolvedScope || { eventId: selectedEvent?.event_id || undefined },
185
- representativePermission,
186
- navigationItem.pageId,
183
+ navigationItem.permissions || [],
187
184
  true // Use cache
188
185
  );
189
186
 
190
- // Determine if user has required permissions
187
+ // Determine if user has required permissions based on requireAll prop
191
188
  const hasRequiredPermissions = useMemo((): boolean => {
192
- if (navigationItem.permissions.length === 0) return true;
189
+ if (!navigationItem.permissions || navigationItem.permissions.length === 0) return true;
193
190
 
194
- // For now, use the representative permission result
195
- // In a future enhancement, we could check all permissions
196
- // but this would require multiple useCan hooks or a custom hook
197
- return can;
198
- }, [navigationItem.permissions, can]);
191
+ if (requireAll) {
192
+ // User must have ALL permissions
193
+ return Object.values(permissionResults).every(result => result === true);
194
+ } else {
195
+ // User must have ANY permission (default behavior)
196
+ return Object.values(permissionResults).some(result => result === true);
197
+ }
198
+ }, [navigationItem.permissions, permissionResults, requireAll]);
199
199
 
200
200
  // Handle permission check completion
201
201
  useEffect(() => {
@@ -57,7 +57,7 @@ const mockNavigationItems: NavigationItem[] = [
57
57
  id: 'admin',
58
58
  label: 'Admin',
59
59
  path: '/admin',
60
- permissions: ['manage:admin'] as Permission[],
60
+ permissions: ['update:admin'] as Permission[],
61
61
  pageId: 'page-admin',
62
62
  accessLevel: 'admin'
63
63
  }
@@ -378,7 +378,7 @@ const PagePermissionGuardComponent = ({
378
378
  }, [operation, pageName]);
379
379
 
380
380
  // Create a stable scope that only includes valid values
381
- // This ensures useCan doesn't run with empty organisationId which causes it to stay in loading state
381
+ // OrganisationId is required - use undefined if not available, useCan will handle loading state
382
382
  const stableScope = useMemo(() => {
383
383
  if (resolvedScope && resolvedScope.organisationId) {
384
384
  return {
@@ -387,8 +387,9 @@ const PagePermissionGuardComponent = ({
387
387
  eventId: resolvedScope.eventId || undefined
388
388
  };
389
389
  }
390
- // Return a scope with empty string organisationId - useCan will handle this by keeping loading state
391
- return { organisationId: '', appId: undefined, eventId: undefined };
390
+ // Return scope without organisationId - useCan will keep loading state until resolved
391
+ // Scope.organisationId is optional, so undefined is valid
392
+ return { organisationId: undefined, appId: undefined, eventId: undefined };
392
393
  }, [resolvedScope]);
393
394
 
394
395
  // Check if user has permission - only call useCan when we have a resolved scope with valid organisationId
@@ -38,7 +38,7 @@ const mockScope: Scope = {
38
38
  appId: 'app-789' as UUID
39
39
  };
40
40
 
41
- const mockPermissions = ['read:users', 'manage:users'] as const;
41
+ const mockPermissions = ['read:users', 'update:users'] as const;
42
42
  const mockPageId = 'page-123';
43
43
 
44
44
  // Test component
@@ -19,7 +19,7 @@
19
19
  * ```tsx
20
20
  * // Basic permission enforcement
21
21
  * <PermissionEnforcer
22
- * permissions={['read:events', 'manage:events']}
22
+ * permissions={['read:events', 'update:events']}
23
23
  * operation="event-management"
24
24
  * fallback={<AccessDeniedPage />}
25
25
  * >
@@ -67,7 +67,7 @@
67
67
  */
68
68
 
69
69
  import React, { useMemo, useCallback, useEffect, useState } from 'react';
70
- import { useCan } from '../hooks';
70
+ import { useMultiplePermissions } from '../hooks/usePermissions';
71
71
  import { useUnifiedAuth } from '../../providers/UnifiedAuthProvider';
72
72
  import { UUID, Permission, Scope } from '../types';
73
73
  import { createScopeFromEvent } from '../utils/eventContext';
@@ -129,7 +129,6 @@ export function PermissionEnforcer({
129
129
  const { user, selectedOrganisation, selectedEvent, supabase } = useUnifiedAuth();
130
130
  const [hasChecked, setHasChecked] = useState(false);
131
131
  const [checkError, setCheckError] = useState<Error | null>(null);
132
- const [permissionResults, setPermissionResults] = useState<Record<string, boolean>>({});
133
132
  const [resolvedScope, setResolvedScope] = useState<Scope | null>(null);
134
133
 
135
134
  // Resolve scope - either use provided scope or resolve from context
@@ -182,26 +181,31 @@ export function PermissionEnforcer({
182
181
  resolveScope();
183
182
  }, [scope, selectedOrganisation, selectedEvent, supabase]);
184
183
 
185
- // Check permissions using the first permission as a representative
186
- // For multiple permissions, we'll check them sequentially
187
- const representativePermission = permissions[0];
188
- const { can, isLoading, error } = useCan(
184
+ // Check all permissions using useMultiplePermissions hook
185
+ const { results: permissionResults, isLoading, error } = useMultiplePermissions(
189
186
  user?.id || '',
190
187
  resolvedScope || { eventId: selectedEvent?.event_id || undefined },
191
- representativePermission,
192
- undefined,
188
+ permissions,
193
189
  true // Use cache
194
190
  );
195
191
 
196
- // Determine if user has required permissions
192
+ // Determine if user has required permissions based on requireAll prop
197
193
  const hasRequiredPermissions = useMemo((): boolean => {
198
194
  if (permissions.length === 0) return true;
199
195
 
200
- // For now, use the representative permission result
201
- // In a future enhancement, we could check all permissions
202
- // but this would require multiple useCan hooks or a custom hook
203
- return can;
204
- }, [permissions, can]);
196
+ // If permissionResults is not yet available or empty, deny access
197
+ if (!permissionResults || Object.keys(permissionResults).length === 0) {
198
+ return false;
199
+ }
200
+
201
+ if (requireAll) {
202
+ // User must have ALL permissions
203
+ return Object.values(permissionResults).every(result => result === true);
204
+ } else {
205
+ // User must have ANY permission (default behavior)
206
+ return Object.values(permissionResults).some(result => result === true);
207
+ }
208
+ }, [permissions, permissionResults, requireAll]);
205
209
 
206
210
  // Handle permission check completion
207
211
  useEffect(() => {
@@ -79,6 +79,9 @@ export interface RouteConfig {
79
79
  /** Permissions required for this route */
80
80
  permissions: Permission[];
81
81
 
82
+ /** If true, this route is public and doesn't require permission checks */
83
+ public?: boolean;
84
+
82
85
  /** Roles that can access this route */
83
86
  roles?: string[];
84
87
 
@@ -232,10 +235,13 @@ export function RoleBasedRouter({
232
235
  currentRouteConfig?.pageId
233
236
  );
234
237
 
235
- // If route has no permissions, deny access (secure by default)
238
+ // Check if route is public
239
+ const isPublicRoute = currentRouteConfig?.public === true;
240
+
241
+ // If route has no permissions and is not public, deny access (secure by default)
236
242
  const hasPermissions = currentRouteConfig?.permissions && currentRouteConfig.permissions.length > 0;
237
- const finalCanAccess = hasPermissions ? canAccessCurrentRoute : false;
238
- const finalLoading = hasPermissions ? permissionLoading : false;
243
+ const finalCanAccess = isPublicRoute ? true : (hasPermissions ? canAccessCurrentRoute : false);
244
+ const finalLoading = isPublicRoute ? false : (hasPermissions ? permissionLoading : false);
239
245
 
240
246
  // Get all accessible routes for current user
241
247
  const getAccessibleRoutes = useCallback((): RouteConfig[] => {
@@ -323,13 +329,14 @@ export function RoleBasedRouter({
323
329
 
324
330
  // Use the actual permission check result
325
331
  const allowed = finalCanAccess;
332
+ // Log route access (including public routes for audit monitoring)
326
333
  recordRouteAccess(currentPath, allowed, currentRouteConfig);
327
334
 
328
- if (!allowed) {
329
- // Redirect to fallback route
335
+ if (!allowed && !isPublicRoute) {
336
+ // Redirect to fallback route (skip for public routes)
330
337
  navigate(fallbackRoute, { replace: true });
331
338
  }
332
- }, [location.pathname, currentRouteConfig, canAccessCurrentRoute, recordRouteAccess, strictMode, user?.id, currentScope, onStrictModeViolation, navigate, fallbackRoute]);
339
+ }, [location.pathname, currentRouteConfig, canAccessCurrentRoute, recordRouteAccess, strictMode, user?.id, currentScope, onStrictModeViolation, navigate, fallbackRoute, isPublicRoute]);
333
340
 
334
341
  // Context value
335
342
  const contextValue = useMemo((): RoleBasedRouterContextType => ({
@@ -350,8 +357,8 @@ export function RoleBasedRouter({
350
357
  auditLog
351
358
  ]);
352
359
 
353
- // Show loading state while checking permissions
354
- if (finalLoading) {
360
+ // Show loading state while checking permissions (skip for public routes)
361
+ if (finalLoading && !isPublicRoute) {
355
362
  return (
356
363
  <div className="flex items-center justify-center min-h-screen">
357
364
  <div className="text-center">
@@ -363,7 +370,7 @@ export function RoleBasedRouter({
363
370
  }
364
371
 
365
372
  // Show unauthorized component if user can't access current route
366
- if (currentRouteConfig && !finalCanAccess) {
373
+ if (currentRouteConfig && !finalCanAccess && !isPublicRoute) {
367
374
  return (
368
375
  <UnauthorizedComponent
369
376
  route={currentRoute}