@jmruthers/pace-core 0.6.4 → 0.6.5

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 (101) hide show
  1. package/dist/{DataTable-E7YQZD7D.js → DataTable-AOVNCPTX.js} +8 -8
  2. package/dist/{PublicPageProvider-DEMpysFR.d.ts → PublicPageProvider-QTFVrL-Z.d.ts} +65 -83
  3. package/dist/{UnifiedAuthProvider-QPXO24B4.js → UnifiedAuthProvider-4SBX4LU5.js} +4 -4
  4. package/dist/{api-6LVZTHDS.js → api-O6HTBX5Y.js} +3 -3
  5. package/dist/{chunk-I6DAQMWX.js → chunk-6COVEUS7.js} +130 -106
  6. package/dist/chunk-6COVEUS7.js.map +1 -0
  7. package/dist/{chunk-36LVWXB2.js → chunk-AFVQODI2.js} +37 -1
  8. package/dist/{chunk-36LVWXB2.js.map → chunk-AFVQODI2.js.map} +1 -1
  9. package/dist/{chunk-3LPHPB62.js → chunk-EFN2EIMK.js} +2 -2
  10. package/dist/{chunk-ATKZM7RX.js → chunk-G7QEZTYQ.js} +31 -31
  11. package/dist/{chunk-ATKZM7RX.js.map → chunk-G7QEZTYQ.js.map} +1 -1
  12. package/dist/{chunk-NN6WWZ5U.js → chunk-HU2C6SSC.js} +29 -18
  13. package/dist/chunk-HU2C6SSC.js.map +1 -0
  14. package/dist/{chunk-AVMLPIM7.js → chunk-IHB5DR3H.js} +102 -51
  15. package/dist/chunk-IHB5DR3H.js.map +1 -0
  16. package/dist/{chunk-7JPAB3T5.js → chunk-IVOFDYWT.js} +364 -208
  17. package/dist/chunk-IVOFDYWT.js.map +1 -0
  18. package/dist/{chunk-6SOIHG6Z.js → chunk-JGRYX5UX.js} +120 -20
  19. package/dist/chunk-JGRYX5UX.js.map +1 -0
  20. package/dist/{chunk-OEWDTMG7.js → chunk-NTM7ZSB6.js} +4 -4
  21. package/dist/chunk-NTM7ZSB6.js.map +1 -0
  22. package/dist/{chunk-5EC5MEWX.js → chunk-RGAWHO7N.js} +4 -4
  23. package/dist/chunk-RGAWHO7N.js.map +1 -0
  24. package/dist/{chunk-YKRAFF5K.js → chunk-UPPMRMYG.js} +3 -3
  25. package/dist/{chunk-YKRAFF5K.js.map → chunk-UPPMRMYG.js.map} +1 -1
  26. package/dist/components.d.ts +2 -3
  27. package/dist/components.js +24 -28
  28. package/dist/components.js.map +1 -1
  29. package/dist/{contextValidator-OOPCLPZW.js → contextValidator-5OGXSPKS.js} +2 -2
  30. package/dist/hooks.d.ts +3 -3
  31. package/dist/hooks.js +41 -139
  32. package/dist/hooks.js.map +1 -1
  33. package/dist/index.d.ts +27 -18
  34. package/dist/index.js +41 -50
  35. package/dist/index.js.map +1 -1
  36. package/dist/providers.js +3 -3
  37. package/dist/rbac/index.d.ts +16 -9
  38. package/dist/rbac/index.js +6 -6
  39. package/dist/{usePublicRouteParams-i3qtoBgg.d.ts → usePublicRouteParams-ClnV4tnv.d.ts} +8 -8
  40. package/dist/utils.js +1 -1
  41. package/docs/api/modules.md +210 -100
  42. package/package.json +1 -2
  43. package/scripts/validate-master.js +1 -1
  44. package/src/components/DataTable/__tests__/keyboard.test.tsx +15 -2
  45. package/src/components/DataTable/components/ImportModal.tsx +4 -6
  46. package/src/components/DataTable/components/ViewRowModal.tsx +4 -4
  47. package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +455 -96
  48. package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +122 -58
  49. package/src/components/DataTable/core/DataTableContext.tsx +1 -1
  50. package/src/components/DateTimeField/DateTimeField.tsx +17 -19
  51. package/src/components/DateTimeField/README.md +5 -2
  52. package/src/components/Dialog/Dialog.test.tsx +248 -228
  53. package/src/components/Dialog/Dialog.tsx +455 -325
  54. package/src/components/Dialog/index.ts +3 -3
  55. package/src/components/FileDisplay/FileDisplay.test.tsx +41 -0
  56. package/src/components/FileDisplay/FileDisplay.tsx +5 -5
  57. package/src/components/Form/Form.test.tsx +3 -2
  58. package/src/components/Form/Form.tsx +4 -5
  59. package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +28 -28
  60. package/src/components/InactivityWarningModal/InactivityWarningModal.tsx +40 -54
  61. package/src/components/LoginForm/LoginForm.tsx +2 -2
  62. package/src/components/NavigationMenu/NavigationMenu.tsx +2 -2
  63. package/src/components/PaceAppLayout/PaceAppLayout.tsx +32 -39
  64. package/src/components/PaceAppLayout/README.md +10 -9
  65. package/src/components/PaceAppLayout/test-setup.tsx +40 -31
  66. package/src/components/PasswordChange/PasswordChangeForm.test.tsx +61 -0
  67. package/src/components/PasswordChange/PasswordChangeForm.tsx +20 -13
  68. package/src/components/PublicLayout/PublicLayout.test.tsx +7 -3
  69. package/src/components/PublicLayout/PublicPageLayout.tsx +5 -8
  70. package/src/components/UserMenu/UserMenu.test.tsx +38 -6
  71. package/src/components/UserMenu/UserMenu.tsx +36 -34
  72. package/src/components/index.ts +3 -4
  73. package/src/hooks/useEventTheme.ts +4 -4
  74. package/src/hooks/useEvents.ts +11 -7
  75. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  76. package/src/hooks/useOrganisationPermissions.ts +4 -4
  77. package/src/hooks/useOrganisations.ts +13 -7
  78. package/src/index.ts +11 -1
  79. package/src/rbac/README.md +20 -20
  80. package/src/rbac/hooks/useRBAC.test.ts +21 -3
  81. package/src/rbac/hooks/useRBAC.ts +4 -3
  82. package/src/rbac/hooks/useResourcePermissions.test.ts +125 -30
  83. package/src/rbac/hooks/useResourcePermissions.ts +57 -29
  84. package/src/rbac/permissions.ts +17 -17
  85. package/src/rbac/utils/contextValidator.ts +36 -0
  86. package/src/services/AuthService.ts +2 -5
  87. package/src/services/InactivityService.ts +139 -58
  88. package/src/styles/core.css +4 -0
  89. package/src/utils/formatting/formatTime.test.ts +3 -2
  90. package/dist/chunk-5EC5MEWX.js.map +0 -1
  91. package/dist/chunk-6SOIHG6Z.js.map +0 -1
  92. package/dist/chunk-7JPAB3T5.js.map +0 -1
  93. package/dist/chunk-AVMLPIM7.js.map +0 -1
  94. package/dist/chunk-I6DAQMWX.js.map +0 -1
  95. package/dist/chunk-NN6WWZ5U.js.map +0 -1
  96. package/dist/chunk-OEWDTMG7.js.map +0 -1
  97. /package/dist/{DataTable-E7YQZD7D.js.map → DataTable-AOVNCPTX.js.map} +0 -0
  98. /package/dist/{UnifiedAuthProvider-QPXO24B4.js.map → UnifiedAuthProvider-4SBX4LU5.js.map} +0 -0
  99. /package/dist/{api-6LVZTHDS.js.map → api-O6HTBX5Y.js.map} +0 -0
  100. /package/dist/{chunk-3LPHPB62.js.map → chunk-EFN2EIMK.js.map} +0 -0
  101. /package/dist/{contextValidator-OOPCLPZW.js.map → contextValidator-5OGXSPKS.js.map} +0 -0
