@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
@@ -350,7 +350,7 @@ describe('useCan Hook', () => {
350
350
  mockIsPermitted.mockResolvedValue(true);
351
351
 
352
352
  const { result } = renderHook(() =>
353
- useCan(mockUserId, mockScope, 'manage:organisations', undefined, false)
353
+ useCan(mockUserId, mockScope, 'update:organisations', undefined, false)
354
354
  );
355
355
 
356
356
  await waitFor(() => {
@@ -362,7 +362,7 @@ describe('useCan Hook', () => {
362
362
  expect(mockIsPermitted).toHaveBeenCalledWith({
363
363
  userId: mockUserId,
364
364
  scope: mockScope,
365
- permission: 'manage:organisations',
365
+ permission: 'update:organisations',
366
366
  pageId: undefined
367
367
  });
368
368
  });
@@ -601,6 +601,33 @@ describe('useCan Hook', () => {
601
601
  }, { timeout: 20000 });
602
602
  });
603
603
 
604
+ it('times out after 3 seconds when organisationId is missing', async () => {
605
+ vi.useFakeTimers({ shouldAdvanceTime: true });
606
+
607
+ const emptyScope = {} as any;
608
+ const { result } = renderHook(() =>
609
+ useCan(mockUserId, emptyScope, mockPermission, undefined, false)
610
+ );
611
+
612
+ // Initially loading
613
+ expect(result.current.isLoading).toBe(true);
614
+ expect(result.current.can).toBe(false);
615
+ expect(result.current.error).toBeNull();
616
+
617
+ // Fast-forward 3 seconds - advance time and run pending timers
618
+ await vi.advanceTimersByTimeAsync(3000);
619
+ await vi.runOnlyPendingTimersAsync();
620
+
621
+ // Flush React updates - waitFor with real timers to handle React state updates
622
+ vi.useRealTimers();
623
+ await waitFor(() => {
624
+ expect(result.current.isLoading).toBe(false);
625
+ expect(result.current.can).toBe(false);
626
+ expect(result.current.error).not.toBeNull();
627
+ expect(result.current.error?.message).toBe('Organisation context is required for permission checks');
628
+ }, { timeout: 2000 });
629
+ }, { timeout: 10000 });
630
+
604
631
  it('handles null userId', async () => {
605
632
  const { result } = renderHook(() =>
606
633
  useCan(null as any, mockScope, mockPermission, undefined, false)
@@ -268,8 +268,8 @@ describe('usePermissions Hook', () => {
268
268
  'create:users': true,
269
269
  'update:users': true,
270
270
  'delete:users': true,
271
- 'manage:organisations': true,
272
- 'manage:events': true,
271
+ 'update:organisations': true,
272
+ 'update:events': true,
273
273
  'super_admin': true
274
274
  };
275
275
 
@@ -287,7 +287,7 @@ describe('usePermissions Hook', () => {
287
287
  expect(result.current.hasPermission('create:users')).toBe(true);
288
288
  expect(result.current.hasPermission('update:users')).toBe(true);
289
289
  expect(result.current.hasPermission('delete:users')).toBe(true);
290
- expect(result.current.hasPermission('manage:organisations')).toBe(true);
290
+ expect(result.current.hasPermission('update:organisations')).toBe(true);
291
291
  expect(result.current.hasPermission('super_admin')).toBe(true);
292
292
  });
293
293
 
@@ -297,8 +297,8 @@ describe('usePermissions Hook', () => {
297
297
  'create:users': true,
298
298
  'update:users': true,
299
299
  'delete:users': false, // Org admin can't delete users
300
- 'manage:organisations': true,
301
- 'manage:events': true,
300
+ 'update:organisations': true,
301
+ 'update:events': true,
302
302
  'org_admin': true
303
303
  };
304
304
 
@@ -316,7 +316,7 @@ describe('usePermissions Hook', () => {
316
316
  expect(result.current.hasPermission('create:users')).toBe(true);
317
317
  expect(result.current.hasPermission('update:users')).toBe(true);
318
318
  expect(result.current.hasPermission('delete:users')).toBe(false);
319
- expect(result.current.hasPermission('manage:organisations')).toBe(true);
319
+ expect(result.current.hasPermission('update:organisations')).toBe(true);
320
320
  expect(result.current.hasPermission('org_admin')).toBe(true);
321
321
  });
322
322
 
@@ -326,8 +326,8 @@ describe('usePermissions Hook', () => {
326
326
  'create:users': false,
327
327
  'update:users': false,
328
328
  'delete:users': false,
329
- 'manage:organisations': false,
330
- 'manage:events': true,
329
+ 'update:organisations': false,
330
+ 'update:events': true,
331
331
  'event_admin': true
332
332
  };
333
333
 
@@ -345,8 +345,8 @@ describe('usePermissions Hook', () => {
345
345
  expect(result.current.hasPermission('create:users')).toBe(false);
346
346
  expect(result.current.hasPermission('update:users')).toBe(false);
347
347
  expect(result.current.hasPermission('delete:users')).toBe(false);
348
- expect(result.current.hasPermission('manage:organisations')).toBe(false);
349
- expect(result.current.hasPermission('manage:events')).toBe(true);
348
+ expect(result.current.hasPermission('update:organisations')).toBe(false);
349
+ expect(result.current.hasPermission('update:events')).toBe(true);
350
350
  expect(result.current.hasPermission('event_admin')).toBe(true);
351
351
  });
352
352
 
@@ -356,8 +356,8 @@ describe('usePermissions Hook', () => {
356
356
  'create:users': false,
357
357
  'update:users': false,
358
358
  'delete:users': false,
359
- 'manage:organisations': false,
360
- 'manage:events': false,
359
+ 'update:organisations': false,
360
+ 'update:events': false,
361
361
  'member': true
362
362
  };
363
363
 
@@ -375,8 +375,8 @@ describe('usePermissions Hook', () => {
375
375
  expect(result.current.hasPermission('create:users')).toBe(false);
376
376
  expect(result.current.hasPermission('update:users')).toBe(false);
377
377
  expect(result.current.hasPermission('delete:users')).toBe(false);
378
- expect(result.current.hasPermission('manage:organisations')).toBe(false);
379
- expect(result.current.hasPermission('manage:events')).toBe(false);
378
+ expect(result.current.hasPermission('update:organisations')).toBe(false);
379
+ expect(result.current.hasPermission('update:events')).toBe(false);
380
380
  expect(result.current.hasPermission('member')).toBe(true);
381
381
  });
382
382
 
@@ -386,8 +386,8 @@ describe('usePermissions Hook', () => {
386
386
  'create:users': false,
387
387
  'update:users': false,
388
388
  'delete:users': false,
389
- 'manage:organisations': false,
390
- 'manage:events': false,
389
+ 'update:organisations': false,
390
+ 'update:events': false,
391
391
  'participant': true
392
392
  };
393
393
 
@@ -405,8 +405,8 @@ describe('usePermissions Hook', () => {
405
405
  expect(result.current.hasPermission('create:users')).toBe(false);
406
406
  expect(result.current.hasPermission('update:users')).toBe(false);
407
407
  expect(result.current.hasPermission('delete:users')).toBe(false);
408
- expect(result.current.hasPermission('manage:organisations')).toBe(false);
409
- expect(result.current.hasPermission('manage:events')).toBe(false);
408
+ expect(result.current.hasPermission('update:organisations')).toBe(false);
409
+ expect(result.current.hasPermission('update:events')).toBe(false);
410
410
  expect(result.current.hasPermission('participant')).toBe(true);
411
411
  });
412
412
 
@@ -416,8 +416,8 @@ describe('usePermissions Hook', () => {
416
416
  'create:users': false,
417
417
  'update:users': true,
418
418
  'delete:users': false,
419
- 'manage:organisations': false,
420
- 'manage:events': true
419
+ 'update:organisations': false,
420
+ 'update:events': true
421
421
  };
422
422
 
423
423
  mockGetPermissionMap.mockResolvedValue(mixedPermissions);
@@ -432,8 +432,8 @@ describe('usePermissions Hook', () => {
432
432
  // Test hasAnyPermission with mixed results
433
433
  expect(result.current.hasAnyPermission(['read:users', 'create:users'])).toBe(true); // One is true
434
434
  expect(result.current.hasAnyPermission(['create:users', 'delete:users'])).toBe(false); // Both are false
435
- expect(result.current.hasAnyPermission(['update:users', 'manage:events'])).toBe(true); // Both are true
436
- expect(result.current.hasAnyPermission(['read:users', 'update:users', 'manage:events'])).toBe(true); // All are true
435
+ expect(result.current.hasAnyPermission(['update:users', 'update:events'])).toBe(true); // Both are true
436
+ expect(result.current.hasAnyPermission(['read:users', 'update:users', 'update:events'])).toBe(true); // All are true
437
437
  });
438
438
 
439
439
  it('handles mixed permission scenarios with hasAllPermissions', async () => {
@@ -442,8 +442,8 @@ describe('usePermissions Hook', () => {
442
442
  'create:users': false,
443
443
  'update:users': true,
444
444
  'delete:users': false,
445
- 'manage:organisations': false,
446
- 'manage:events': true
445
+ 'update:organisations': false,
446
+ 'update:events': true
447
447
  };
448
448
 
449
449
  mockGetPermissionMap.mockResolvedValue(mixedPermissions);
@@ -459,7 +459,7 @@ describe('usePermissions Hook', () => {
459
459
  expect(result.current.hasAllPermissions(['read:users', 'update:users'])).toBe(true); // Both are true
460
460
  expect(result.current.hasAllPermissions(['read:users', 'create:users'])).toBe(false); // One is false
461
461
  expect(result.current.hasAllPermissions(['create:users', 'delete:users'])).toBe(false); // Both are false
462
- expect(result.current.hasAllPermissions(['read:users', 'update:users', 'manage:events'])).toBe(true); // All are true
462
+ expect(result.current.hasAllPermissions(['read:users', 'update:users', 'update:events'])).toBe(true); // All are true
463
463
  });
464
464
  });
465
465
 
@@ -52,6 +52,22 @@ export function usePermissions(userId: UUID, scope: Scope) {
52
52
  const [error, setError] = useState<Error | null>(null);
53
53
  const isFetchingRef = useRef(false);
54
54
 
55
+ // Add timeout for missing organisation context (3 seconds)
56
+ useEffect(() => {
57
+ if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
58
+ const timeoutId = setTimeout(() => {
59
+ setError(new Error('Organisation context is required for permission checks'));
60
+ setIsLoading(false);
61
+ }, 3000); // 3 seconds - typical permission check is < 1 second
62
+
63
+ return () => clearTimeout(timeoutId);
64
+ }
65
+ // Clear error if organisation context becomes available
66
+ if (error?.message === 'Organisation context is required for permission checks') {
67
+ setError(null);
68
+ }
69
+ }, [scope.organisationId, error]);
70
+
55
71
  useEffect(() => {
56
72
  const fetchPermissions = async () => {
57
73
  // Prevent multiple simultaneous fetches
@@ -65,9 +81,9 @@ export function usePermissions(userId: UUID, scope: Scope) {
65
81
  return;
66
82
  }
67
83
 
68
- // Don't fetch permissions if scope is invalid (e.g., organisationId is empty)
69
- // Return empty permissions and loading true for invalid scopes to prevent premature access denied
70
- if (!scope.organisationId || scope.organisationId.trim() === '') {
84
+ // Don't fetch permissions if scope is invalid (e.g., organisationId is null/empty)
85
+ // Wait for organisation context to resolve
86
+ if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
71
87
  setPermissions({} as PermissionMap);
72
88
  setIsLoading(true);
73
89
  setError(null);
@@ -125,8 +141,8 @@ export function usePermissions(userId: UUID, scope: Scope) {
125
141
  return;
126
142
  }
127
143
 
128
- // Don't fetch permissions if scope is invalid (e.g., organisationId is empty)
129
- if (!scope.organisationId || scope.organisationId.trim() === '') {
144
+ // Don't fetch permissions if scope is invalid (e.g., organisationId is null/empty)
145
+ if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
130
146
  setPermissions({} as PermissionMap);
131
147
  setIsLoading(true);
132
148
  setError(null);
@@ -193,6 +209,23 @@ export function useCan(
193
209
  const [isLoading, setIsLoading] = useState(true);
194
210
  const [error, setError] = useState<Error | null>(null);
195
211
 
212
+ // Add timeout for missing organisation context (3 seconds)
213
+ useEffect(() => {
214
+ if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
215
+ const timeoutId = setTimeout(() => {
216
+ setError(new Error('Organisation context is required for permission checks'));
217
+ setIsLoading(false);
218
+ setCan(false);
219
+ }, 3000); // 3 seconds - typical permission check is < 1 second
220
+
221
+ return () => clearTimeout(timeoutId);
222
+ }
223
+ // Clear error if organisation context becomes available
224
+ if (error?.message === 'Organisation context is required for permission checks') {
225
+ setError(null);
226
+ }
227
+ }, [scope.organisationId, error]);
228
+
196
229
  // Use refs to track the last values to prevent unnecessary re-runs
197
230
  const lastUserIdRef = useRef<UUID | null>(null);
198
231
  const lastScopeRef = useRef<string | null>(null);
@@ -226,12 +259,13 @@ export function useCan(
226
259
  return;
227
260
  }
228
261
 
229
- // Don't check permissions if scope is invalid (e.g., organisationId is empty)
230
- // Return can: false and loading: true for invalid scopes to prevent premature access denied
231
- if (!scope.organisationId || scope.organisationId.trim() === '') {
232
- console.log('[useCan] Invalid scope - organisationId is empty:', { scope, permission, pageId });
233
- setCan(false);
262
+ // Don't check permissions if scope is invalid (e.g., organisationId is null/empty)
263
+ // Wait for organisation context to resolve
264
+ if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
234
265
  setIsLoading(true);
266
+ setCan(false);
267
+ setError(null);
268
+ // Timeout is handled in separate useEffect (Phase 1.4)
235
269
  return;
236
270
  }
237
271
 
@@ -239,21 +273,10 @@ export function useCan(
239
273
  setIsLoading(true);
240
274
  setError(null);
241
275
 
242
- console.log('[useCan] Checking permission:', {
243
- userId,
244
- organisationId: scope.organisationId,
245
- eventId: scope.eventId,
246
- appId: scope.appId,
247
- permission,
248
- pageId
249
- });
250
-
251
276
  const result = useCache
252
277
  ? await isPermittedCached({ userId, scope, permission, pageId })
253
278
  : await isPermitted({ userId, scope, permission, pageId });
254
279
 
255
- console.log('[useCan] Permission check result:', { permission, result, pageId });
256
-
257
280
  setCan(result);
258
281
  } catch (err) {
259
282
  console.error('[useCan] Permission check error:', { permission, error: err });
@@ -275,10 +298,11 @@ export function useCan(
275
298
  return;
276
299
  }
277
300
 
278
- // Don't check permissions if scope is invalid (e.g., organisationId is empty)
279
- if (!scope.organisationId || scope.organisationId.trim() === '') {
301
+ // Don't check permissions if scope is invalid (e.g., organisationId is null/empty)
302
+ if (!scope.organisationId || scope.organisationId === null || (typeof scope.organisationId === 'string' && scope.organisationId.trim() === '')) {
280
303
  setCan(false);
281
304
  setIsLoading(true);
305
+ setError(null);
282
306
  return;
283
307
  }
284
308
 
@@ -57,7 +57,6 @@ vi.mock('./useRBAC', () => ({
57
57
  eventAppRole: 'participant',
58
58
  isSuperAdmin: false,
59
59
  isOrgAdmin: false,
60
- hasPermission: () => true,
61
60
  hasGlobalPermission: () => true,
62
61
  error: null,
63
62
  }),
@@ -80,18 +79,12 @@ describe('useRBAC Hook - Simple Tests', () => {
80
79
  expect(result.current).toHaveProperty('globalRole');
81
80
  expect(result.current).toHaveProperty('organisationRole');
82
81
  expect(result.current).toHaveProperty('eventAppRole');
83
- expect(result.current).toHaveProperty('hasPermission');
84
82
  expect(result.current).toHaveProperty('hasGlobalPermission');
85
83
  expect(result.current).toHaveProperty('isSuperAdmin');
86
84
  expect(result.current).toHaveProperty('isOrgAdmin');
87
85
  expect(result.current).toHaveProperty('isLoading');
88
86
  expect(result.current).toHaveProperty('error');
89
- });
90
-
91
- it('hasPermission is a function', () => {
92
- const { result } = renderHook(() => useRBAC());
93
-
94
- expect(typeof result.current.hasPermission).toBe('function');
87
+ // Note: hasPermission was removed - use useCan() hook instead
95
88
  });
96
89
 
97
90
  it('hasGlobalPermission is a function', () => {
@@ -97,7 +97,7 @@ describe('useRBAC', () => {
97
97
 
98
98
  await waitFor(() => {
99
99
  expect(result.current.isLoading).toBe(false);
100
- expect(result.current.hasPermission).toBeTypeOf('function');
100
+ expect(result.current.hasGlobalPermission).toBeTypeOf('function');
101
101
  });
102
102
 
103
103
  expect(mockResolveAppContext).toHaveBeenCalledWith({ userId: 'user-1', appName: 'test-app' });
@@ -109,45 +109,8 @@ describe('useRBAC', () => {
109
109
  );
110
110
  });
111
111
 
112
- it('uses cached permission map before hitting engine', async () => {
113
- mockUseUnifiedAuth.mockReturnValue({
114
- user: { id: 'user-1' },
115
- session: { access_token: 'token' },
116
- appName: 'test-app',
117
- });
118
- mockUseOrganisations.mockReturnValue({ selectedOrganisation: { id: 'org-1' } });
119
- mockGetPermissionMap.mockResolvedValue({ 'read:dashboard': true });
120
-
121
- const { result } = renderHook(() => useRBAC('dashboard'));
122
-
123
- await waitFor(() => expect(result.current.isLoading).toBe(false));
124
-
125
- mockIsPermittedCached.mockClear();
126
- const allowed = await result.current.hasPermission('read', 'dashboard');
127
-
128
- expect(allowed).toBe(true);
129
- expect(mockIsPermittedCached).not.toHaveBeenCalled();
130
- });
131
-
132
- it('falls back to engine when permission not cached', async () => {
133
- mockUseUnifiedAuth.mockReturnValue({
134
- user: { id: 'user-1' },
135
- session: { access_token: 'token' },
136
- appName: 'test-app',
137
- });
138
- mockUseOrganisations.mockReturnValue({ selectedOrganisation: { id: 'org-1' } });
139
- mockGetPermissionMap.mockResolvedValue({});
140
- mockIsPermittedCached.mockResolvedValue(true);
141
-
142
- const { result } = renderHook(() => useRBAC('dashboard'));
143
-
144
- await waitFor(() => expect(result.current.isLoading).toBe(false));
145
-
146
- const allowed = await result.current.hasPermission('read', 'dashboard');
147
-
148
- expect(allowed).toBe(true);
149
- expect(mockIsPermittedCached).toHaveBeenCalled();
150
- });
112
+ // Note: Permission checking tests have been moved to useCan.test.ts
113
+ // useRBAC now focuses on role information and context, not permission checks
151
114
 
152
115
  it('handles denied app resolution securely', async () => {
153
116
  mockUseUnifiedAuth.mockReturnValue({
@@ -17,7 +17,6 @@ import { useEvents } from '../../hooks/useEvents';
17
17
  import {
18
18
  getPermissionMap,
19
19
  getAccessLevel,
20
- isPermittedCached,
21
20
  resolveAppContext,
22
21
  getRoleContext,
23
22
  } from '../api';
@@ -27,7 +26,6 @@ import type {
27
26
  GlobalRole,
28
27
  OrganisationRole,
29
28
  EventAppRole,
30
- Operation,
31
29
  Permission,
32
30
  Scope,
33
31
  PermissionMap,
@@ -125,58 +123,6 @@ export function useRBAC(pageId?: string): UserRBACContext {
125
123
  }
126
124
  }, [appName, logger, resetState, selectedEvent?.event_id, selectedOrganisation?.id, session, user]);
127
125
 
128
- const hasPermission = useCallback(
129
- async (operationOrPermission: Operation | string, targetPageId?: string): Promise<boolean> => {
130
- if (!user) {
131
- logger.warn('[useRBAC] Permission check attempted without authenticated user context');
132
- return false;
133
- }
134
-
135
- if (!currentScope || !currentScope.organisationId) {
136
- logger.error('[useRBAC] Permission check denied due to missing organisation context', {
137
- hasScope: !!currentScope,
138
- scope: currentScope,
139
- userId: user.id,
140
- permission: operationOrPermission,
141
- });
142
- return false;
143
- }
144
-
145
- if (globalRole === 'super_admin' || permissionMap['*']) {
146
- logger.info('[useRBAC] Super admin bypass granted', {
147
- userId: user.id,
148
- permission: operationOrPermission,
149
- });
150
- return true;
151
- }
152
-
153
- let permission: Permission;
154
- if (operationOrPermission.includes(':')) {
155
- permission = operationOrPermission as Permission;
156
- } else if (targetPageId || pageId) {
157
- permission = `${operationOrPermission}:${targetPageId || pageId}` as Permission;
158
- } else {
159
- permission = operationOrPermission as Permission;
160
- }
161
-
162
- const cachedValue = permissionMap[permission];
163
- if (cachedValue === true) {
164
- return true;
165
- }
166
- if (cachedValue === false) {
167
- return false;
168
- }
169
-
170
- return isPermittedCached({
171
- userId: user.id as UUID,
172
- scope: currentScope,
173
- permission,
174
- pageId: targetPageId ?? pageId,
175
- });
176
- },
177
- [currentScope, globalRole, logger, pageId, permissionMap, user],
178
- );
179
-
180
126
  const hasGlobalPermission = useCallback(
181
127
  (permission: string): boolean => {
182
128
  if (globalRole === 'super_admin' || permissionMap['*']) {
@@ -211,7 +157,6 @@ export function useRBAC(pageId?: string): UserRBACContext {
211
157
  globalRole,
212
158
  organisationRole,
213
159
  eventAppRole,
214
- hasPermission,
215
160
  hasGlobalPermission,
216
161
  isSuperAdmin,
217
162
  isOrgAdmin,
@@ -74,28 +74,30 @@ export function useResolvedScope({
74
74
  eventId: undefined
75
75
  });
76
76
 
77
- // Only update the stable scope if the resolved scope has actually changed
78
- if (resolvedScope && resolvedScope.organisationId) {
79
- const newScope = {
80
- organisationId: resolvedScope.organisationId,
81
- appId: resolvedScope.appId,
82
- eventId: resolvedScope.eventId
83
- };
84
-
85
- // Only update if the scope has actually changed
86
- if (stableScopeRef.current.organisationId !== newScope.organisationId ||
87
- stableScopeRef.current.eventId !== newScope.eventId ||
88
- stableScopeRef.current.appId !== newScope.appId) {
89
- stableScopeRef.current = {
90
- organisationId: newScope.organisationId,
91
- appId: newScope.appId || '',
92
- eventId: newScope.eventId
77
+ // Update stable scope ref in useEffect to avoid updates during render
78
+ useEffect(() => {
79
+ if (resolvedScope && resolvedScope.organisationId) {
80
+ const newScope = {
81
+ organisationId: resolvedScope.organisationId,
82
+ appId: resolvedScope.appId,
83
+ eventId: resolvedScope.eventId
93
84
  };
85
+
86
+ // Only update if the scope has actually changed
87
+ if (stableScopeRef.current.organisationId !== newScope.organisationId ||
88
+ stableScopeRef.current.eventId !== newScope.eventId ||
89
+ stableScopeRef.current.appId !== newScope.appId) {
90
+ stableScopeRef.current = {
91
+ organisationId: newScope.organisationId,
92
+ appId: newScope.appId || '',
93
+ eventId: newScope.eventId
94
+ };
95
+ }
96
+ } else if (!resolvedScope) {
97
+ // Reset to empty scope when no resolved scope
98
+ stableScopeRef.current = { organisationId: '', appId: '', eventId: undefined };
94
99
  }
95
- } else if (!resolvedScope) {
96
- // Reset to empty scope when no resolved scope
97
- stableScopeRef.current = { organisationId: '', appId: '', eventId: undefined };
98
- }
100
+ }, [resolvedScope]);
99
101
 
100
102
  const stableScope = stableScopeRef.current;
101
103
 
@@ -144,17 +146,10 @@ export function useResolvedScope({
144
146
  }
145
147
  }
146
148
 
147
- // Debug logging
148
- console.log('[useResolvedScope] Attempting to resolve scope:', {
149
- selectedOrganisationId,
150
- selectedEventId,
151
- appId: appId || 'NOT RESOLVED YET',
152
- resolveStep: 'initial'
153
- });
149
+ // Resolve scope based on available context
154
150
 
155
151
  // If we have both organisation and event, use them directly
156
152
  if (selectedOrganisationId && selectedEventId) {
157
- console.log('[useResolvedScope] Resolving with both org and event');
158
153
  if (!cancelled) {
159
154
  setResolvedScope({
160
155
  organisationId: selectedOrganisationId,
@@ -168,7 +163,6 @@ export function useResolvedScope({
168
163
 
169
164
  // If we only have organisation, use it
170
165
  if (selectedOrganisationId) {
171
- console.log('[useResolvedScope] Resolving with organisation only');
172
166
  if (!cancelled) {
173
167
  setResolvedScope({
174
168
  organisationId: selectedOrganisationId,
@@ -183,7 +177,6 @@ export function useResolvedScope({
183
177
  // If we only have event, resolve organisation from event
184
178
  if (selectedEventId && supabase) {
185
179
  try {
186
- console.log('[useResolvedScope] Resolving from event:', { selectedEventId, appId });
187
180
  const eventScope = await createScopeFromEvent(supabase, selectedEventId, appId);
188
181
  if (!eventScope) {
189
182
  console.error('[useResolvedScope] Could not resolve organization from event context');
@@ -194,7 +187,6 @@ export function useResolvedScope({
194
187
  }
195
188
  return;
196
189
  }
197
- console.log('[useResolvedScope] Resolved from event:', eventScope);
198
190
  // Preserve the resolved app ID
199
191
  if (!cancelled) {
200
192
  setResolvedScope({
@@ -84,13 +84,17 @@ describe('RBAC Permissions', () => {
84
84
 
85
85
  it('rejects invalid Permission types', () => {
86
86
  const invalidPermissions = [
87
- 'manage:users',
88
- 'READ:users',
89
- 'read:',
90
- ':users',
91
- 'read:users*',
92
- 'read:*users',
93
- 'invalid'
87
+ 'READ:users', // Capital letters not allowed
88
+ 'read:', // Missing resource
89
+ ':users', // Missing operation
90
+ 'read:users*', // Invalid wildcard placement
91
+ 'read:*users', // Invalid wildcard placement
92
+ 'invalid', // Not in format operation:resource
93
+ 'read:users-', // Invalid character (hyphen) at end
94
+ 'read:users_', // Invalid character (underscore) at end
95
+ 'read:.users', // Cannot start with dot
96
+ 'read:users.', // Cannot end with dot
97
+ 'read:users..detail', // Cannot have consecutive dots
94
98
  ];
95
99
 
96
100
  invalidPermissions.forEach(permission => {
@@ -23,7 +23,7 @@ describe('RBACSecurityValidator', () => {
23
23
  'create:user-profiles',
24
24
  'update:events',
25
25
  'delete:organisations',
26
- 'manage:base.events',
26
+ 'update:base.events',
27
27
  'read:user-profiles.details'
28
28
  ];
29
29
 
@@ -352,7 +352,7 @@ describe('RBACSecurityMiddleware', () => {
352
352
  const input = {
353
353
  userId: '123e4567-e89b-12d3-a456-426614174000' as UUID,
354
354
  scope: { organisationId: '123e4567-e89b-12d3-a456-426614174001' as UUID },
355
- permission: 'manage:everything' as Permission
355
+ permission: 'update:everything' as Permission
356
356
  };
357
357
 
358
358
  const context = {