@jmruthers/pace-core 0.5.127 → 0.5.129

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 (131) hide show
  1. package/dist/{DataTable-QZH6SEUM.js → DataTable-O2COE77K.js} +2 -2
  2. package/dist/{PublicLoadingSpinner-qqvM-NUe.d.ts → PublicLoadingSpinner-CUAnTvcg.d.ts} +7 -0
  3. package/dist/{chunk-JDBO5NCG.js → chunk-BJ7MCGY6.js} +66 -18
  4. package/dist/chunk-BJ7MCGY6.js.map +1 -0
  5. package/dist/{chunk-TMUNK34W.js → chunk-MOOJ2TK6.js} +37 -9
  6. package/dist/chunk-MOOJ2TK6.js.map +1 -0
  7. package/dist/components.d.ts +1 -1
  8. package/dist/components.js +2 -2
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/utils.d.ts +1 -1
  12. package/dist/utils.js +1 -1
  13. package/docs/api/classes/ColumnFactory.md +1 -1
  14. package/docs/api/classes/ErrorBoundary.md +1 -1
  15. package/docs/api/classes/InvalidScopeError.md +1 -1
  16. package/docs/api/classes/MissingUserContextError.md +1 -1
  17. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  18. package/docs/api/classes/PermissionDeniedError.md +1 -1
  19. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  20. package/docs/api/classes/RBACAuditManager.md +1 -1
  21. package/docs/api/classes/RBACCache.md +1 -1
  22. package/docs/api/classes/RBACEngine.md +1 -1
  23. package/docs/api/classes/RBACError.md +1 -1
  24. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  25. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  26. package/docs/api/classes/StorageUtils.md +1 -1
  27. package/docs/api/enums/FileCategory.md +1 -1
  28. package/docs/api/interfaces/AggregateConfig.md +1 -1
  29. package/docs/api/interfaces/ButtonProps.md +1 -1
  30. package/docs/api/interfaces/CardProps.md +1 -1
  31. package/docs/api/interfaces/ColorPalette.md +1 -1
  32. package/docs/api/interfaces/ColorShade.md +1 -1
  33. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  34. package/docs/api/interfaces/DataRecord.md +1 -1
  35. package/docs/api/interfaces/DataTableAction.md +1 -1
  36. package/docs/api/interfaces/DataTableColumn.md +1 -1
  37. package/docs/api/interfaces/DataTableProps.md +1 -1
  38. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  39. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  40. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  41. package/docs/api/interfaces/EventAppRoleData.md +1 -1
  42. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  43. package/docs/api/interfaces/FileMetadata.md +1 -1
  44. package/docs/api/interfaces/FileReference.md +1 -1
  45. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  46. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  47. package/docs/api/interfaces/FileUploadProps.md +1 -1
  48. package/docs/api/interfaces/FooterProps.md +1 -1
  49. package/docs/api/interfaces/GrantEventAppRoleParams.md +1 -1
  50. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  51. package/docs/api/interfaces/InputProps.md +1 -1
  52. package/docs/api/interfaces/LabelProps.md +1 -1
  53. package/docs/api/interfaces/LoginFormProps.md +1 -1
  54. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  55. package/docs/api/interfaces/NavigationContextType.md +1 -1
  56. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  57. package/docs/api/interfaces/NavigationItem.md +1 -1
  58. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  59. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  60. package/docs/api/interfaces/Organisation.md +1 -1
  61. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  62. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  63. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  64. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  65. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  66. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  67. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  68. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  69. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  70. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  71. package/docs/api/interfaces/PaletteData.md +1 -1
  72. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  73. package/docs/api/interfaces/ProtectedRouteProps.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/RBACLogger.md +1 -1
  82. package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
  83. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  84. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  85. package/docs/api/interfaces/RoleManagementResult.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/SwitchProps.md +1 -1
  100. package/docs/api/interfaces/ToastActionElement.md +1 -1
  101. package/docs/api/interfaces/ToastProps.md +1 -1
  102. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  103. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  104. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  105. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  106. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  107. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  108. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  109. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  110. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  111. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  112. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  113. package/docs/api/interfaces/UserEventAccess.md +1 -1
  114. package/docs/api/interfaces/UserMenuProps.md +1 -1
  115. package/docs/api/interfaces/UserProfile.md +1 -1
  116. package/docs/api/modules.md +10 -3
  117. package/docs/api-reference/components.md +24 -0
  118. package/docs/api-reference/types.md +28 -0
  119. package/docs/implementation-guides/data-tables.md +93 -10
  120. package/docs/implementation-guides/permission-enforcement.md +4 -0
  121. package/docs/rbac/super-admin-guide.md +43 -5
  122. package/package.json +1 -1
  123. package/src/components/DataTable/__tests__/DataTable.export.test.tsx +702 -0
  124. package/src/components/DataTable/components/DataTableCore.tsx +90 -39
  125. package/src/components/DataTable/index.ts +3 -1
  126. package/src/components/DataTable/types.ts +68 -0
  127. package/src/components/PaceAppLayout/PaceAppLayout.tsx +60 -11
  128. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +81 -1
  129. package/dist/chunk-JDBO5NCG.js.map +0 -1
  130. package/dist/chunk-TMUNK34W.js.map +0 -1
  131. /package/dist/{DataTable-QZH6SEUM.js.map → DataTable-O2COE77K.js.map} +0 -0
