@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.
- package/dist/{DataTable-BTXP6MW4.js → DataTable-IX7N7XYG.js} +4 -4
- package/dist/{chunk-7QEQCZDV.js → chunk-37WAIATW.js} +4 -4
- package/dist/{chunk-7UEIZCST.js → chunk-4TMM2IGR.js} +5 -4
- package/dist/chunk-4TMM2IGR.js.map +1 -0
- package/dist/{chunk-DKDTXS5Q.js → chunk-JE7K6Q3N.js} +10 -9
- package/dist/chunk-JE7K6Q3N.js.map +1 -0
- package/dist/{chunk-NU3GLMIQ.js → chunk-R4QSIQDW.js} +2 -2
- package/dist/{chunk-CMXBNDPM.js → chunk-UYKKHRJN.js} +2 -2
- package/dist/{chunk-UIBYODOF.js → chunk-WIVRKQQR.js} +51 -56
- package/dist/chunk-WIVRKQQR.js.map +1 -0
- package/dist/{chunk-J3JWBQSC.js → chunk-YA77BOZM.js} +58 -22
- package/dist/chunk-YA77BOZM.js.map +1 -0
- package/dist/{chunk-DV2Z3RBQ.js → chunk-ZIYFAQJ5.js} +2 -2
- package/dist/components.js +6 -6
- package/dist/hooks.js +2 -2
- package/dist/index.js +8 -8
- package/dist/providers.js +2 -2
- package/dist/rbac/index.d.ts +1 -11
- package/dist/rbac/index.js +3 -3
- package/dist/utils.js +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +1 -1
- package/docs/api/classes/MissingUserContextError.md +1 -1
- package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
- package/docs/api/classes/PermissionDeniedError.md +1 -1
- package/docs/api/classes/PublicErrorBoundary.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +1 -1
- package/docs/api/classes/RBACCache.md +1 -1
- package/docs/api/classes/RBACEngine.md +1 -1
- package/docs/api/classes/RBACError.md +1 -1
- package/docs/api/classes/RBACNotInitializedError.md +1 -1
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +1 -1
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- package/docs/api/interfaces/EventContextType.md +7 -7
- package/docs/api/interfaces/EventLogoProps.md +1 -1
- package/docs/api/interfaces/EventProviderProps.md +2 -2
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
- package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
- package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +1 -1
- package/docs/api/interfaces/RBACContextType.md +1 -1
- package/docs/api/interfaces/RBACLogger.md +1 -1
- package/docs/api/interfaces/RBACProviderProps.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RouteAccessRecord.md +1 -1
- package/docs/api/interfaces/RouteConfig.md +1 -1
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +12 -12
- package/docs/troubleshooting/cake-infinite-rerender-debugging.md +284 -0
- package/docs/troubleshooting/cake-infinite-rerender-summary.md +117 -0
- package/docs/troubleshooting/cake-rerender-diagnostic.js +162 -0
- package/docs/troubleshooting/rbac-critical-fixes-summary.md +260 -0
- package/package.json +1 -1
- package/src/__tests__/hooks/usePermissions.test.ts +265 -0
- package/src/__tests__/rbac/PagePermissionGuard.test.tsx +187 -0
- package/src/hooks/useAppConfig.ts +7 -6
- package/src/providers/EventProvider.tsx +4 -2
- package/src/rbac/components/PagePermissionGuard.tsx +68 -69
- package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +3 -3
- package/src/rbac/hooks/usePermissions.ts +85 -21
- package/dist/chunk-7UEIZCST.js.map +0 -1
- package/dist/chunk-DKDTXS5Q.js.map +0 -1
- package/dist/chunk-J3JWBQSC.js.map +0 -1
- package/dist/chunk-UIBYODOF.js.map +0 -1
- /package/dist/{DataTable-BTXP6MW4.js.map → DataTable-IX7N7XYG.js.map} +0 -0
- /package/dist/{chunk-7QEQCZDV.js.map → chunk-37WAIATW.js.map} +0 -0
- /package/dist/{chunk-NU3GLMIQ.js.map → chunk-R4QSIQDW.js.map} +0 -0
- /package/dist/{chunk-CMXBNDPM.js.map → chunk-UYKKHRJN.js.map} +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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: '
|
|
955
|
-
eventId:
|
|
956
|
-
appId: '
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
|
|
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
|
|
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
|
}
|