@jmruthers/pace-core 0.5.18 → 0.5.20

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 (134) hide show
  1. package/dist/{DataTable-BTXP6MW4.js → DataTable-IX7N7XYG.js} +4 -4
  2. package/dist/{chunk-7QEQCZDV.js → chunk-37WAIATW.js} +4 -4
  3. package/dist/{chunk-7UEIZCST.js → chunk-4TMM2IGR.js} +5 -4
  4. package/dist/chunk-4TMM2IGR.js.map +1 -0
  5. package/dist/{chunk-DKDTXS5Q.js → chunk-JE7K6Q3N.js} +10 -9
  6. package/dist/chunk-JE7K6Q3N.js.map +1 -0
  7. package/dist/{chunk-NU3GLMIQ.js → chunk-R4QSIQDW.js} +2 -2
  8. package/dist/{chunk-CMXBNDPM.js → chunk-UYKKHRJN.js} +2 -2
  9. package/dist/{chunk-UIBYODOF.js → chunk-WIVRKQQR.js} +51 -56
  10. package/dist/chunk-WIVRKQQR.js.map +1 -0
  11. package/dist/{chunk-J3JWBQSC.js → chunk-YA77BOZM.js} +58 -22
  12. package/dist/chunk-YA77BOZM.js.map +1 -0
  13. package/dist/{chunk-DV2Z3RBQ.js → chunk-ZIYFAQJ5.js} +2 -2
  14. package/dist/components.js +6 -6
  15. package/dist/hooks.js +2 -2
  16. package/dist/index.js +8 -8
  17. package/dist/providers.js +2 -2
  18. package/dist/rbac/index.d.ts +1 -11
  19. package/dist/rbac/index.js +3 -3
  20. package/dist/utils.js +1 -1
  21. package/docs/api/classes/ErrorBoundary.md +1 -1
  22. package/docs/api/classes/InvalidScopeError.md +1 -1
  23. package/docs/api/classes/MissingUserContextError.md +1 -1
  24. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  25. package/docs/api/classes/PermissionDeniedError.md +1 -1
  26. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  27. package/docs/api/classes/RBACAuditManager.md +1 -1
  28. package/docs/api/classes/RBACCache.md +1 -1
  29. package/docs/api/classes/RBACEngine.md +1 -1
  30. package/docs/api/classes/RBACError.md +1 -1
  31. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  32. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  33. package/docs/api/interfaces/AggregateConfig.md +1 -1
  34. package/docs/api/interfaces/ButtonProps.md +1 -1
  35. package/docs/api/interfaces/CardProps.md +1 -1
  36. package/docs/api/interfaces/ColorPalette.md +1 -1
  37. package/docs/api/interfaces/ColorShade.md +1 -1
  38. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  39. package/docs/api/interfaces/DataTableAction.md +1 -1
  40. package/docs/api/interfaces/DataTableColumn.md +1 -1
  41. package/docs/api/interfaces/DataTableProps.md +1 -1
  42. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  43. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  44. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  45. package/docs/api/interfaces/EventContextType.md +7 -7
  46. package/docs/api/interfaces/EventLogoProps.md +1 -1
  47. package/docs/api/interfaces/EventProviderProps.md +2 -2
  48. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  49. package/docs/api/interfaces/FileUploadProps.md +1 -1
  50. package/docs/api/interfaces/FooterProps.md +1 -1
  51. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  52. package/docs/api/interfaces/InputProps.md +1 -1
  53. package/docs/api/interfaces/LabelProps.md +1 -1
  54. package/docs/api/interfaces/LoginFormProps.md +1 -1
  55. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  56. package/docs/api/interfaces/NavigationContextType.md +1 -1
  57. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  58. package/docs/api/interfaces/NavigationItem.md +1 -1
  59. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  60. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  61. package/docs/api/interfaces/Organisation.md +1 -1
  62. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  63. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  64. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  65. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  66. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  67. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  68. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  69. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  70. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  71. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  72. package/docs/api/interfaces/PaletteData.md +1 -1
  73. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  74. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  75. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  76. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  77. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  78. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  79. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  80. package/docs/api/interfaces/RBACConfig.md +1 -1
  81. package/docs/api/interfaces/RBACContextType.md +1 -1
  82. package/docs/api/interfaces/RBACLogger.md +1 -1
  83. package/docs/api/interfaces/RBACProviderProps.md +1 -1
  84. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  85. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  86. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  87. package/docs/api/interfaces/RouteConfig.md +1 -1
  88. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  89. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  90. package/docs/api/interfaces/StorageConfig.md +1 -1
  91. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  92. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  93. package/docs/api/interfaces/StorageListOptions.md +1 -1
  94. package/docs/api/interfaces/StorageListResult.md +1 -1
  95. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  96. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  97. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  98. package/docs/api/interfaces/StyleImport.md +1 -1
  99. package/docs/api/interfaces/ToastActionElement.md +1 -1
  100. package/docs/api/interfaces/ToastProps.md +1 -1
  101. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  102. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  103. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  104. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  105. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  106. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  107. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  108. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  109. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  110. package/docs/api/interfaces/UserEventAccess.md +1 -1
  111. package/docs/api/interfaces/UserMenuProps.md +1 -1
  112. package/docs/api/interfaces/UserProfile.md +1 -1
  113. package/docs/api/modules.md +12 -12
  114. package/docs/troubleshooting/cake-infinite-rerender-debugging.md +284 -0
  115. package/docs/troubleshooting/cake-infinite-rerender-summary.md +117 -0
  116. package/docs/troubleshooting/cake-rerender-diagnostic.js +162 -0
  117. package/docs/troubleshooting/rbac-critical-fixes-summary.md +260 -0
  118. package/package.json +1 -1
  119. package/src/__tests__/hooks/usePermissions.test.ts +265 -0
  120. package/src/__tests__/rbac/PagePermissionGuard.test.tsx +187 -0
  121. package/src/hooks/useAppConfig.ts +7 -6
  122. package/src/providers/EventProvider.tsx +4 -2
  123. package/src/rbac/components/PagePermissionGuard.tsx +68 -69
  124. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +3 -3
  125. package/src/rbac/hooks/usePermissions.ts +85 -21
  126. package/dist/chunk-7UEIZCST.js.map +0 -1
  127. package/dist/chunk-DKDTXS5Q.js.map +0 -1
  128. package/dist/chunk-J3JWBQSC.js.map +0 -1
  129. package/dist/chunk-UIBYODOF.js.map +0 -1
  130. /package/dist/{DataTable-BTXP6MW4.js.map → DataTable-IX7N7XYG.js.map} +0 -0
  131. /package/dist/{chunk-7QEQCZDV.js.map → chunk-37WAIATW.js.map} +0 -0
  132. /package/dist/{chunk-NU3GLMIQ.js.map → chunk-R4QSIQDW.js.map} +0 -0
  133. /package/dist/{chunk-CMXBNDPM.js.map → chunk-UYKKHRJN.js.map} +0 -0
  134. /package/dist/{chunk-DV2Z3RBQ.js.map → chunk-ZIYFAQJ5.js.map} +0 -0
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @file PagePermissionGuard Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module RBAC/Components/PagePermissionGuard
5
+ * @since 2.0.0
6
+ *
7
+ * Comprehensive tests for PagePermissionGuard component to ensure:
8
+ * - UI state management works correctly
9
+ * - Permission checks are properly handled
10
+ * - No infinite re-renders occur
11
+ * - Scope resolution is robust
12
+ */
13
+
14
+ import React from 'react';
15
+ import { render, screen, waitFor, act } from '@testing-library/react';
16
+ import { vi } from 'vitest';
17
+ import { PagePermissionGuard } from '../../rbac/components/PagePermissionGuard';
18
+ import { useUnifiedAuth } from '../../providers/UnifiedAuthProvider';
19
+ import { useCan } from '../../rbac/hooks/usePermissions';
20
+
21
+ // Mock the hooks
22
+ vi.mock('../../providers/UnifiedAuthProvider');
23
+ vi.mock('../../rbac/hooks/usePermissions');
24
+ vi.mock('../../utils/appNameResolver');
25
+
26
+ const mockUseUnifiedAuth = vi.mocked(useUnifiedAuth);
27
+ const mockUseCan = vi.mocked(useCan);
28
+
29
+ // Mock app name resolver
30
+ vi.mock('../../utils/appNameResolver', () => ({
31
+ getCurrentAppName: () => 'test-app'
32
+ }));
33
+
34
+ describe('PagePermissionGuard', () => {
35
+ const mockUser = {
36
+ id: 'user-123',
37
+ email: 'test@example.com'
38
+ };
39
+
40
+ const mockScope = {
41
+ organisationId: 'org-123',
42
+ eventId: 'event-123',
43
+ appId: 'app-123'
44
+ };
45
+
46
+ beforeEach(() => {
47
+ // Reset all mocks
48
+ vi.clearAllMocks();
49
+
50
+ // Default mock implementations
51
+ mockUseUnifiedAuth.mockReturnValue({
52
+ user: mockUser,
53
+ selectedOrganisationId: 'org-123',
54
+ selectedEventId: 'event-123',
55
+ supabase: {} as any,
56
+ session: {} as any,
57
+ appName: 'test-app',
58
+ isAuthenticated: true,
59
+ signOut: vi.fn(),
60
+ // Add other required properties
61
+ } as any);
62
+
63
+ mockUseCan.mockReturnValue({
64
+ can: false,
65
+ isLoading: false,
66
+ error: null,
67
+ refetch: vi.fn()
68
+ });
69
+ });
70
+
71
+ describe('UI State Management', () => {
72
+ it('should render without crashing', () => {
73
+ render(
74
+ <PagePermissionGuard pageName="test" operation="read">
75
+ <div>Protected Content</div>
76
+ </PagePermissionGuard>
77
+ );
78
+
79
+ // Should render something (either loading, content, or fallback)
80
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
81
+ });
82
+
83
+ it('should show protected content when permission is granted', async () => {
84
+ mockUseCan.mockReturnValue({
85
+ can: true,
86
+ isLoading: false,
87
+ error: null,
88
+ refetch: vi.fn()
89
+ });
90
+
91
+ render(
92
+ <PagePermissionGuard pageName="test" operation="read">
93
+ <div>Protected Content</div>
94
+ </PagePermissionGuard>
95
+ );
96
+
97
+ await waitFor(() => {
98
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('Permission State Transitions', () => {
104
+ it('should handle permission state changes', async () => {
105
+ mockUseCan.mockReturnValue({
106
+ can: true,
107
+ isLoading: false,
108
+ error: null,
109
+ refetch: vi.fn()
110
+ });
111
+
112
+ render(
113
+ <PagePermissionGuard pageName="test" operation="read">
114
+ <div>Protected Content</div>
115
+ </PagePermissionGuard>
116
+ );
117
+
118
+ await waitFor(() => {
119
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
120
+ });
121
+ });
122
+ });
123
+
124
+ describe('Error Handling', () => {
125
+ it('should handle errors gracefully', async () => {
126
+ mockUseCan.mockReturnValue({
127
+ can: false,
128
+ isLoading: false,
129
+ error: new Error('Permission check failed'),
130
+ refetch: vi.fn()
131
+ });
132
+
133
+ render(
134
+ <PagePermissionGuard pageName="test" operation="read">
135
+ <div>Protected Content</div>
136
+ </PagePermissionGuard>
137
+ );
138
+
139
+ // Should render something even with errors
140
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
141
+ });
142
+ });
143
+
144
+ describe('Hook Stability', () => {
145
+ it('should render without infinite re-renders', () => {
146
+ let renderCount = 0;
147
+
148
+ const TestComponent = () => {
149
+ renderCount++;
150
+ return (
151
+ <PagePermissionGuard pageName="test" operation="read">
152
+ <div>Protected Content</div>
153
+ </PagePermissionGuard>
154
+ );
155
+ };
156
+
157
+ render(<TestComponent />);
158
+
159
+ // Should not have excessive re-renders
160
+ expect(renderCount).toBeLessThan(10);
161
+ });
162
+ });
163
+
164
+ describe('Scope Resolution', () => {
165
+ it('should work with organisation context', async () => {
166
+ mockUseUnifiedAuth.mockReturnValue({
167
+ user: mockUser,
168
+ selectedOrganisationId: 'org-123',
169
+ selectedEventId: null,
170
+ supabase: {} as any,
171
+ session: {} as any,
172
+ appName: 'test-app',
173
+ isAuthenticated: true,
174
+ signOut: vi.fn(),
175
+ } as any);
176
+
177
+ render(
178
+ <PagePermissionGuard pageName="test" operation="read">
179
+ <div>Protected Content</div>
180
+ </PagePermissionGuard>
181
+ );
182
+
183
+ // Should render something
184
+ expect(screen.getByText('Protected Content')).toBeInTheDocument();
185
+ });
186
+ });
187
+ });
@@ -28,6 +28,7 @@
28
28
  * ```
29
29
  */
30
30
 
31
+ import { useMemo } from 'react';
31
32
  import { useUnifiedAuth } from '../providers/UnifiedAuthProvider';
32
33
  import { useIsPublicPage } from '../components/PublicLayout/PublicPageProvider';
33
34
 
@@ -65,30 +66,30 @@ export function useAppConfig(): UseAppConfigReturn {
65
66
  return 'PACE';
66
67
  };
67
68
 
68
- return {
69
+ return useMemo(() => ({
69
70
  supportsDirectAccess: false, // Public pages don't support direct access
70
71
  requiresEvent: true, // Public pages always require an event
71
72
  isLoading: false,
72
73
  appName: getAppName()
73
- };
74
+ }), []);
74
75
  }
75
76
 
76
77
  // For authenticated pages, use UnifiedAuthProvider
77
78
  try {
78
79
  const { appConfig, appName } = useUnifiedAuth();
79
- return {
80
+ return useMemo(() => ({
80
81
  supportsDirectAccess: !(appConfig?.requires_event ?? true),
81
82
  requiresEvent: appConfig?.requires_event ?? true,
82
83
  isLoading: appConfig === null,
83
84
  appName
84
- };
85
+ }), [appConfig?.requires_event, appName]);
85
86
  } catch (error) {
86
87
  // Fallback if UnifiedAuthProvider is not available
87
- return {
88
+ return useMemo(() => ({
88
89
  supportsDirectAccess: false,
89
90
  requiresEvent: true,
90
91
  isLoading: false,
91
92
  appName: 'PACE'
92
- };
93
+ }), []);
93
94
  }
94
95
  }
@@ -6,6 +6,7 @@ import React, {
6
6
  useLayoutEffect,
7
7
  useCallback,
8
8
  useRef,
9
+ useMemo,
9
10
  } from 'react';
10
11
  import { useUnifiedAuth } from './UnifiedAuthProvider';
11
12
  import { useOrganisations } from './OrganisationProvider';
@@ -307,14 +308,15 @@ export function EventProvider({ children }: EventProviderProps) {
307
308
  await fetchEvents();
308
309
  }, [fetchEvents]);
309
310
 
310
- const contextValue: EventContextType = {
311
+ // Memoize the context value to prevent unnecessary re-renders
312
+ const contextValue: EventContextType = useMemo(() => ({
311
313
  events,
312
314
  selectedEvent,
313
315
  isLoading,
314
316
  error,
315
317
  setSelectedEvent,
316
318
  refreshEvents,
317
- };
319
+ }), [events, selectedEvent, isLoading, error, setSelectedEvent, refreshEvents]);
318
320
 
319
321
  return (
320
322
  <EventContext.Provider value={contextValue}>
@@ -116,7 +116,7 @@ export interface PagePermissionGuardProps {
116
116
  * @param props - Component props
117
117
  * @returns React element with permission enforcement
118
118
  */
119
- export function PagePermissionGuard({
119
+ const PagePermissionGuardComponent = ({
120
120
  pageName,
121
121
  operation,
122
122
  children,
@@ -127,7 +127,7 @@ export function PagePermissionGuard({
127
127
  scope,
128
128
  onDenied,
129
129
  loading = <DefaultLoading />
130
- }: PagePermissionGuardProps) {
130
+ }: PagePermissionGuardProps) => {
131
131
  // Generate a unique instance ID for debugging
132
132
  const instanceId = useMemo(() => Math.random().toString(36).substr(2, 9), []);
133
133
 
@@ -140,17 +140,40 @@ export function PagePermissionGuard({
140
140
  const supabaseRef = useRef(supabase);
141
141
  supabaseRef.current = supabase;
142
142
 
143
- // Create a stable scope object that never changes unless the actual values change
144
- const stableScope = useMemo(() => {
145
- if (!resolvedScope || !resolvedScope.organisationId) {
146
- return { organisationId: '', appId: '', eventId: undefined };
147
- }
148
- return {
143
+ // Track the last scope we called useCan with to prevent infinite loops
144
+ const lastScopeRef = useRef<string | null>(null);
145
+
146
+ // Use a ref to store the stable scope and only update it when it actually changes
147
+ const stableScopeRef = useRef<{ organisationId: string; appId: string; eventId: string | undefined }>({
148
+ organisationId: '',
149
+ appId: '',
150
+ eventId: undefined
151
+ });
152
+
153
+ // Only update the stable scope if the resolved scope has actually changed
154
+ if (resolvedScope && resolvedScope.organisationId) {
155
+ const newScope = {
149
156
  organisationId: resolvedScope.organisationId,
150
157
  appId: resolvedScope.appId,
151
158
  eventId: resolvedScope.eventId
152
159
  };
153
- }, [resolvedScope?.organisationId, resolvedScope?.eventId, resolvedScope?.appId]);
160
+
161
+ // Only update if the scope has actually changed
162
+ if (stableScopeRef.current.organisationId !== newScope.organisationId ||
163
+ stableScopeRef.current.eventId !== newScope.eventId ||
164
+ stableScopeRef.current.appId !== newScope.appId) {
165
+ stableScopeRef.current = {
166
+ organisationId: newScope.organisationId,
167
+ appId: newScope.appId || '',
168
+ eventId: newScope.eventId
169
+ };
170
+ }
171
+ } else if (!resolvedScope) {
172
+ // Reset to empty scope when no resolved scope
173
+ stableScopeRef.current = { organisationId: '', appId: '', eventId: undefined };
174
+ }
175
+
176
+ const stableScope = stableScopeRef.current;
154
177
 
155
178
  // Resolve scope - either use provided scope or resolve from context
156
179
  useEffect(() => {
@@ -296,8 +319,19 @@ export function PagePermissionGuard({
296
319
  return;
297
320
  }
298
321
 
299
- // No context available
300
- setCheckError(new Error('Either organisation context or event context is required for page permission checking'));
322
+ // No context available - provide more helpful error message
323
+ const errorMessage = !selectedOrganisationId && !selectedEventId
324
+ ? 'Either organisation context or event context is required for page permission checking'
325
+ : 'Insufficient context for permission checking. Please ensure you are properly authenticated and have selected an organisation or event.';
326
+
327
+ console.error('[PagePermissionGuard] Context resolution failed:', {
328
+ selectedOrganisationId,
329
+ selectedEventId,
330
+ appId,
331
+ error: errorMessage
332
+ });
333
+
334
+ setCheckError(new Error(errorMessage));
301
335
  setResolvedScope(null); // Ensure we don't proceed with incomplete scope
302
336
  };
303
337
 
@@ -316,23 +350,6 @@ export function PagePermissionGuard({
316
350
 
317
351
  // Check if user has permission - only call useCan when we have a resolved scope
318
352
  // If resolvedScope is null, we're still resolving, so show loading state
319
- console.log(`[PagePermissionGuard] Calling useCan with scope: (instance: ${instanceId})`, resolvedScope);
320
- console.log(`[PagePermissionGuard] resolvedScope: (instance: ${instanceId})`, resolvedScope);
321
- console.log(`[PagePermissionGuard] selectedEventId: (instance: ${instanceId})`, selectedEventId);
322
-
323
- console.log(`[PagePermissionGuard] About to call useCan with: (instance: ${instanceId})`, {
324
- userId: user?.id || '',
325
- scope: resolvedScope,
326
- permission,
327
- pageId: effectivePageId,
328
- useCache: true
329
- });
330
-
331
- // Only call useCan when we have a valid resolved scope
332
- // This prevents the race condition by not calling useCan with invalid scope
333
- const shouldCheckPermissions = resolvedScope && resolvedScope.organisationId;
334
-
335
- // Call useCan with the stable scope object
336
353
  const { can, isLoading: canIsLoading, error: canError } = useCan(
337
354
  user?.id || '',
338
355
  stableScope,
@@ -341,20 +358,9 @@ export function PagePermissionGuard({
341
358
  true // Use cache
342
359
  );
343
360
 
344
- console.log('[PagePermissionGuard] useCan returned:', { can, canIsLoading, canError });
345
- console.log('[PagePermissionGuard] can type and value:', { type: typeof can, value: can, isBoolean: typeof can === 'boolean' });
346
-
347
361
  // Combine loading states - we're loading if either scope is resolving OR permission check is loading
348
- const isLoading = !resolvedScope || !shouldCheckPermissions || canIsLoading;
362
+ const isLoading = !resolvedScope || canIsLoading;
349
363
  const error = checkError || canError;
350
-
351
- console.log('[PagePermissionGuard] Combined state:', {
352
- can,
353
- isLoading,
354
- canIsLoading,
355
- resolvedScopeExists: !!resolvedScope,
356
- error: error?.message
357
- });
358
364
 
359
365
  // Handle permission check completion
360
366
  useEffect(() => {
@@ -398,58 +404,50 @@ export function PagePermissionGuard({
398
404
  }
399
405
  }, [strictMode, hasChecked, isLoading, can, pageName, operation, user?.id, resolvedScope]);
400
406
 
401
- // Calculate the actual render state
402
- const shouldShowAccessDenied = !isLoading && !!resolvedScope && !checkError && !can;
403
- const shouldShowContent = !isLoading && !!resolvedScope && !checkError && can;
404
-
405
- // Debug: Log final render state
406
- console.log('[PagePermissionGuard] Final render state:', {
407
- isLoading,
408
- resolvedScope: !!resolvedScope,
409
- checkError: checkError?.message,
410
- can,
411
- canIsLoading,
412
- shouldCheckPermissions,
413
- willShowLoading: isLoading || !resolvedScope,
414
- willShowError: !!checkError,
415
- willShowAccessDenied: shouldShowAccessDenied,
416
- willShowContent: shouldShowContent,
417
- scopeKey: resolvedScope ? `${resolvedScope.organisationId}-${resolvedScope.eventId}-${resolvedScope.appId}` : 'no-scope'
418
- });
407
+ // Calculate the actual render state - FIXED: Proper state calculation
408
+ const shouldShowAccessDenied = !isLoading && !!resolvedScope && !checkError && hasChecked && !can;
409
+ const shouldShowContent = !isLoading && !!resolvedScope && !checkError && hasChecked && can;
419
410
 
420
411
  // Create a key to force re-render when scope or permission state changes
421
412
  const scopeKey = resolvedScope ? `${resolvedScope.organisationId}-${resolvedScope.eventId}-${resolvedScope.appId}` : 'no-scope';
422
- const permissionKey = `${scopeKey}-${can}-${isLoading}-${!!checkError}`;
413
+ const permissionKey = `${scopeKey}-${can}-${isLoading}-${!!checkError}-${hasChecked}`;
414
+
415
+ // Debug logging for state transitions
416
+ useEffect(() => {
417
+ console.log('[PagePermissionGuard] State transition:', {
418
+ instanceId,
419
+ isLoading,
420
+ hasChecked,
421
+ resolvedScope: !!resolvedScope,
422
+ checkError: !!checkError,
423
+ can,
424
+ shouldShowAccessDenied,
425
+ shouldShowContent,
426
+ permissionKey
427
+ });
428
+ }, [isLoading, hasChecked, resolvedScope, checkError, can, shouldShowAccessDenied, shouldShowContent, permissionKey, instanceId]);
423
429
 
424
430
  // Show loading state
425
- if (isLoading || !resolvedScope) {
426
- console.log(`[PagePermissionGuard] RENDERING LOADING STATE for ${pageName} (instance: ${instanceId})`);
431
+ if (isLoading || !resolvedScope || !hasChecked) {
427
432
  return <div key={`loading-${permissionKey}`}>{loading}</div>;
428
433
  }
429
434
 
430
435
  // Show error state
431
436
  if (checkError) {
432
- console.error(`[PagePermissionGuard] Permission check failed for page ${pageName}:`, checkError);
433
- console.log(`[PagePermissionGuard] RENDERING ERROR STATE for ${pageName} (instance: ${instanceId})`);
434
437
  return <div key={`error-${permissionKey}`}>{fallback}</div>;
435
438
  }
436
439
 
437
- // Use the calculated state instead of raw values to prevent race conditions
438
-
439
440
  // Show access denied
440
441
  if (shouldShowAccessDenied) {
441
- console.log(`[PagePermissionGuard] RENDERING ACCESS DENIED STATE for ${pageName} (instance: ${instanceId})`);
442
442
  return <div key={`denied-${permissionKey}`}>{fallback}</div>;
443
443
  }
444
444
 
445
445
  // Show protected content
446
446
  if (shouldShowContent) {
447
- console.log(`[PagePermissionGuard] RENDERING CONTENT STATE for ${pageName} (instance: ${instanceId})`);
448
447
  return <div key={`content-${permissionKey}`}>{children}</div>;
449
448
  }
450
449
 
451
450
  // Fallback: This should never happen, but just in case
452
- console.log(`[PagePermissionGuard] RENDERING FALLBACK STATE for ${pageName} (instance: ${instanceId})`);
453
451
  return <div key={`fallback-${permissionKey}`}>{loading}</div>;
454
452
  }
455
453
 
@@ -488,6 +486,7 @@ function DefaultLoading() {
488
486
  </div>
489
487
  </div>
490
488
  );
491
- }
489
+ };
492
490
 
491
+ export const PagePermissionGuard = PagePermissionGuardComponent;
493
492
  export default PagePermissionGuard;
@@ -951,9 +951,9 @@ describe('PagePermissionGuard Component', () => {
951
951
  expect(mockUseCan).toHaveBeenCalledWith(
952
952
  '',
953
953
  expect.objectContaining({
954
- organisationId: 'org-123',
955
- eventId: 'event-123',
956
- appId: 'app-123'
954
+ organisationId: '',
955
+ eventId: undefined,
956
+ appId: ''
957
957
  }),
958
958
  'read:page.dashboard',
959
959
  'dashboard',
@@ -139,7 +139,8 @@ export function usePermissions(userId: UUID, scope: Scope) {
139
139
  }
140
140
  }, [userId, scope.organisationId, scope.eventId, scope.appId]);
141
141
 
142
- return {
142
+ // Memoize the return object to prevent unnecessary re-renders
143
+ return useMemo(() => ({
143
144
  permissions,
144
145
  isLoading,
145
146
  error,
@@ -147,7 +148,7 @@ export function usePermissions(userId: UUID, scope: Scope) {
147
148
  hasAnyPermission,
148
149
  hasAllPermissions,
149
150
  refetch
150
- };
151
+ }), [permissions, isLoading, error, hasPermission, hasAnyPermission, hasAllPermissions, refetch]);
151
152
  }
152
153
 
153
154
  /**
@@ -183,7 +184,69 @@ export function useCan(
183
184
  const [isLoading, setIsLoading] = useState(true);
184
185
  const [error, setError] = useState<Error | null>(null);
185
186
 
186
- const checkPermission = useCallback(async () => {
187
+ // Use refs to track the last values to prevent unnecessary re-runs
188
+ const lastUserIdRef = useRef<UUID | null>(null);
189
+ const lastScopeRef = useRef<string | null>(null);
190
+ const lastPermissionRef = useRef<Permission | null>(null);
191
+ const lastPageIdRef = useRef<UUID | undefined | null>(null);
192
+ const lastUseCacheRef = useRef<boolean | null>(null);
193
+
194
+ useEffect(() => {
195
+ // Create a scope key to track changes
196
+ const scopeKey = `${scope.organisationId}-${scope.eventId}-${scope.appId}`;
197
+
198
+ // Only run if something has actually changed
199
+ if (
200
+ lastUserIdRef.current !== userId ||
201
+ lastScopeRef.current !== scopeKey ||
202
+ lastPermissionRef.current !== permission ||
203
+ lastPageIdRef.current !== pageId ||
204
+ lastUseCacheRef.current !== useCache
205
+ ) {
206
+ lastUserIdRef.current = userId;
207
+ lastScopeRef.current = scopeKey;
208
+ lastPermissionRef.current = permission;
209
+ lastPageIdRef.current = pageId;
210
+ lastUseCacheRef.current = useCache;
211
+
212
+ // Inline the permission check logic to avoid useCallback dependency issues
213
+ const checkPermission = async () => {
214
+ if (!userId) {
215
+ setCan(false);
216
+ setIsLoading(false);
217
+ return;
218
+ }
219
+
220
+ // Don't check permissions if scope is invalid (e.g., organisationId is empty)
221
+ // Return can: false and loading: true for invalid scopes to prevent premature access denied
222
+ if (!scope.organisationId || scope.organisationId.trim() === '') {
223
+ setCan(false);
224
+ setIsLoading(true);
225
+ return;
226
+ }
227
+
228
+ try {
229
+ setIsLoading(true);
230
+ setError(null);
231
+
232
+ const result = useCache
233
+ ? await isPermittedCached({ userId, scope, permission, pageId })
234
+ : await isPermitted({ userId, scope, permission, pageId });
235
+
236
+ setCan(result);
237
+ } catch (err) {
238
+ setError(err instanceof Error ? err : new Error('Failed to check permission'));
239
+ setCan(false);
240
+ } finally {
241
+ setIsLoading(false);
242
+ }
243
+ };
244
+
245
+ checkPermission();
246
+ }
247
+ }, [userId, scope.organisationId, scope.eventId, scope.appId, permission, pageId, useCache]);
248
+
249
+ const refetch = useCallback(async () => {
187
250
  if (!userId) {
188
251
  setCan(false);
189
252
  setIsLoading(false);
@@ -191,7 +254,6 @@ export function useCan(
191
254
  }
192
255
 
193
256
  // Don't check permissions if scope is invalid (e.g., organisationId is empty)
194
- // Return can: false and loading: true for invalid scopes to prevent premature access denied
195
257
  if (!scope.organisationId || scope.organisationId.trim() === '') {
196
258
  setCan(false);
197
259
  setIsLoading(true);
@@ -215,16 +277,13 @@ export function useCan(
215
277
  }
216
278
  }, [userId, scope.organisationId, scope.eventId, scope.appId, permission, pageId, useCache]);
217
279
 
218
- useEffect(() => {
219
- checkPermission();
220
- }, [checkPermission]);
221
-
222
- return {
280
+ // Memoize the return object to prevent unnecessary re-renders
281
+ return useMemo(() => ({
223
282
  can,
224
283
  isLoading,
225
284
  error,
226
- refetch: checkPermission
227
- };
285
+ refetch
286
+ }), [can, isLoading, error, refetch]);
228
287
  }
229
288
 
230
289
  /**
@@ -286,12 +345,13 @@ export function useAccessLevel(userId: UUID, scope: Scope): {
286
345
  fetchAccessLevel();
287
346
  }, [fetchAccessLevel]);
288
347
 
289
- return {
348
+ // Memoize the return object to prevent unnecessary re-renders
349
+ return useMemo(() => ({
290
350
  accessLevel,
291
351
  isLoading,
292
352
  error,
293
353
  refetch: fetchAccessLevel
294
- };
354
+ }), [accessLevel, isLoading, error, fetchAccessLevel]);
295
355
  }
296
356
 
297
357
  /**
@@ -374,12 +434,13 @@ export function useMultiplePermissions(
374
434
  checkPermissions();
375
435
  }, [checkPermissions]);
376
436
 
377
- return {
437
+ // Memoize the return object to prevent unnecessary re-renders
438
+ return useMemo(() => ({
378
439
  results,
379
440
  isLoading,
380
441
  error,
381
442
  refetch: checkPermissions
382
- };
443
+ }), [results, isLoading, error, checkPermissions]);
383
444
  }
384
445
 
385
446
  /**
@@ -459,12 +520,13 @@ export function useHasAnyPermission(
459
520
  checkAnyPermission();
460
521
  }, [checkAnyPermission]);
461
522
 
462
- return {
523
+ // Memoize the return object to prevent unnecessary re-renders
524
+ return useMemo(() => ({
463
525
  hasAny,
464
526
  isLoading,
465
527
  error,
466
528
  refetch: checkAnyPermission
467
- };
529
+ }), [hasAny, isLoading, error, checkAnyPermission]);
468
530
  }
469
531
 
470
532
  /**
@@ -544,12 +606,13 @@ export function useHasAllPermissions(
544
606
  checkAllPermissions();
545
607
  }, [checkAllPermissions]);
546
608
 
547
- return {
609
+ // Memoize the return object to prevent unnecessary re-renders
610
+ return useMemo(() => ({
548
611
  hasAll,
549
612
  isLoading,
550
613
  error,
551
614
  refetch: checkAllPermissions
552
- };
615
+ }), [hasAll, isLoading, error, checkAllPermissions]);
553
616
  }
554
617
 
555
618
  /**
@@ -617,11 +680,12 @@ export function useCachedPermissions(userId: UUID, scope: Scope): {
617
680
  fetchCachedPermissions();
618
681
  }, [fetchCachedPermissions]);
619
682
 
620
- return {
683
+ // Memoize the return object to prevent unnecessary re-renders
684
+ return useMemo(() => ({
621
685
  permissions,
622
686
  isLoading,
623
687
  error,
624
688
  invalidateCache,
625
689
  refetch: fetchCachedPermissions
626
- };
690
+ }), [permissions, isLoading, error, invalidateCache, fetchCachedPermissions]);
627
691
  }