@@ -46,6 +46,7 @@ import { useCan, useResolvedScope } from '../../../rbac/hooks';
46
46
  // Do NOT set duration or timeout properties - let the toast system use its default.
47
47
  import { toast } from '../../../hooks/useToast';
48
48
  import { exportToCSV, exportToCSVWithTableRows } from '../utils/exportUtils';
49
+ import type { ExportOptions } from '../types';
49
50
  import { useUnifiedAuth } from '../../../providers/UnifiedAuthProvider';
50
51
  import { Scope } from '../../../rbac/types';
51
52
  import { useDataTablePermissions } from '../hooks/useDataTablePermissions';
@@ -150,7 +151,7 @@ export interface DataTableCoreProps<TData extends DataRecord> {
150
151
  onDeleteRow?: (row: TData) => void;
151
152
  onCreateRow?: (data: Partial<TData>) => void;
152
153
  onImport?: (data: TData[]) => void | Promise<void>;
153
- onExport?: () => void;
154
+ onExport?: (options: import('../types').ExportOptions<TData>) => void | Promise<void>;
154
155
  onRowSelectionChange?: (selection: Record<string, boolean>) => void;
155
156
  selection?: Record<string, boolean>;
156
157
  onDeleteSelected?: (selectedRows: Record<string, boolean>) => void;
@@ -661,14 +662,24 @@ function DataTableInternal<TData extends DataRecord>({
661
662
  onDeleteSelected: permissions.canDelete.can ? onDeleteSelected : undefined,
662
663
  };
663
664
 
664
- // Debug logging for creation handler
665
+ // Debug logging for handlers
666
+ console.log('[DataTableCore] Secure handlers setup:', {
667
+ 'permissions.canExport.can': permissions.canExport.can,
668
+ 'onExport prop provided': !!onExport,
669
+ 'onExport type': typeof onExport,
670
+ 'secureHandlers.onExport': !!handlers.onExport,
671
+ });
672
+
665
673
  if (import.meta.env.MODE === 'development') {
666
- logger.debug('[DataTableCore] Creation handler check:', {
674
+ logger.debug('[DataTableCore] Handler check:', {
667
675
  'permissions.canCreate.can': permissions.canCreate.can,
668
676
  'onCreateRow prop provided': !!onCreateRow,
669
677
  'secureHandlers.onCreateRow': !!handlers.onCreateRow,
670
678
  'secureFeatures.creation': secureFeatures.creation,
671
- 'will pass onCreateRow to toolbar': secureFeatures.creation && !!handlers.onCreateRow
679
+ 'will pass onCreateRow to toolbar': secureFeatures.creation && !!handlers.onCreateRow,
680
+ 'permissions.canExport.can': permissions.canExport.can,
681
+ 'onExport prop provided': !!onExport,
682
+ 'secureHandlers.onExport': !!handlers.onExport,
672
683
  });
673
684
  }
674
685
 
@@ -1011,9 +1022,9 @@ function DataTableInternal<TData extends DataRecord>({
1011
1022
  }
1012
1023
  stateActions.setImportModal(true);
1013
1024
  }}
1014
- onExport={secureHandlers.onExport || (async () => {
1025
+ onExport={async () => {
1015
1026
  try {
1016
- // Automatic export: exports exactly what's shown in the table
1027
+ // Prepare export options with all available data
1017
1028
  // Get the table rows (which have getValue() that properly evaluates accessorFn)
1018
1029
  const tableRows = table.getFilteredRowModel().rows;
1019
1030
 
@@ -1026,16 +1037,8 @@ function DataTableInternal<TData extends DataRecord>({
1026
1037
  return !isSystemColumn && col.getIsVisible();
1027
1038
  });
1028
1039
 
1029
- // Map table columns to export columns
1030
- // Use TanStack Table's getValue() which properly handles accessorFn
1031
- const visibleColumns: Array<{
1032
- header?: string;
1033
- id?: string;
1034
- accessorKey?: string;
1035
- accessorFn?: (row: any) => any;
1036
- editAccessorKey?: string;
1037
- isIdColumn?: boolean;
1038
- }> = [];
1040
+ // Map table columns to visible columns
1041
+ const visibleColumns: DataTableColumn<TData>[] = [];
1039
1042
 
1040
1043
  // Store mapping of column IDs to table column instances for getValue() calls
1041
1044
  const columnIdToTableColumn = new Map<string, typeof visibleTableColumns[0]>();
@@ -1052,42 +1055,90 @@ function DataTableInternal<TData extends DataRecord>({
1052
1055
  // Store the table column for getValue() calls
1053
1056
  columnIdToTableColumn.set(tableCol.id, tableCol);
1054
1057
 
1055
- const hasAccessorFn = 'accessorFn' in originalCol && (originalCol as any).accessorFn;
1056
- const editAccessorKey = originalCol.editAccessorKey;
1057
-
1058
1058
  // Add the display column (what's shown in the table)
1059
- // For columns with accessorFn, we'll use the table's getValue() method instead of calling accessorFn directly
1060
- const displayHeader = typeof originalCol.header === 'string'
1061
- ? originalCol.header
1062
- : originalCol.accessorKey || tableCol.id || 'Column';
1063
-
1064
- visibleColumns.push({
1065
- ...originalCol,
1066
- header: displayHeader,
1067
- // Store table column ID for getValue() lookup
1068
- id: tableCol.id,
1069
- // Preserve accessorFn if present (will use getValue() if available)
1070
- accessorFn: hasAccessorFn ? (originalCol as any).accessorFn : undefined,
1071
- });
1072
-
1073
- // Note: We do NOT export editAccessorKey fields as separate columns
1074
- // Only visible columns (with their display values via accessorFn) are exported
1075
- // This ensures exports match what users see in the table
1059
+ visibleColumns.push(originalCol);
1076
1060
  });
1077
1061
 
1078
1062
  // Generate filename with timestamp
1079
1063
  const timestamp = new Date().toISOString().split('T')[0];
1080
1064
  const filename = title ? `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_${timestamp}.csv` : `data_export_${timestamp}.csv`;
1081
1065
 
1066
+ // Create export options
1067
+ const exportOptions: ExportOptions<TData> = {
1068
+ tableRows,
1069
+ allColumns: columns,
1070
+ visibleColumns,
1071
+ columnIdToTableColumn,
1072
+ data,
1073
+ filename,
1074
+ table
1075
+ };
1076
+
1077
+ // If custom handler provided, call it with options
1078
+ console.log('[DataTableCore] Export handler check:', {
1079
+ 'secureHandlers.onExport exists': !!secureHandlers.onExport,
1080
+ 'permissions.canExport.can': permissions.canExport.can,
1081
+ 'onExport prop provided': !!onExport,
1082
+ 'onExport type': typeof onExport,
1083
+ });
1084
+
1085
+ if (secureHandlers.onExport) {
1086
+ console.log('[DataTableCore] ✅ Calling custom onExport handler');
1087
+ logger.debug('[DataTableCore] Calling custom onExport handler');
1088
+ await secureHandlers.onExport(exportOptions);
1089
+ console.log('[DataTableCore] ✅ Custom onExport handler completed');
1090
+ logger.debug('[DataTableCore] Custom onExport handler completed');
1091
+ return;
1092
+ } else {
1093
+ console.warn('[DataTableCore] ⚠️ No custom onExport handler, using default export', {
1094
+ 'secureHandlers.onExport': !!secureHandlers.onExport,
1095
+ 'permissions.canExport.can': permissions.canExport.can,
1096
+ 'onExport prop provided': !!onExport,
1097
+ });
1098
+ logger.debug('[DataTableCore] No custom onExport handler, using default export', {
1099
+ 'secureHandlers.onExport': !!secureHandlers.onExport,
1100
+ 'permissions.canExport.can': permissions.canExport.can,
1101
+ 'onExport prop provided': !!onExport,
1102
+ });
1103
+ }
1104
+
1105
+ // Default export: exports exactly what's shown in the table
1106
+ // Convert visible columns to ExportColumn format
1107
+ const exportColumns: Array<{
1108
+ header?: string;
1109
+ id?: string;
1110
+ accessorKey?: string;
1111
+ accessorFn?: (row: any) => any;
1112
+ editAccessorKey?: string;
1113
+ isIdColumn?: boolean;
1114
+ }> = exportOptions.visibleColumns.map(col => {
1115
+ const colId = col.id || col.accessorKey;
1116
+ const hasAccessorFn = 'accessorFn' in col && (col as any).accessorFn;
1117
+
1118
+ return {
1119
+ ...col,
1120
+ header: typeof col.header === 'string'
1121
+ ? col.header
1122
+ : col.accessorKey || colId || 'Column',
1123
+ id: colId ? String(colId) : undefined,
1124
+ accessorFn: hasAccessorFn ? (col as any).accessorFn : undefined,
1125
+ };
1126
+ });
1127
+
1082
1128
  // Export using table rows with getValue() for proper accessorFn evaluation
1083
1129
  // This ensures we get the same values that are displayed in the table
1084
- await exportToCSVWithTableRows(tableRows, visibleColumns, columnIdToTableColumn, filename);
1130
+ await exportToCSVWithTableRows(
1131
+ exportOptions.tableRows,
1132
+ exportColumns,
1133
+ exportOptions.columnIdToTableColumn,
1134
+ exportOptions.filename
1135
+ );
1085
1136
 
1086
1137
  // Show success toast notification
1087
1138
  // NOTE: Toast notifications use default timeout (5 seconds) - do not set duration property
1088
1139
  toast({
1089
1140
  title: "Export Successful",
1090
- description: `Data exported to ${filename}`,
1141
+ description: `Data exported to ${exportOptions.filename}`,
1091
1142
  variant: "default"
1092
1143
  });
1093
1144
 
@@ -1103,7 +1154,7 @@ function DataTableInternal<TData extends DataRecord>({
1103
1154
  variant: "destructive"
1104
1155
  });
1105
1156
  }
1106
- })}
1157
+ }}
1107
1158
  rowSelection={rowSelection}
1108
1159
  onDeleteSelected={secureHandlers.onDeleteSelected ? async (selectedRows: Record<string, boolean>) => {
1109
1160
  const selectedCount = Object.values(selectedRows).filter(Boolean).length;
@@ -44,7 +44,9 @@ export type {
44
44
  PerformanceConfig,
45
45
  ServerSideConfig,
46
46
  ChunkingConfig,
47
- SearchIndexConfig
47
+ SearchIndexConfig,
48
+ // Export types
49
+ ExportOptions
48
50
  } from './types';
49
51
 
50
52
  export {
@@ -604,6 +604,52 @@ export interface RBACContext {
604
604
  pageId: string;
605
605
  }
606
606
 
607
+ // ============================================================================
608
+ // EXPORT TYPES
609
+ // ============================================================================
610
+
611
+ /**
612
+ * Options provided to the onExport handler for custom export functionality
613
+ *
614
+ * @example
615
+ * ```tsx
616
+ * <DataTable
617
+ * onExport={async (options) => {
618
+ * // Export only specific columns
619
+ * const customColumns = options.allColumns.filter(col =>
620
+ * ['name', 'email', 'role'].includes(col.accessorKey || '')
621
+ * );
622
+ * await exportToCSVWithTableRows(
623
+ * options.tableRows,
624
+ * customColumns,
625
+ * options.columnIdToTableColumn,
626
+ * 'custom-export.csv'
627
+ * );
628
+ * }}
629
+ * />
630
+ * ```
631
+ */
632
+ export interface ExportOptions<TData extends DataRecord> {
633
+ /** Filtered table rows with getValue() method for proper accessorFn evaluation */
634
+ tableRows: Array<{
635
+ original: TData;
636
+ getValue: (columnId: string) => any;
637
+ id: string;
638
+ }>;
639
+ /** All column definitions passed to the DataTable */
640
+ allColumns: DataTableColumn<TData>[];
641
+ /** Currently visible columns in the table */
642
+ visibleColumns: DataTableColumn<TData>[];
643
+ /** Mapping of column IDs to TanStack table column instances (for getValue() calls) */
644
+ columnIdToTableColumn: Map<string, any>;
645
+ /** Raw data array (unfiltered) */
646
+ data: TData[];
647
+ /** Default filename generated from table title */
648
+ filename: string;
649
+ /** TanStack table instance for advanced operations */
650
+ table: any; // Using any to avoid tight coupling with TanStack types
651
+ }
652
+
607
653
  // ============================================================================
608
654
  // MAIN COMPONENT PROPS
609
655
  // ============================================================================
@@ -672,6 +718,28 @@ export interface DataTableProps<TData extends DataRecord> {
672
718
  onCreateRow?: (data: Partial<TData>) => void;
673
719
  /** Import handler */
674
720
  onImport?: (data: TData[]) => void | Promise<void>;
721
+ /**
722
+ * Export handler - allows custom export logic with full control over columns and data
723
+ *
724
+ * If not provided, defaults to exporting all visible columns.
725
+ *
726
+ * @example
727
+ * ```tsx
728
+ * // Custom export with specific columns
729
+ * onExport={async (options) => {
730
+ * const exportColumns = options.allColumns.filter(col =>
731
+ * ['name', 'email'].includes(col.accessorKey || '')
732
+ * );
733
+ * await exportToCSVWithTableRows(
734
+ * options.tableRows,
735
+ * exportColumns,
736
+ * options.columnIdToTableColumn,
737
+ * 'users-export.csv'
738
+ * );
739
+ * }}
740
+ * ```
741
+ */
742
+ onExport?: (options: ExportOptions<TData>) => void | Promise<void>;
675
743
  /** Row selection change handler */
676
744
  onRowSelectionChange?: (selection: Record<string, boolean>) => void;
677
745
  /** Controlled selection state */
@@ -106,6 +106,7 @@ import { useEventTheme } from '../../hooks/useEventTheme';
106
106
  import { useCan, useResolvedScope } from '../../rbac/hooks';
107
107
  import { createScopeFromEvent } from '../../rbac/utils/eventContext';
108
108
  import { getCurrentAppName } from '../../utils/appNameResolver';
109
+ import { isSuperAdmin } from '../../rbac/api';
109
110
  import type { Permission, Scope } from '../../rbac/types';
110
111
 
111
112
  // Stable empty objects to prevent infinite loops
@@ -202,6 +203,12 @@ export interface PaceAppLayoutProps {
202
203
  * Outlet to render child routes. It provides integrated authentication, navigation,
203
204
  * and user management functionality.
204
205
  *
206
+ * **Super Admin Access:** When `enforcePermissions={true}`, PaceAppLayout automatically
207
+ * checks if the user is a super admin before enforcing permissions. Super admins bypass
208
+ * all permission checks and can access any route without violations. The component extracts
209
+ * base page names from route paths (e.g., `/organisation/scouts-victoria` → `"organisation"`)
210
+ * for permission checking, which can be overridden using `pageIdMapping`.
211
+ *
205
212
  * **Important:** The appName prop should use an APP_NAME constant declared in your App.tsx
206
213
  * file. This ensures consistency with public pages (via PublicPageProvider) which should
207
214
  * also receive the same APP_NAME constant. The logo URL is automatically constructed as
@@ -218,6 +225,7 @@ export interface PaceAppLayoutProps {
218
225
  * - Layout-level permission enforcement
219
226
  * - Permission-based navigation filtering
220
227
  * - Automatic page permission validation
228
+ * - Super admin bypass (super admins automatically bypass all permission checks)
221
229
  *
222
230
  * @example
223
231
  * Basic React Router setup with permission enforcement (RECOMMENDED):
@@ -418,9 +426,17 @@ export function PaceAppLayout({
418
426
  }, [location.pathname, routePermissions, defaultPermission]);
419
427
 
420
428
  // Get current page ID for permission checking
429
+ // Extract base page name (first path segment) instead of full route path
430
+ // Example: /organisation/scouts-victoria -> "organisation"
421
431
  const currentPageId = useMemo(() => {
422
432
  const currentPath = location.pathname;
423
- return pageIdMapping[currentPath] || currentPath.slice(1) || 'home';
433
+ // Use pageIdMapping if provided (takes precedence)
434
+ if (pageIdMapping[currentPath]) {
435
+ return pageIdMapping[currentPath];
436
+ }
437
+ // Extract first path segment (base page name)
438
+ const pathSegments = currentPath.slice(1).split('/').filter(Boolean);
439
+ return pathSegments[0] || 'home';
424
440
  }, [location.pathname, pageIdMapping]);
425
441
 
426
442
  // Build permission string in format: operation:page.pageId
@@ -432,8 +448,37 @@ export function PaceAppLayout({
432
448
  return permissionString as Permission;
433
449
  }, [enforcePermissions, currentRoutePermission, currentPageId]);
434
450
 
451
+ // Check super admin status before permission enforcement
452
+ const [isSuperAdminUser, setIsSuperAdminUser] = useState<boolean>(false);
453
+ const [isCheckingSuperAdmin, setIsCheckingSuperAdmin] = useState<boolean>(false);
454
+
455
+ useEffect(() => {
456
+ const checkSuperAdminStatus = async () => {
457
+ if (!user?.id) {
458
+ setIsSuperAdminUser(false);
459
+ setIsCheckingSuperAdmin(false);
460
+ return;
461
+ }
462
+
463
+ setIsCheckingSuperAdmin(true);
464
+ try {
465
+ const superAdminStatus = await isSuperAdmin(user.id);
466
+ setIsSuperAdminUser(superAdminStatus);
467
+ } catch (error) {
468
+ console.error('[PaceAppLayout] Error checking super admin status:', error);
469
+ setIsSuperAdminUser(false);
470
+ } finally {
471
+ setIsCheckingSuperAdmin(false);
472
+ }
473
+ };
474
+
475
+ checkSuperAdminStatus();
476
+ }, [user?.id]);
477
+
435
478
  // Use useCan hook for permission checking (standardized approach)
436
- const { can, isLoading: isCheckingPermission, error: permissionError } = useCan(
479
+ // Note: The database function already handles super admin bypass, but we check here
480
+ // as an additional safety layer to prevent unnecessary permission checks
481
+ const { can: canFromHook, isLoading: isCheckingPermission, error: permissionError } = useCan(
437
482
  user?.id || '',
438
483
  scope,
439
484
  currentPermission,
@@ -441,7 +486,9 @@ export function PaceAppLayout({
441
486
  true // useCache
442
487
  );
443
488
 
444
- // Permission enforcement state - sync from useCan
489
+ // Permission enforcement state - super admin bypasses all checks
490
+ // This ensures super admins never see permission errors even if useCan hasn't completed
491
+ const can = isSuperAdminUser ? true : canFromHook;
445
492
  const hasPermission = enforcePermissions ? can : true;
446
493
 
447
494
  // Handle permission check results with audit logging and callbacks
@@ -451,17 +498,19 @@ export function PaceAppLayout({
451
498
  }
452
499
 
453
500
  // Only proceed when permission check is complete (not loading)
454
- if (isCheckingPermission) {
501
+ // Wait for both super admin check and permission check to complete
502
+ if (isCheckingSuperAdmin || isCheckingPermission) {
455
503
  return;
456
504
  }
457
505
 
458
506
  // NEW: Phase 1 - Enhanced Security Features
459
- // Handle strict mode violations
460
- if (strictMode && !can) {
507
+ // Handle strict mode violations - skip for super admins
508
+ if (strictMode && !isSuperAdminUser && !can) {
461
509
  console.error(`[PaceAppLayout] STRICT MODE VIOLATION: User attempted to access protected page without permission`, {
462
510
  pageName: currentPageId,
463
511
  operation: currentRoutePermission,
464
512
  userId: user?.id,
513
+ isSuperAdmin: isSuperAdminUser,
465
514
  timestamp: new Date().toISOString()
466
515
  });
467
516
 
@@ -470,11 +519,11 @@ export function PaceAppLayout({
470
519
  }
471
520
  }
472
521
 
473
- // Handle page access denied callback
474
- if (!can && onPageAccessDenied) {
522
+ // Handle page access denied callback - skip for super admins
523
+ if (!isSuperAdminUser && !can && onPageAccessDenied) {
475
524
  onPageAccessDenied(currentPageId, currentRoutePermission);
476
525
  }
477
- }, [enforcePermissions, can, isCheckingPermission, currentPageId, currentRoutePermission, user?.id, strictMode, auditLog, onPageAccessDenied, onStrictModeViolation]);
526
+ }, [enforcePermissions, can, isCheckingPermission, isCheckingSuperAdmin, isSuperAdminUser, currentPageId, currentRoutePermission, user?.id, strictMode, auditLog, onPageAccessDenied, onStrictModeViolation]);
478
527
 
479
528
  // Filter navigation items based on permissions
480
529
  // This works independently of route enforcement - navigation filtering doesn't require enforcePermissions
@@ -702,8 +751,8 @@ export function PaceAppLayout({
702
751
  return result || { error: null };
703
752
  };
704
753
 
705
- // Show loading state while checking permissions
706
- if (enforcePermissions && isCheckingPermission) {
754
+ // Show loading state while checking permissions or super admin status
755
+ if (enforcePermissions && (isCheckingSuperAdmin || isCheckingPermission)) {
707
756
  return (
708
757
  <div className="flex items-center justify-center min-h-screen">
709
758
  <div className="text-center">
@@ -114,11 +114,13 @@ vi.mock('../../../hooks/useEventTheme', () => ({
114
114
  const mockIsPermitted = vi.fn().mockResolvedValue(true);
115
115
  const mockCheckPermission = vi.fn().mockResolvedValue(true);
116
116
 
117
+ const mockIsSuperAdmin = vi.fn().mockResolvedValue(false);
118
+
117
119
  vi.mock('../../../rbac/api', () => ({
118
120
  isPermitted: vi.fn().mockResolvedValue(true),
119
121
  getPermissionMap: vi.fn().mockResolvedValue({}),
120
122
  getAccessLevel: vi.fn().mockResolvedValue('viewer'),
121
- isSuperAdmin: vi.fn().mockResolvedValue(false),
123
+ isSuperAdmin: (...args: any[]) => mockIsSuperAdmin(...args),
122
124
  setupRBAC: vi.fn()
123
125
  }));
124
126
 
@@ -232,6 +234,10 @@ describe('PaceAppLayout Security', () => {
232
234
  mockIsPermitted.mockClear();
233
235
  mockIsPermitted.mockResolvedValue(true);
234
236
 
237
+ // Reset super admin mock
238
+ mockIsSuperAdmin.mockClear();
239
+ mockIsSuperAdmin.mockResolvedValue(false);
240
+
235
241
  // Reset RBAC hook mocks
236
242
  mockHasPermissionRBAC.mockClear();
237
243
  mockHasPermissionRBAC.mockResolvedValue(true);
@@ -789,6 +795,80 @@ describe('PaceAppLayout Security', () => {
789
795
  });
790
796
  });
791
797
 
798
+ it('allows super admin to bypass all permission checks', async () => {
799
+ // Mock super admin status
800
+ mockIsSuperAdmin.mockResolvedValueOnce(true);
801
+
802
+ // Mock useCan to return false (would normally deny access)
803
+ mockUseCan.mockReturnValueOnce({
804
+ can: false,
805
+ isLoading: false,
806
+ error: null,
807
+ refetch: vi.fn().mockResolvedValue(undefined),
808
+ });
809
+
810
+ render(
811
+ <TestWrapper>
812
+ <PaceAppLayout
813
+ appName="Test App"
814
+ enforcePermissions={true}
815
+ routePermissions={{
816
+ '/test-path': 'read'
817
+ }}
818
+ />
819
+ </TestWrapper>
820
+ );
821
+
822
+ await waitFor(() => {
823
+ // Super admin should bypass permission checks and see content
824
+ expect(screen.getByTestId('mock-header')).toBeInTheDocument();
825
+ expect(screen.getByTestId('mock-outlet')).toBeInTheDocument();
826
+ // Should NOT show access denied
827
+ expect(screen.queryByText('Access Denied')).not.toBeInTheDocument();
828
+ }, { timeout: 2000 });
829
+ }, { timeout: 3000 });
830
+
831
+ it('does not log strict mode violations for super admins', async () => {
832
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
833
+
834
+ // Mock super admin status
835
+ mockIsSuperAdmin.mockResolvedValueOnce(true);
836
+
837
+ // Mock useCan to return false (would normally trigger violation)
838
+ mockUseCan.mockReturnValueOnce({
839
+ can: false,
840
+ isLoading: false,
841
+ error: null,
842
+ refetch: vi.fn().mockResolvedValue(undefined),
843
+ });
844
+
845
+ render(
846
+ <TestWrapper>
847
+ <PaceAppLayout
848
+ appName="Test App"
849
+ enforcePermissions={true}
850
+ strictMode={true}
851
+ routePermissions={{
852
+ '/test-path': 'read'
853
+ }}
854
+ />
855
+ </TestWrapper>
856
+ );
857
+
858
+ await waitFor(() => {
859
+ // Wait for super admin check to complete
860
+ expect(mockIsSuperAdmin).toHaveBeenCalled();
861
+ });
862
+
863
+ // Should not log strict mode violations for super admins
864
+ const violationLogs = consoleSpy.mock.calls.filter(call =>
865
+ call[0]?.includes('STRICT MODE VIOLATION')
866
+ );
867
+ expect(violationLogs).toHaveLength(0);
868
+
869
+ consoleSpy.mockRestore();
870
+ }, { timeout: 3000 });
871
+
792
872
  it('prevents privilege escalation', async () => {
793
873
  // Test that users cannot escalate their privileges
794
874
  // Create a test wrapper with admin path for privilege escalation test