@@ -229,18 +229,18 @@ import { PermissionEnforcer } from '@jmruthers/pace-core/rbac';
229
229
 
230
230
  function Dashboard() {
231
231
  return (
232
- <div>
232
+ <main>
233
233
  <h1>Dashboard</h1>
234
234
 
235
235
  {/* Automatic scope resolution - no manual context needed */}
236
236
  <PermissionEnforcer
237
237
  permissions={['read:admin']}
238
238
  operation="admin-panel"
239
- fallback={<div>Access Denied</div>}
239
+ fallback={<main>Access Denied</main>}
240
240
  >
241
241
  <AdminPanel />
242
242
  </PermissionEnforcer>
243
- </div>
243
+ </main>
244
244
  );
245
245
  }
246
246
  ```
@@ -266,14 +266,14 @@ function UserActions() {
266
266
  }
267
267
  );
268
268
 
269
- if (isLoading) return <div>Loading permissions...</div>;
270
- if (error) return <div>Error: {error.message}</div>;
269
+ if (isLoading) return <main>Loading permissions...</main>;
270
+ if (error) return <main>Error: {error.message}</main>;
271
271
 
272
272
  return (
273
- <div>
273
+ <section>
274
274
  {permissions['page-1']?.includes('read') && <ReadButton />}
275
275
  {permissions['page-1']?.includes('update') && <UpdateButton />}
276
- </div>
276
+ </section>
277
277
  );
278
278
  }
279
279
  ```
