@jmruthers/pace-core 0.5.109 → 0.5.111

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 (240) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/{AuthService-DrHrvXNZ.d.ts → AuthService-CVgsgtaZ.d.ts} +8 -0
  3. package/dist/{DataTable-5HITILXS.js → DataTable-5W2HVLLV.js} +8 -8
  4. package/dist/{UnifiedAuthProvider-A7I23UCN.js → UnifiedAuthProvider-LUM3QLS5.js} +3 -3
  5. package/dist/{api-5I3E47G2.js → api-SIZPFBFX.js} +5 -3
  6. package/dist/{audit-65VNHEV2.js → audit-5JI5T3SL.js} +2 -2
  7. package/dist/{chunk-P72NKAT5.js → chunk-2BIDKXQU.js} +157 -120
  8. package/dist/chunk-2BIDKXQU.js.map +1 -0
  9. package/dist/{chunk-S4D3Z723.js → chunk-ACYQNYHB.js} +7 -7
  10. package/dist/{chunk-D6MEKC27.js → chunk-EFVQBYFN.js} +2 -2
  11. package/dist/{chunk-EZ64QG2I.js → chunk-I5YM5BGS.js} +2 -2
  12. package/dist/{chunk-Q7APDV6H.js → chunk-IWJYNWXN.js} +13 -5
  13. package/dist/chunk-IWJYNWXN.js.map +1 -0
  14. package/dist/{chunk-YFMENCR4.js → chunk-JE2GFA3O.js} +3 -3
  15. package/dist/{chunk-AUXS7XSO.js → chunk-MW73E7SP.js} +35 -11
  16. package/dist/chunk-MW73E7SP.js.map +1 -0
  17. package/dist/{chunk-F6TSYCKP.js → chunk-PXXS26G5.js} +68 -29
  18. package/dist/chunk-PXXS26G5.js.map +1 -0
  19. package/dist/{chunk-UW2DE6JX.js → chunk-TD4BXGPE.js} +4 -4
  20. package/dist/{chunk-EYSXQ756.js → chunk-TDFBX7KJ.js} +2 -2
  21. package/dist/{chunk-WWNOVFDC.js → chunk-UGVU7L7N.js} +52 -90
  22. package/dist/chunk-UGVU7L7N.js.map +1 -0
  23. package/dist/{chunk-2W4WKJVF.js → chunk-X7SPKHYZ.js} +290 -255
  24. package/dist/chunk-X7SPKHYZ.js.map +1 -0
  25. package/dist/{chunk-3TKTL5AZ.js → chunk-ZL45MG76.js} +60 -60
  26. package/dist/chunk-ZL45MG76.js.map +1 -0
  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 +46 -29
  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 +9 -8
  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 +19 -8
  104. package/docs/api/interfaces/RBACLogger.md +5 -5
  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 +44 -43
  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/documentation-index.md +0 -2
  146. package/docs/migration/rbac-migration.md +65 -66
  147. package/docs/rbac/README.md +114 -38
  148. package/docs/rbac/advanced-patterns.md +15 -22
  149. package/docs/rbac/api-reference.md +63 -16
  150. package/docs/rbac/examples.md +12 -12
  151. package/docs/rbac/getting-started.md +19 -19
  152. package/docs/rbac/quick-start.md +110 -35
  153. package/docs/rbac/troubleshooting.md +127 -3
  154. package/package.json +1 -1
  155. package/src/components/DataTable/components/__tests__/ActionButtons.test.tsx +913 -0
  156. package/src/components/DataTable/components/__tests__/ColumnFilter.test.tsx +609 -0
  157. package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +434 -0
  158. package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +120 -0
  159. package/src/components/DataTable/components/__tests__/PaginationControls.test.tsx +519 -0
  160. package/src/components/DataTable/examples/__tests__/HierarchicalActionsExample.test.tsx +316 -0
  161. package/src/components/DataTable/examples/__tests__/InitialPageSizeExample.test.tsx +211 -0
  162. package/src/components/FileUpload/FileUpload.tsx +2 -8
  163. package/src/components/NavigationMenu/NavigationMenu.test.tsx +38 -4
  164. package/src/components/NavigationMenu/NavigationMenu.tsx +71 -6
  165. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +193 -63
  166. package/src/components/PaceAppLayout/PaceAppLayout.tsx +102 -135
  167. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +41 -2
  168. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +61 -6
  169. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +71 -21
  170. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +113 -41
  171. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +155 -45
  172. package/src/components/PaceLoginPage/PaceLoginPage.tsx +30 -1
  173. package/src/hooks/__tests__/usePermissionCache.simple.test.ts +63 -5
  174. package/src/hooks/__tests__/usePermissionCache.unit.test.ts +156 -72
  175. package/src/hooks/__tests__/useRBAC.unit.test.ts +4 -38
  176. package/src/hooks/index.ts +1 -1
  177. package/src/hooks/useFileDisplay.ts +51 -0
  178. package/src/hooks/usePermissionCache.test.ts +112 -68
  179. package/src/hooks/usePermissionCache.ts +55 -15
  180. package/src/rbac/README.md +81 -39
  181. package/src/rbac/__tests__/adapters.comprehensive.test.tsx +3 -3
  182. package/src/rbac/__tests__/engine.comprehensive.test.ts +15 -6
  183. package/src/rbac/__tests__/rbac-core.test.tsx +1 -1
  184. package/src/rbac/__tests__/rbac-engine-core-logic.test.ts +57 -4
  185. package/src/rbac/__tests__/rbac-engine-simplified.test.ts +3 -2
  186. package/src/rbac/adapters.tsx +4 -4
  187. package/src/rbac/api.test.ts +39 -15
  188. package/src/rbac/api.ts +27 -9
  189. package/src/rbac/audit.test.ts +2 -2
  190. package/src/rbac/audit.ts +14 -5
  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 +22 -38
  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 +2 -2
  202. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +121 -103
  203. package/src/rbac/config.ts +2 -0
  204. package/src/rbac/docs/event-based-apps.md +6 -6
  205. package/src/rbac/engine.ts +27 -7
  206. package/src/rbac/hooks/useCan.test.ts +29 -2
  207. package/src/rbac/hooks/usePermissions.test.ts +25 -25
  208. package/src/rbac/hooks/usePermissions.ts +47 -23
  209. package/src/rbac/hooks/useRBAC.simple.test.ts +1 -8
  210. package/src/rbac/hooks/useRBAC.test.ts +3 -40
  211. package/src/rbac/hooks/useRBAC.ts +0 -55
  212. package/src/rbac/hooks/useResolvedScope.ts +23 -31
  213. package/src/rbac/permissions.test.ts +11 -7
  214. package/src/rbac/security.test.ts +2 -2
  215. package/src/rbac/security.ts +23 -8
  216. package/src/rbac/types.test.ts +2 -2
  217. package/src/rbac/types.ts +1 -2
  218. package/src/services/EventService.ts +41 -13
  219. package/src/services/__tests__/EventService.test.ts +25 -4
  220. package/src/services/interfaces/IEventService.ts +1 -0
  221. package/src/utils/file-reference.ts +9 -0
  222. package/dist/chunk-2W4WKJVF.js.map +0 -1
  223. package/dist/chunk-3TKTL5AZ.js.map +0 -1
  224. package/dist/chunk-AUXS7XSO.js.map +0 -1
  225. package/dist/chunk-F6TSYCKP.js.map +0 -1
  226. package/dist/chunk-P72NKAT5.js.map +0 -1
  227. package/dist/chunk-Q7APDV6H.js.map +0 -1
  228. package/dist/chunk-WWNOVFDC.js.map +0 -1
  229. package/docs/rbac/breaking-changes-v3.md +0 -222
  230. package/docs/rbac/migration-guide.md +0 -260
  231. /package/dist/{DataTable-5HITILXS.js.map → DataTable-5W2HVLLV.js.map} +0 -0
  232. /package/dist/{UnifiedAuthProvider-A7I23UCN.js.map → UnifiedAuthProvider-LUM3QLS5.js.map} +0 -0
  233. /package/dist/{api-5I3E47G2.js.map → api-SIZPFBFX.js.map} +0 -0
  234. /package/dist/{audit-65VNHEV2.js.map → audit-5JI5T3SL.js.map} +0 -0
  235. /package/dist/{chunk-S4D3Z723.js.map → chunk-ACYQNYHB.js.map} +0 -0
  236. /package/dist/{chunk-D6MEKC27.js.map → chunk-EFVQBYFN.js.map} +0 -0
  237. /package/dist/{chunk-EZ64QG2I.js.map → chunk-I5YM5BGS.js.map} +0 -0
  238. /package/dist/{chunk-YFMENCR4.js.map → chunk-JE2GFA3O.js.map} +0 -0
  239. /package/dist/{chunk-UW2DE6JX.js.map → chunk-TD4BXGPE.js.map} +0 -0
  240. /package/dist/{chunk-EYSXQ756.js.map → chunk-TDFBX7KJ.js.map} +0 -0
