@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.
- package/dist/{DataTable-QZH6SEUM.js → DataTable-O2COE77K.js} +2 -2
- package/dist/{PublicLoadingSpinner-qqvM-NUe.d.ts → PublicLoadingSpinner-CUAnTvcg.d.ts} +7 -0
- package/dist/{chunk-JDBO5NCG.js → chunk-BJ7MCGY6.js} +66 -18
- package/dist/chunk-BJ7MCGY6.js.map +1 -0
- package/dist/{chunk-TMUNK34W.js → chunk-MOOJ2TK6.js} +37 -9
- package/dist/chunk-MOOJ2TK6.js.map +1 -0
- package/dist/components.d.ts +1 -1
- package/dist/components.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +1 -1
- package/docs/api/classes/ColumnFactory.md +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/classes/StorageUtils.md +1 -1
- package/docs/api/enums/FileCategory.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/DataRecord.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/EventAppRoleData.md +1 -1
- package/docs/api/interfaces/FileDisplayProps.md +1 -1
- package/docs/api/interfaces/FileMetadata.md +1 -1
- package/docs/api/interfaces/FileReference.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadOptions.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/GrantEventAppRoleParams.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 +27 -27
- 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/ProtectedRouteProps.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/RBACLogger.md +1 -1
- package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RoleManagementResult.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/SwitchProps.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/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeReturn.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 +10 -3
- package/docs/api-reference/components.md +24 -0
- package/docs/api-reference/types.md +28 -0
- package/docs/implementation-guides/data-tables.md +93 -10
- package/docs/implementation-guides/permission-enforcement.md +4 -0
- package/docs/rbac/super-admin-guide.md +43 -5
- package/package.json +1 -1
- package/src/components/DataTable/__tests__/DataTable.export.test.tsx +702 -0
- package/src/components/DataTable/components/DataTableCore.tsx +90 -39
- package/src/components/DataTable/index.ts +3 -1
- package/src/components/DataTable/types.ts +68 -0
- package/src/components/PaceAppLayout/PaceAppLayout.tsx +60 -11
- package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +81 -1
- package/dist/chunk-JDBO5NCG.js.map +0 -1
- package/dist/chunk-TMUNK34W.js.map +0 -1
- /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
|
|
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]
|
|
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={
|
|
1025
|
+
onExport={async () => {
|
|
1015
1026
|
try {
|
|
1016
|
-
//
|
|
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
|
|
1030
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
-
|
|
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:
|
|
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
|