@@ -301,13 +301,13 @@ function UserActions() {
301
301
  'page-123' // optional pageId
302
302
  );
303
303
 
304
- if (isLoading) return <div>Checking permission...</div>;
305
- if (error) return <div>Error: {error.message}</div>;
304
+ if (isLoading) return <main>Checking permission...</main>;
305
+ if (error) return <main>Error: {error.message}</main>;
306
306
 
307
307
  return (
308
- <div>
308
+ <section>
309
309
  {can ? <AdminPanel /> : <AccessDenied />}
310
- </div>
310
+ </section>
311
311
  );
312
312
  }
313
313
  ```
@@ -321,7 +321,7 @@ import { PermissionEnforcer, PagePermissionGuard } from '@jmruthers/pace-core/rb
321
321
 
322
322
  function EventDashboard() {
323
323
  return (
324
- <div>
324
+ <main>
325
325
  <h1>Event Dashboard</h1>
326
326
 
327
327
  {/* Organization is automatically resolved from event context */}
@@ -338,7 +338,7 @@ function EventDashboard() {
338
338
  >
339
339
  <EventSettings />
340
340
  </PagePermissionGuard>
341
- </div>
341
+ </main>
342
342
  );
343
343
  }
344
344
  ```
@@ -374,7 +374,7 @@ function EventDashboard() {
374
374
  ```tsx
375
375
  const { selectedOrganisationId } = useUnifiedAuth();
376
376
  if (!selectedOrganisationId) {
377
- return <div>Please select an organisation first</div>;
377
+ return <main>Please select an organisation first</main>;
378
378
  }
379
379
  ```
380
380
 
@@ -479,14 +479,14 @@ function MyComponent() {
479
479
  { organisationId: 'org-456' }
480
480
  );
481
481
 
482
- if (isLoading) return <div>Loading...</div>;
483
- if (error) return <div>Error: {error.message}</div>;
482
+ if (isLoading) return <main>Loading...</main>;
483
+ if (error) return <main>Error: {error.message}</main>;
484
484
 
485
485
  return (
486
- <div>
486
+ <section>
487
487
  {can && <AdminPanel />}
488
488
  {accessLevel === 'admin' && <AdminControls />}
489
- </div>
489
+ </section>
490
490
  );
491
491
  }