package/src/rbac/api.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  PermissionMap,
18
18
  PermissionCheck,
19
19
  RBACNotInitializedError,
20
+ OrganisationContextRequiredError,
20
21
  RBACAppContext,
21
22
  RBACRoleContext,
22
23
  } from './types';
@@ -49,7 +50,8 @@ export function setupRBAC(supabase: SupabaseClient<Database>, config?: Partial<R
49
50
 
50
51
  createRBACConfig(fullConfig);
51
52
 
52
- globalEngine = createRBACEngine(supabase);
53
+ // Pass security config to engine if provided
54
+ globalEngine = createRBACEngine(supabase, config?.security);
53
55
 
54
56
  // Setup audit manager
55
57
  const auditManager = createAuditManager(supabase);
@@ -146,7 +148,7 @@ export async function getRoleContext(input: {
146
148
  * const canManage = await isPermitted({
147
149
  * userId: 'user-123',
148
150
  * scope: { organisationId: 'org-456' },
149
- * permission: 'manage:events',
151
+ * permission: 'update:events',
150
152
  * pageId: 'page-789'
151
153
  * });
152
154
  * ```
@@ -154,11 +156,16 @@ export async function getRoleContext(input: {
154
156
  export async function isPermitted(input: PermissionCheck): Promise<boolean> {
155
157
  const engine = getEngine();
156
158
 
159
+ // Validate organisation context is required
160
+ if (!input.scope.organisationId) {
161
+ throw new OrganisationContextRequiredError();
162
+ }
163
+
157
164
  // Create security context from input
158
- // SecurityContext requires organisationId as UUID, so we use a valid fallback
165
+ // OrganisationId is required - it can always be derived from event context in event-based apps
159
166
  const securityContext: SecurityContext = {
160
167
  userId: input.userId,
161
- organisationId: input.scope.organisationId || input.userId, // Fallback to userId as UUID
168
+ organisationId: input.scope.organisationId, // Required - no fallback
162
169
  timestamp: new Date(),
163
170
  // Optional fields can be omitted
164
171
  };
@@ -348,11 +355,19 @@ export async function isEventAdmin(userId: UUID, scope: Scope): Promise<boolean>
348
355
  * @param organisationId - Organisation ID (optional)
349
356
  */
350
357
  export function invalidateUserCache(userId: UUID, organisationId?: UUID): void {
351
- if (organisationId) {
352
- rbacCache.invalidate(CACHE_PATTERNS.PERMISSION(userId, organisationId));
353
- } else {
354
- rbacCache.invalidate(CACHE_PATTERNS.USER(userId));
355
- }
358
+ const patterns = organisationId
359
+ ? [
360
+ CACHE_PATTERNS.PERMISSION(userId, organisationId),
361
+ `access:${userId}:${organisationId}:`,
362
+ `map:${userId}:${organisationId}:`,
363
+ ]
364
+ : [
365
+ `perm:${userId}:`,
366
+ `access:${userId}:`,
367
+ `map:${userId}:`,
368
+ ];
369
+
370
+ patterns.forEach(pattern => rbacCache.invalidate(pattern));
356
371
  }
357
372
 
358
373
  /**
@@ -388,3 +403,6 @@ export function invalidateAppCache(appId: UUID): void {
388
403
  export function clearCache(): void {
389
404
  rbacCache.clear();
390
405
  }
406
+
407
+ // Re-export OrganisationContextRequiredError for convenience
408
+ export { OrganisationContextRequiredError } from './types';
@@ -143,7 +143,7 @@ describe('RBACAuditManager', () => {
143
143
  eventId: 'event-789',
144
144
  appId: 'app-101' as UUID,
145
145
  pageId: 'page-202' as UUID,
146
- permission: 'manage:users',
146
+ permission: 'update:users',
147
147
  source: 'api' as AuditEventSource,
148
148
  metadata: { reason: 'Insufficient role' }
149
149
  };
@@ -159,7 +159,7 @@ describe('RBACAuditManager', () => {
159
159
  event_id: 'event-789',
160
160
  app_id: 'app-101',
161
161
  page_id: 'page-202',
162
- permission: 'manage:users',
162
+ permission: 'update:users',
163
163
  source: 'api',
164
164
  metadata: expect.objectContaining({ reason: 'Insufficient role' })
165
165
  })
package/src/rbac/audit.ts CHANGED
@@ -163,13 +163,22 @@ 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
+
167
176
  const auditEvent: Omit<RBACAuditEvent, 'id' | 'created_at'> = {
168
177
  event_type: event.type,
169
178
  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,
179
+ // Store organisationId - nullable to properly track missing context cases
180
+ // Do NOT use fallback UUID as it masks security issues
181
+ organisation_id: event.organisationId || null, // Explicitly null if missing
173
182
  event_id: 'eventId' in event ? event.eventId : undefined,
174
183
  app_id: 'appId' in event ? event.appId : undefined,
175
184
  page_id: 'pageId' in event ? event.pageId : undefined,
@@ -182,7 +191,7 @@ export class RBACAuditManager {
182
191
  ...event.metadata,
183
192
  cache_hit: 'cache_hit' in event ? event.cache_hit : undefined,
184
193
  cache_source: 'cache_source' in event ? event.cache_source : undefined,
185
- // Store a flag indicating this event had no organisation context
194
+ // Explicit flag indicating this event had no organisation context
186
195
  no_organisation_context: !event.organisationId,
187
196
  },
188
197
  };
@@ -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
  }
@@ -148,41 +148,6 @@ const PagePermissionGuardComponent = ({
148
148
  const supabaseRef = useRef(supabase);
149
149
  supabaseRef.current = supabase;
150
150
 
151
- // Track the last scope we called useCan with to prevent infinite loops
152
- const lastScopeRef = useRef<string | null>(null);
153
-
154
- // Use a ref to store the stable scope and only update it when it actually changes
155
- const stableScopeRef = useRef<{ organisationId: string; appId: string; eventId: string | undefined }>({
156
- organisationId: '',
157
- appId: '',
158
- eventId: undefined
159
- });
160
-
161
- // Only update the stable scope if the resolved scope has actually changed
162
- if (resolvedScope && resolvedScope.organisationId) {
163
- const newScope = {
164
- organisationId: resolvedScope.organisationId,
165
- appId: resolvedScope.appId,
166
- eventId: resolvedScope.eventId
167
- };
168
-
169
- // Only update if the scope has actually changed
170
- if (stableScopeRef.current.organisationId !== newScope.organisationId ||
171
- stableScopeRef.current.eventId !== newScope.eventId ||
172
- stableScopeRef.current.appId !== newScope.appId) {
173
- stableScopeRef.current = {
174
- organisationId: newScope.organisationId,
175
- appId: newScope.appId || '',
176
- eventId: newScope.eventId
177
- };
178
- }
179
- } else if (!resolvedScope) {
180
- // Reset to empty scope when no resolved scope
181
- stableScopeRef.current = { organisationId: '', appId: '', eventId: undefined };
182
- }
183
-
184
- const stableScope = stableScopeRef.current;
185
-
186
151
  // Resolve scope - either use provided scope or resolve from context
187
152
  useEffect(() => {
188
153
  const abortController = new AbortController();
@@ -412,9 +377,23 @@ const PagePermissionGuardComponent = ({
412
377
  return `${operation}:page.${pageName}` as Permission;
413
378
  }, [operation, pageName]);
414
379
 
380
+ // Create a stable scope that only includes valid values
381
+ // OrganisationId is required - use undefined if not available, useCan will handle loading state
382
+ const stableScope = useMemo(() => {
383
+ if (resolvedScope && resolvedScope.organisationId) {
384
+ return {
385
+ organisationId: resolvedScope.organisationId,
386
+ appId: resolvedScope.appId || undefined,
387
+ eventId: resolvedScope.eventId || undefined
388
+ };
389
+ }
390
+ // Return 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 };
393
+ }, [resolvedScope]);
415
394
 
416
- // Check if user has permission - only call useCan when we have a resolved scope
417
- // If resolvedScope is null, we're still resolving, so show loading state
395
+ // Check if user has permission - only call useCan when we have a resolved scope with valid organisationId
396
+ // If resolvedScope is null or has no organisationId, useCan will keep isLoading=true
418
397
  const { can, isLoading: canIsLoading, error: canError } = useCan(
419
398
  user?.id || '',
420
399
  stableScope,
@@ -464,12 +443,17 @@ const PagePermissionGuardComponent = ({
464
443
  console.error(`[PagePermissionGuard] STRICT MODE VIOLATION: User attempted to access protected page without permission`, {
465
444
  pageName,
466
445
  operation,
446
+ permission: `${operation}:page.${pageName}`,
447
+ pageId: effectivePageId,
467
448
  userId: user?.id,
468
449
  scope: resolvedScope,
450
+ scopeValid: resolvedScope && resolvedScope.organisationId ? true : false,
451
+ checkError,
452
+ canError,
469
453
  timestamp: new Date().toISOString()
470
454
  });
471
455
  }
472
- }, [strictMode, hasChecked, isLoading, can, pageName, operation, user?.id, resolvedScope]);
456
+ }, [strictMode, hasChecked, isLoading, can, pageName, operation, effectivePageId, user?.id, resolvedScope, checkError, canError]);
473
457
 
474
458
  // Calculate the actual render state - FIXED: Proper state calculation
475
459
  // Add defensive checks to ensure we have valid state
@@ -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}