492
492
  ```
@@ -498,7 +498,7 @@ import { PermissionEnforcer, PagePermissionGuard } from '@jmruthers/pace-core/rb
498
498
 
499
499
  function App() {
500
500
  return (
501
- <div>
501
+ <main>
502
502
  <PermissionEnforcer
503
503
  permissions={['update:events']}
504
504
  operation="event-management"
@@ -514,7 +514,7 @@ function App() {
514
514
  >
515
515
  <AdminPanel />
516
516
  </PagePermissionGuard>
517
- </div>
517
+ </main>
518
518
  );
519
519
  }
520
520
  ```
@@ -83,12 +83,15 @@ describe('useRBAC', () => {
83
83
  user: { id: 'user-1' },
84
84
  session: { access_token: 'token' },
85
85
  appName: 'test-app',
86
+ appId: undefined, // Ensure appId is undefined so resolveAppContext is called
87
+ contextAppId: undefined, // Also ensure contextAppId is undefined
86
88
  appConfig: { requires_event: false },
87
89
  selectedOrganisation: { id: 'org-1' },
88
90
  isContextReady: true,
89
91
  organisationLoading: false,
90
92
  selectedEvent: null,
91
93
  eventLoading: false,
94
+ supabase: {} as any,
92
95
  });
93
96
  mockUseOrganisations.mockReturnValue({ selectedOrganisation: { id: 'org-1' } });
94
97
 
@@ -98,15 +101,29 @@ describe('useRBAC', () => {
98
101
  organisationRole: null,
99
102
  eventAppRole: null,
100
103
  });
104
+ // Ensure resolveAppContext returns a value so the hook continues
105
+ mockResolveAppContext.mockResolvedValue({ appId: 'app-123' as any, hasAccess: true });
101
106
 
102
107
  const { result } = renderHook(() => useRBAC('dashboard'));
103
108
 
104
109
  await waitFor(() => {
105
110
  expect(result.current.isLoading).toBe(false);
106
111
  expect(result.current.hasGlobalPermission).toBeTypeOf('function');
112
+ }, { timeout: 5000 });
113
+
114
+ // resolveAppContext should be called when appName is set and appId/contextAppId are undefined
115
+ // The function signature is: resolveAppContext({ userId, appName })
116
+ // Note: The error message format may be confusing - check actual call structure
117
+ expect(mockResolveAppContext).toHaveBeenCalled();
118
+ // Verify it was called with correct structure (userId and appName in first argument)
119
+ const calls = mockResolveAppContext.mock.calls;
120
+ const hasCorrectCall = calls.some(call => {
121
+ const firstArg = call[0];
122
+ return firstArg && typeof firstArg === 'object' &&
123
+ 'userId' in firstArg && firstArg.userId === 'user-1' &&
124
+ 'appName' in firstArg && firstArg.appName === 'test-app';
107
125
  });
108
-
109
- expect(mockResolveAppContext).toHaveBeenCalledWith({ userId: 'user-1', appName: 'test-app' });
126
+ expect(hasCorrectCall).toBe(true);
110
127
  expect(mockGetPermissionMap).toHaveBeenCalledWith(
111
128
  {
112
129
  userId: 'user-1',
@@ -115,7 +132,8 @@ describe('useRBAC', () => {
115
132
  eventId: undefined,
116
133
  appId: 'app-123'
117
134
  }
118
- }
135
+ },
136
+ 'test-app' // appName is passed as second argument to getPermissionMap
119
137
  );
120
138
  });
121
139
 
@@ -196,10 +196,11 @@ export function useRBAC(pageId?: string): UserRBACContext {
196
196
  setCurrentScope(resolvedScope);
197
197
 
198
198
  // API calls no longer need appConfig (scope is page-level)
199
+ // Pass appName to allow PORTAL/ADMIN apps to work without organisation context
199
200
  const [map, roleContext, accessLevel] = await Promise.all([
200
- getPermissionMap({ userId: user.id as UUID, scope: resolvedScope }),
201
- getRoleContext({ userId: user.id as UUID, scope: resolvedScope }),
202
- getAccessLevel({ userId: user.id as UUID, scope: resolvedScope }),
201
+ getPermissionMap({ userId: user.id as UUID, scope: resolvedScope }, appName),
202
+ getRoleContext({ userId: user.id as UUID, scope: resolvedScope }, appName),
203
+ getAccessLevel({ userId: user.id as UUID, scope: resolvedScope }, appName),
203
204
  ]);
204
205
 
205
206
  setPermissionMap(map);
@@ -137,11 +137,12 @@ describe('useResourcePermissions Hook', () => {
137
137
  renderHook(() => useResourcePermissions('contacts'));
138
138
 
139
139
  expect(mockUseCan).toHaveBeenCalledTimes(4); // create, update, delete, read
140
- // When appId is available in scope, resource name is passed as pageId to enable page permission checks
140
+ // When appId is available in scope, permission strings include page. prefix
141
+ // and resource name is passed as pageId to enable page permission checks
141
142
  expect(mockUseCan).toHaveBeenCalledWith(
142
143
  'user-123',
143
144
  mockScope,
144
- 'create:contacts',
145
+ 'create:page.contacts',
145
146
  'contacts', // pageId is resource name when appId is available
146
147
  true,
147
148
  null, // precomputedSuperAdmin
@@ -150,7 +151,7 @@ describe('useResourcePermissions Hook', () => {
150
151
  expect(mockUseCan).toHaveBeenCalledWith(
151
152
  'user-123',
152
153
  mockScope,
153
- 'update:contacts',
154
+ 'update:page.contacts',
154
155
  'contacts', // pageId is resource name when appId is available
155
156
  true,
156
157
  null, // precomputedSuperAdmin
@@ -159,7 +160,7 @@ describe('useResourcePermissions Hook', () => {
159
160
  expect(mockUseCan).toHaveBeenCalledWith(
160
161
  'user-123',
161
162
  mockScope,
162
- 'delete:contacts',
163
+ 'delete:page.contacts',
163
164
  'contacts', // pageId is resource name when appId is available
164
165
  true,
165
166
  null, // precomputedSuperAdmin
@@ -168,7 +169,7 @@ describe('useResourcePermissions Hook', () => {
168
169
  expect(mockUseCan).toHaveBeenCalledWith(
169
170
  'user-123',
170
171
  mockScope,
171
- 'read:contacts',
172
+ 'read:page.contacts',
172
173
  'contacts', // pageId is resource name when appId is available
173
174
  true,
174
175
  null, // precomputedSuperAdmin
@@ -193,7 +194,7 @@ describe('useResourcePermissions Hook', () => {
193
194
 
194
195
  it('returns false when user cannot create resource', () => {
195
196
  mockUseCan.mockImplementation((userId, scope, permission) => {
196
- if (permission === 'create:contacts') {
197
+ if (permission === 'create:page.contacts') {
197
198
  return {
198
199
  can: false,
199
200
  isLoading: false,
@@ -234,7 +235,7 @@ describe('useResourcePermissions Hook', () => {
234
235
 
235
236
  it('checks read permissions when enableRead is true', () => {
236
237
  mockUseCan.mockImplementation((userId, scope, permission) => {
237
- if (permission === 'read:contacts') {
238
+ if (permission === 'read:page.contacts') {
238
239
  return {
239
240
  can: true,
240
241
  isLoading: false,
@@ -259,7 +260,7 @@ describe('useResourcePermissions Hook', () => {
259
260
 
260
261
  it('returns false for read when permission is denied and enableRead is true', () => {
261
262
  mockUseCan.mockImplementation((userId, scope, permission) => {
262
- if (permission === 'read:contacts') {
263
+ if (permission === 'read:page.contacts') {
263
264
  return {
264
265
  can: false,
265
266
  isLoading: false,
@@ -407,11 +408,11 @@ describe('useResourcePermissions Hook', () => {
407
408
  const { result } = renderHook(() => useResourcePermissions('contacts'));
408
409
 
409
410
  // When user is null, userId is empty string, but scope and permissions are still checked
410
- // Since mockScope has appId, pageId will be the resource name ('contacts')
411
+ // Since mockScope has appId, permission strings include page. prefix and pageId will be the resource name ('contacts')
411
412
  expect(mockUseCan).toHaveBeenCalledWith(
412
413
  '',
413
414
  mockScope,
414
- 'create:contacts',
415
+ 'create:page.contacts',
415
416
  'contacts', // pageId is resource name when appId is available in scope
416
417
  true,
417
418
  null, // precomputedSuperAdmin
@@ -420,7 +421,7 @@ describe('useResourcePermissions Hook', () => {
420
421
  expect(mockUseCan).toHaveBeenCalledWith(
421
422
  '',
422
423
  mockScope,
423
- 'update:contacts',
424
+ 'update:page.contacts',
424
425
  'contacts',
425
426
  true,
426
427
  null, // precomputedSuperAdmin
@@ -429,7 +430,7 @@ describe('useResourcePermissions Hook', () => {
429
430
  expect(mockUseCan).toHaveBeenCalledWith(
430
431
  '',
431
432
  mockScope,
432
- 'delete:contacts',
433
+ 'delete:page.contacts',
433
434
  'contacts',
434
435
  true,
435
436
  null, // precomputedSuperAdmin
@@ -438,7 +439,7 @@ describe('useResourcePermissions Hook', () => {
438
439
  expect(mockUseCan).toHaveBeenCalledWith(
439
440
  '',
440
441
  mockScope,
441
- 'read:contacts',
442
+ 'read:page.contacts',
442
443
  'contacts',
443
444
  true,
444
445
  null, // precomputedSuperAdmin
@@ -481,7 +482,7 @@ describe('useResourcePermissions Hook', () => {
481
482
 
482
483
  it('aggregates loading states from permission checks', () => {
483
484
  mockUseCan.mockImplementation((userId, scope, permission) => {
484
- if (permission === 'create:contacts') {
485
+ if (permission === 'create:page.contacts') {
485
486
  return {
486
487
  can: false,
487
488
  isLoading: true,
@@ -504,7 +505,7 @@ describe('useResourcePermissions Hook', () => {
504
505
 
505
506
  it('includes read loading state when enableRead is true', () => {
506
507
  mockUseCan.mockImplementation((userId, scope, permission) => {
507
- if (permission === 'read:contacts') {
508
+ if (permission === 'read:page.contacts') {
508
509
  return {
509
510
  can: false,
510
511
  isLoading: true,
@@ -529,7 +530,7 @@ describe('useResourcePermissions Hook', () => {
529
530
 
530
531
  it('excludes read loading state when enableRead is false', () => {
531
532
  mockUseCan.mockImplementation((userId, scope, permission) => {
532
- if (permission === 'read:contacts') {
533
+ if (permission === 'read:page.contacts') {
533
534
  return {
534
535
  can: false,
535
536
  isLoading: true,
@@ -568,7 +569,7 @@ describe('useResourcePermissions Hook', () => {
568
569
  it('aggregates errors from permission checks', () => {
569
570
  const permissionError = new Error('Permission check failed');
570
571
  mockUseCan.mockImplementation((userId, scope, permission) => {
571
- if (permission === 'create:contacts') {
572
+ if (permission === 'create:page.contacts') {
572
573
  return {
573
574
  can: false,
574
575
  isLoading: false,
@@ -600,7 +601,7 @@ describe('useResourcePermissions Hook', () => {
600
601
  });
601
602
 
602
603
  mockUseCan.mockImplementation((userId, scope, permission) => {
603
- if (permission === 'create:contacts') {
604
+ if (permission === 'create:page.contacts') {
604
605
  return {
605
606
  can: false,
606
607
  isLoading: false,
@@ -632,11 +633,11 @@ describe('useResourcePermissions Hook', () => {
632
633
  it('respects enableRead option', () => {
633
634
  renderHook(() => useResourcePermissions('contacts', { enableRead: true }));
634
635
 
635
- // Should still call useCan for read permission
636
+ // Should still call useCan for read permission with page. prefix when appId is available
636
637
  expect(mockUseCan).toHaveBeenCalledWith(
637
638
  expect.any(String),
638
639
  expect.any(Object),
639
- 'read:contacts',
640
+ 'read:page.contacts',
640
641
  'contacts', // pageId is resource name when appId is available
641
642
  true,
642
643
  null, // precomputedSuperAdmin
@@ -659,7 +660,7 @@ describe('useResourcePermissions Hook', () => {
659
660
  expect(mockUseCan).toHaveBeenCalledWith(
660
661
  expect.any(String),
661
662
  expect.any(Object),
662
- 'create:risks',
663
+ 'create:page.risks',
663
664
  'risks', // pageId is resource name when appId is available
664
665
  true,
665
666
  null, // precomputedSuperAdmin
@@ -668,7 +669,7 @@ describe('useResourcePermissions Hook', () => {
668
669
  expect(mockUseCan).toHaveBeenCalledWith(
669
670
  expect.any(String),
670
671
  expect.any(Object),
671
- 'update:risks',
672
+ 'update:page.risks',
672
673
  'risks', // pageId is resource name when appId is available
673
674
  true,
674
675
  null, // precomputedSuperAdmin
@@ -677,7 +678,7 @@ describe('useResourcePermissions Hook', () => {
677
678
  expect(mockUseCan).toHaveBeenCalledWith(
678
679
  expect.any(String),
679
680
  expect.any(Object),
680
- 'delete:risks',
681
+ 'delete:page.risks',
681
682
  'risks', // pageId is resource name when appId is available
682
683
  true,
683
684
  null, // precomputedSuperAdmin
@@ -687,7 +688,7 @@ describe('useResourcePermissions Hook', () => {
687
688
  });
688
689
 
689
690
  describe('Page Permission Support', () => {
690
- it('passes resource name as pageId when appId is available in scope', () => {
691
+ it('constructs permission strings with page. prefix when appId is available', () => {
691
692
  const scopeWithAppId: Scope = {
692
693
  organisationId: 'org-123',
693
694
  eventId: 'event-123',
@@ -702,20 +703,38 @@ describe('useResourcePermissions Hook', () => {
702
703
 
703
704
  renderHook(() => useResourcePermissions('planning'));
704
705
 
705
- // When appId is available, resource name should be passed as pageId
706
- // This enables the RPC function to check page permissions
706
+ // When appId is available, permission strings should include page. prefix
707
+ // and resource name should be passed as pageId to enable page permission checks
707
708
  expect(mockUseCan).toHaveBeenCalledWith(
708
709
  'user-123',
709
710
  scopeWithAppId,
710
- 'create:planning',
711
+ 'create:page.planning',
711
712
  'planning', // Resource name passed as pageId to enable page permission checks
712
713
  true,
713
714
  null, // precomputedSuperAdmin
714
715
  undefined // appName
715
716
  );
717
+ expect(mockUseCan).toHaveBeenCalledWith(
718
+ 'user-123',
719
+ scopeWithAppId,
720
+ 'update:page.planning',
721
+ 'planning',
722
+ true,
723
+ null,
724
+ undefined
725
+ );
726
+ expect(mockUseCan).toHaveBeenCalledWith(
727
+ 'user-123',
728
+ scopeWithAppId,
729
+ 'delete:page.planning',
730
+ 'planning',
731
+ true,
732
+ null,
733
+ undefined
734
+ );
716
735
  });
717
736
 
718
- it('does not pass pageId when appId is not available', () => {
737
+ it('does not add page. prefix when appId is not available', () => {
719
738
  const scopeWithoutAppId: Scope = {
720
739
  organisationId: 'org-123',
721
740
  eventId: 'event-123',
@@ -730,8 +749,8 @@ describe('useResourcePermissions Hook', () => {
730
749
 
731
750
  renderHook(() => useResourcePermissions('planning'));
732
751
 
733
- // When appId is not available, pageId should be undefined
734
- // This falls back to resource-based permission checking
752
+ // When appId is not available, permission strings should NOT include page. prefix
753
+ // and pageId should be undefined - falls back to resource-based permission checking
735
754
  expect(mockUseCan).toHaveBeenCalledWith(
736
755
  'user-123',
737
756
  scopeWithoutAppId,
@@ -741,6 +760,82 @@ describe('useResourcePermissions Hook', () => {
741
760
  null, // precomputedSuperAdmin
742
761
  undefined // appName
743
762
  );
763
+ expect(mockUseCan).toHaveBeenCalledWith(
764
+ 'user-123',
765
+ scopeWithoutAppId,
766
+ 'update:planning',
767
+ undefined,
768
+ true,
769
+ null,
770
+ undefined
771
+ );
772
+ expect(mockUseCan).toHaveBeenCalledWith(
773
+ 'user-123',
774
+ scopeWithoutAppId,
775
+ 'delete:planning',
776
+ undefined,
777
+ true,
778
+ null,
779
+ undefined
780
+ );
781
+ });
782
+
783
+ it('waits for scope resolution before using page permissions (timing fix)', () => {
784
+ // Simulate scope resolution in progress (resolvedScope is null, isLoading is true)
785
+ mockUseResolvedScope.mockReturnValue({
786
+ resolvedScope: null,
787
+ isLoading: true,
788
+ error: null,
789
+ });
790
+
791
+ renderHook(() => useResourcePermissions('planning'));
792
+
793
+ // During scope loading, should use resource-based permissions (no page. prefix)
794
+ // because resolvedScope is null, so hasAppId is false
795
+ const fallbackScope: Scope = {
796
+ organisationId: 'org-123',
797
+ eventId: 'event-123',
798
+ appId: undefined,
799
+ };
800
+
801
+ expect(mockUseCan).toHaveBeenCalledWith(
802
+ 'user-123',
803
+ fallbackScope,
804
+ 'create:planning', // Resource-based permission (no page. prefix) during loading
805
+ undefined, // No pageId when appId is not available
806
+ true,
807
+ null,
808
+ undefined
809
+ );
810
+
811
+ // Now simulate scope resolution completing with appId
812
+ const scopeWithAppId: Scope = {
813
+ organisationId: 'org-123',
814
+ eventId: 'event-123',
815
+ appId: 'app-123',
816
+ };
817
+
818
+ mockUseResolvedScope.mockReturnValue({
819
+ resolvedScope: scopeWithAppId,
820
+ isLoading: false,
821
+ error: null,
822
+ });
823
+
824
+ // Re-render to trigger update
825
+ const { rerender } = renderHook(() => useResourcePermissions('planning'));
826
+ rerender();
827
+
828
+ // After scope resolution, should use page permissions (with page. prefix)
829
+ // because resolvedScope.appId is now available, so hasAppId is true
830
+ expect(mockUseCan).toHaveBeenCalledWith(
831
+ 'user-123',
832
+ scopeWithAppId,
833
+ 'create:page.planning', // Page-based permission (with page. prefix) after resolution
834
+ 'planning', // pageId is resource name when appId is available
835
+ true,
836
+ null,
837
+ undefined
838
+ );
744
839
  });
745
840
  });
746
841
  });