@jmruthers/pace-core 0.5.90 → 0.5.92

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 (153) hide show
  1. package/dist/{DataTable-VIP44OB6.js → DataTable-HC5S4RKB.js} +6 -6
  2. package/dist/{PublicLoadingSpinner-BQXD1fbO.d.ts → PublicLoadingSpinner-n74JgA9h.d.ts} +59 -4
  3. package/dist/{UnifiedAuthProvider-6JRTOFPS.js → UnifiedAuthProvider-ZM7VUC45.js} +3 -3
  4. package/dist/{chunk-EWMXLDIX.js → chunk-5LAY74WM.js} +375 -284
  5. package/dist/chunk-5LAY74WM.js.map +1 -0
  6. package/dist/{chunk-G2SCPUKC.js → chunk-6WFM22A4.js} +2 -2
  7. package/dist/{chunk-AIV3VYBQ.js → chunk-AAM57AEU.js} +4 -2
  8. package/dist/chunk-AAM57AEU.js.map +1 -0
  9. package/dist/{chunk-7XBW2P7B.js → chunk-AYC2P377.js} +2 -2
  10. package/dist/{chunk-GD3ENUKD.js → chunk-AZ2QJYKU.js} +3 -3
  11. package/dist/{chunk-G2YT64FA.js → chunk-GP3HU6WS.js} +3 -3
  12. package/dist/{chunk-7NIERLC6.js → chunk-HW5BGOWB.js} +4 -4
  13. package/dist/{chunk-JDPFQV3V.js → chunk-M52CQP5W.js} +4 -4
  14. package/dist/{chunk-JQWSAYZC.js → chunk-OXFOS62D.js} +2 -2
  15. package/dist/{chunk-4DYK5KCK.js → chunk-TZXYSZT3.js} +4 -4
  16. package/dist/{chunk-XZHZYSAK.js → chunk-XIBSVWJW.js} +5 -5
  17. package/dist/components.d.ts +1 -1
  18. package/dist/components.js +10 -8
  19. package/dist/components.js.map +1 -1
  20. package/dist/hooks.js +7 -7
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +13 -11
  23. package/dist/index.js.map +1 -1
  24. package/dist/providers.js +2 -2
  25. package/dist/rbac/index.js +7 -7
  26. package/dist/utils.js +1 -1
  27. package/docs/api/classes/ColumnFactory.md +1 -1
  28. package/docs/api/classes/ErrorBoundary.md +1 -1
  29. package/docs/api/classes/InvalidScopeError.md +1 -1
  30. package/docs/api/classes/MissingUserContextError.md +1 -1
  31. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  32. package/docs/api/classes/PermissionDeniedError.md +1 -1
  33. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  34. package/docs/api/classes/RBACAuditManager.md +1 -1
  35. package/docs/api/classes/RBACCache.md +1 -1
  36. package/docs/api/classes/RBACEngine.md +1 -1
  37. package/docs/api/classes/RBACError.md +1 -1
  38. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  39. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  40. package/docs/api/classes/StorageUtils.md +1 -1
  41. package/docs/api/enums/FileCategory.md +1 -1
  42. package/docs/api/interfaces/AggregateConfig.md +1 -1
  43. package/docs/api/interfaces/ButtonProps.md +1 -1
  44. package/docs/api/interfaces/CardProps.md +1 -1
  45. package/docs/api/interfaces/ColorPalette.md +1 -1
  46. package/docs/api/interfaces/ColorShade.md +1 -1
  47. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  48. package/docs/api/interfaces/DataRecord.md +1 -1
  49. package/docs/api/interfaces/DataTableAction.md +1 -1
  50. package/docs/api/interfaces/DataTableColumn.md +1 -1
  51. package/docs/api/interfaces/DataTableProps.md +1 -1
  52. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  53. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  54. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  55. package/docs/api/interfaces/EventLogoProps.md +1 -1
  56. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  57. package/docs/api/interfaces/FileMetadata.md +1 -1
  58. package/docs/api/interfaces/FileReference.md +1 -1
  59. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  60. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  61. package/docs/api/interfaces/FileUploadProps.md +1 -1
  62. package/docs/api/interfaces/FooterProps.md +1 -1
  63. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  64. package/docs/api/interfaces/InputProps.md +1 -1
  65. package/docs/api/interfaces/LabelProps.md +1 -1
  66. package/docs/api/interfaces/LoginFormProps.md +1 -1
  67. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  68. package/docs/api/interfaces/NavigationContextType.md +1 -1
  69. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  70. package/docs/api/interfaces/NavigationItem.md +1 -1
  71. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  72. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  73. package/docs/api/interfaces/Organisation.md +1 -1
  74. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  75. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  76. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  77. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  78. package/docs/api/interfaces/PaceAppLayoutProps.md +34 -21
  79. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  80. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  81. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  82. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  83. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  84. package/docs/api/interfaces/PaletteData.md +1 -1
  85. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  86. package/docs/api/interfaces/ProtectedRouteProps.md +97 -0
  87. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  88. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  89. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  90. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  91. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  92. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  93. package/docs/api/interfaces/RBACConfig.md +1 -1
  94. package/docs/api/interfaces/RBACLogger.md +1 -1
  95. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  96. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  97. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  98. package/docs/api/interfaces/RouteConfig.md +1 -1
  99. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  100. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  101. package/docs/api/interfaces/StorageConfig.md +1 -1
  102. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  103. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  104. package/docs/api/interfaces/StorageListOptions.md +1 -1
  105. package/docs/api/interfaces/StorageListResult.md +1 -1
  106. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  107. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  108. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  109. package/docs/api/interfaces/StyleImport.md +1 -1
  110. package/docs/api/interfaces/SwitchProps.md +1 -1
  111. package/docs/api/interfaces/ToastActionElement.md +1 -1
  112. package/docs/api/interfaces/ToastProps.md +1 -1
  113. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  114. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  115. package/docs/api/interfaces/UseEventLogoOptions.md +1 -1
  116. package/docs/api/interfaces/UseEventLogoReturn.md +1 -1
  117. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  118. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  119. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  120. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  121. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  122. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  123. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  124. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  125. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  126. package/docs/api/interfaces/UserEventAccess.md +1 -1
  127. package/docs/api/interfaces/UserMenuProps.md +1 -1
  128. package/docs/api/interfaces/UserProfile.md +1 -1
  129. package/docs/api/modules.md +43 -5
  130. package/package.json +1 -1
  131. package/src/components/EventSelector/EventSelector.tsx +19 -1
  132. package/src/components/Header/Header.tsx +52 -15
  133. package/src/components/PaceAppLayout/PaceAppLayout.tsx +4 -0
  134. package/src/components/PaceAppLayout/README.md +30 -0
  135. package/src/components/ProtectedRoute/ProtectedRoute.tsx +224 -0
  136. package/src/components/ProtectedRoute/index.ts +3 -0
  137. package/src/components/index.ts +4 -1
  138. package/src/index.ts +3 -0
  139. package/src/providers/AuthProvider.simplified.tsx +108 -14
  140. package/src/services/EventService.ts +6 -1
  141. package/dist/chunk-AIV3VYBQ.js.map +0 -1
  142. package/dist/chunk-EWMXLDIX.js.map +0 -1
  143. /package/dist/{DataTable-VIP44OB6.js.map → DataTable-HC5S4RKB.js.map} +0 -0
  144. /package/dist/{UnifiedAuthProvider-6JRTOFPS.js.map → UnifiedAuthProvider-ZM7VUC45.js.map} +0 -0
  145. /package/dist/{chunk-G2SCPUKC.js.map → chunk-6WFM22A4.js.map} +0 -0
  146. /package/dist/{chunk-7XBW2P7B.js.map → chunk-AYC2P377.js.map} +0 -0
  147. /package/dist/{chunk-GD3ENUKD.js.map → chunk-AZ2QJYKU.js.map} +0 -0
  148. /package/dist/{chunk-G2YT64FA.js.map → chunk-GP3HU6WS.js.map} +0 -0
  149. /package/dist/{chunk-7NIERLC6.js.map → chunk-HW5BGOWB.js.map} +0 -0
  150. /package/dist/{chunk-JDPFQV3V.js.map → chunk-M52CQP5W.js.map} +0 -0
  151. /package/dist/{chunk-JQWSAYZC.js.map → chunk-OXFOS62D.js.map} +0 -0
  152. /package/dist/{chunk-4DYK5KCK.js.map → chunk-TZXYSZT3.js.map} +0 -0
  153. /package/dist/{chunk-XZHZYSAK.js.map → chunk-XIBSVWJW.js.map} +0 -0
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Features:
11
11
  * - Customizable logo (URL or component)
12
+ * - Clickable logo that routes to dashboard (configurable)
12
13
  * - Navigation menu integration
13
14
  * - User menu with authentication
14
15
  * - Event selector for multi-tenant applications
@@ -20,10 +21,11 @@
20
21
  *
21
22
  * @example
22
23
  * ```tsx
23
- * // Basic header with logo and user menu
24
+ * // Basic header with logo and user menu (logo routes to /dashboard by default)
24
25
  * <Header
25
26
  * logoUrl="/logo.svg"
26
27
  * logoAlt="My App"
28
+ * logoHref="/dashboard"
27
29
  * user={currentUser}
28
30
  * onSignOut={handleSignOut}
29
31
  * />
@@ -31,6 +33,7 @@
31
33
  * // Header with navigation and custom actions
32
34
  * <Header
33
35
  * logo={<CustomLogo />}
36
+ * logoHref="/home"
34
37
  * navItems={[
35
38
  * { id: 'dashboard', label: 'Dashboard', href: '/dashboard' },
36
39
  * { id: 'users', label: 'Users', href: '/users' },
@@ -50,6 +53,7 @@
50
53
  * // Header without event selector
51
54
  * <Header
52
55
  * logoUrl="/logo.svg"
56
+ * logoHref="/dashboard"
53
57
  * showEventSelector={false}
54
58
  * user={currentUser}
55
59
  * onSignOut={handleSignOut}
@@ -58,6 +62,7 @@
58
62
  * // Header with custom user menu
59
63
  * <Header
60
64
  * logoUrl="/logo.svg"
65
+ * logoHref="/home"
61
66
  * userMenu={<CustomUserMenu user={currentUser} />}
62
67
  * showUserMenu={true}
63
68
  * />
@@ -81,6 +86,7 @@
81
86
  */
82
87
 
83
88
  import React from 'react';
89
+ import { Link } from 'react-router-dom';
84
90
  import { User } from '@supabase/supabase-js';
85
91
  import { cn } from '../../utils/cn';
86
92
  import { EventSelector } from '../EventSelector';
@@ -120,6 +126,8 @@ export interface HeaderProps {
120
126
  currentPath?: string;
121
127
  /** Custom navigation handler */
122
128
  onNavigate?: (item: NavigationItem) => void;
129
+ /** URL to navigate to when logo is clicked (e.g., '/dashboard') */
130
+ logoHref?: string;
123
131
  }
124
132
 
125
133
  /**
@@ -131,6 +139,7 @@ export interface HeaderProps {
131
139
  *
132
140
  * Features:
133
141
  * - Customizable logo (URL or custom component)
142
+ * - Clickable logo that automatically routes to dashboard (configurable via logoHref)
134
143
  * - Navigation menu integration with highlighting
135
144
  * - User menu with authentication and password management
136
145
  * - Event selector for multi-tenant applications
@@ -139,9 +148,9 @@ export interface HeaderProps {
139
148
  * - Accessibility compliant with proper ARIA attributes
140
149
  * - Backdrop blur effects for modern UI
141
150
  * - Flexible layout with configurable sections
142
- *
151
+ *
143
152
  * @example
144
- * Basic header with logo and navigation:
153
+ * Basic header with logo and navigation (logo routes to /dashboard when clicked):
145
154
  * ```tsx
146
155
  * import { Header } from '@jmruthers/pace-core';
147
156
  * import { useNavigate, useLocation } from 'react-router-dom';
@@ -160,6 +169,7 @@ export interface HeaderProps {
160
169
  * <Header
161
170
  * logoUrl="/company-logo.svg"
162
171
  * logoAlt="My Company"
172
+ * logoHref="/dashboard"
163
173
  * navItems={navItems}
164
174
  * currentPath={location.pathname}
165
175
  * onNavigate={(item) => navigate(item.href)}
@@ -230,7 +240,8 @@ export function Header({
230
240
  showEventSelector = true,
231
241
  showUserMenu = true,
232
242
  currentPath,
233
- onNavigate
243
+ onNavigate,
244
+ logoHref
234
245
  }: HeaderProps) {
235
246
  return (
236
247
  <header className={cn(
@@ -240,19 +251,45 @@ export function Header({
240
251
  <nav className="px-4 w-[min(var(--app-width),100%)] mx-auto grid grid-cols-[auto_auto_1fr_auto] gap-4 h-full items-center">
241
252
  {/* Logo */}
242
253
  {logo ? (
243
- logo
254
+ logoHref ? (
255
+ <Link to={logoHref} className="cursor-pointer hover:opacity-80 transition-opacity">
256
+ {logo}
257
+ </Link>
258
+ ) : (
259
+ logo
260
+ )
244
261
  ) : logoUrl ? (
245
- <img
246
- src={logoUrl}
247
- alt={logoAlt || 'Logo'}
248
- className="h-[2.15rem] w-auto max-w-[200px] object-contain rounded-md shadow-md bg-transparent"
249
- />
262
+ logoHref ? (
263
+ <Link to={logoHref} className="cursor-pointer hover:opacity-80 transition-opacity">
264
+ <img
265
+ src={logoUrl}
266
+ alt={logoAlt || 'Logo'}
267
+ className="h-[2.15rem] w-auto max-w-[200px] object-contain rounded-md shadow-md bg-transparent"
268
+ />
269
+ </Link>
270
+ ) : (
271
+ <img
272
+ src={logoUrl}
273
+ alt={logoAlt || 'Logo'}
274
+ className="h-[2.15rem] w-auto max-w-[200px] object-contain rounded-md shadow-md bg-transparent"
275
+ />
276
+ )
250
277
  ) : (
251
- <img
252
- src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Ctext x='16' y='20' text-anchor='middle' fill='white' font-family='Arial' font-size='14' font-weight='bold'%3EL%3C/text%3E%3C/svg%3E"
253
- alt={logoAlt || 'Logo'}
254
- className="h-8 w-8 shadow-md"
255
- />
278
+ logoHref ? (
279
+ <Link to={logoHref} className="cursor-pointer hover:opacity-80 transition-opacity">
280
+ <img
281
+ src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Ctext x='16' y='20' text-anchor='middle' fill='white' font-family='Arial' font-size='14' font-weight='bold'%3EL%3C/text%3E%3C/svg%3E"
282
+ alt={logoAlt || 'Logo'}
283
+ className="h-8 w-8 shadow-md"
284
+ />
285
+ </Link>
286
+ ) : (
287
+ <img
288
+ src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' fill='%23000'/%3E%3Ctext x='16' y='20' text-anchor='middle' fill='white' font-family='Arial' font-size='14' font-weight='bold'%3EL%3C/text%3E%3C/svg%3E"
289
+ alt={logoAlt || 'Logo'}
290
+ className="h-8 w-8 shadow-md"
291
+ />
292
+ )
256
293
  )}
257
294
 
258
295
  {/* Navigation Menu */}
@@ -125,6 +125,8 @@ export interface PaceAppLayoutProps {
125
125
  headerActions?: React.ReactNode;
126
126
  /** Custom logo component (overrides default logo) */
127
127
  customLogo?: React.ReactNode;
128
+ /** URL to navigate to when logo is clicked (defaults to '/dashboard') */
129
+ logoHref?: string;
128
130
  /** Custom user menu component (overrides default user menu) */
129
131
  customUserMenu?: React.ReactNode;
130
132
  /** Custom className for the header */
@@ -323,6 +325,7 @@ export function PaceAppLayout({
323
325
  showEventSelector,
324
326
  headerActions,
325
327
  customLogo,
328
+ logoHref = '/dashboard',
326
329
  customUserMenu,
327
330
  headerClassName,
328
331
  showUserMenu = true,
@@ -709,6 +712,7 @@ export function PaceAppLayout({
709
712
  logo={customLogo || undefined}
710
713
  logoUrl={!customLogo ? `/${appName.toLowerCase()}_logo_wide.svg` : undefined}
711
714
  logoAlt={`${appName} Logo`}
715
+ logoHref={logoHref}
712
716
  navItems={filteredMenuItems}
713
717
  actions={headerActions}
714
718
  userMenu={customUserMenu}
@@ -18,6 +18,7 @@ A comprehensive application layout component that provides a consistent structur
18
18
  - Branding support
19
19
  - **Event selector control** - can be hidden for non-event applications
20
20
  - **Header customization** - custom logo, actions, user menu, and styling
21
+ - **Clickable logo** - logo automatically routes to dashboard page (configurable)
21
22
 
22
23
  ## Props
23
24
 
@@ -28,6 +29,7 @@ A comprehensive application layout component that provides a consistent structur
28
29
  | `showEventSelector` | `boolean` | `true` | Show/hide event selector in the header |
29
30
  | `headerActions` | `React.ReactNode` | optional | Custom actions to display in the header (between event selector and user menu) |
30
31
  | `customLogo` | `React.ReactNode` | optional | Custom logo component (overrides default logo) |
32
+ | `logoHref` | `string` | `'/dashboard'` | URL to navigate to when logo is clicked (e.g., '/dashboard', '/home') |
31
33
  | `customUserMenu` | `React.ReactNode` | optional | Custom user menu component (overrides default user menu) |
32
34
  | `headerClassName` | `string` | optional | Custom className for the header |
33
35
  | `showUserMenu` | `boolean` | `true` | Show/hide user menu |
@@ -101,6 +103,7 @@ function App() {
101
103
  appName="My Application"
102
104
  navItems={navItems}
103
105
  customLogo={<CustomLogo />}
106
+ logoHref="/dashboard" // Logo clicks navigate to dashboard
104
107
  headerActions={headerActions}
105
108
  customUserMenu={<CustomUserMenu />}
106
109
  headerClassName="custom-header-styles"
@@ -116,6 +119,27 @@ function App() {
116
119
  }
117
120
  ```
118
121
 
122
+ ### Logo Navigation
123
+
124
+ The logo in the header is automatically clickable and routes to the dashboard page. You can customize the destination:
125
+
126
+ ```tsx
127
+ // Default: routes to '/dashboard'
128
+ <PaceAppLayout appName="My App" />
129
+
130
+ // Custom route: routes to '/home'
131
+ <PaceAppLayout
132
+ appName="My App"
133
+ logoHref="/home"
134
+ />
135
+
136
+ // Event-specific dashboard: routes to current event's dashboard
137
+ <PaceAppLayout
138
+ appName="My App"
139
+ logoHref={`/events/${currentEventId}/dashboard`}
140
+ />
141
+ ```
142
+
119
143
  ### App Layout Without Event Selector (Perfect for TEAM-style apps)
120
144
 
121
145
  ```tsx
@@ -226,6 +250,12 @@ function App() {
226
250
  - Apps needing custom header styling
227
251
  - Applications with context-specific header content
228
252
 
253
+ ### Logo Navigation
254
+ - The logo automatically routes to the dashboard when clicked (default: `/dashboard`)
255
+ - Customize the destination with the `logoHref` prop
256
+ - Works with both default logo and custom logo components
257
+ - Provides visual feedback with hover opacity transition
258
+
229
259
  ## Integration with UnifiedAuthProvider
230
260
 
231
261
  The PaceAppLayout works seamlessly with the UnifiedAuthProvider. When `showEventSelector={false}`, the provider will:
@@ -0,0 +1,224 @@
1
+ /**
2
+ * @file Protected Route Component
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/ProtectedRoute
5
+ * @since 0.6.0
6
+ *
7
+ * A route protection component that handles authentication and optional event selection
8
+ * without creating a chicken-and-egg problem where users cannot see the event selector.
9
+ *
10
+ * Features:
11
+ * - Authentication checking with redirect to login
12
+ * - Session restoration handling
13
+ * - Event loading state management
14
+ * - Smart event selection logic (allows rendering when events exist but none selected)
15
+ * - Optional event requirement (can be disabled for apps that don't need events)
16
+ * - Super admin bypass support
17
+ * - Clear error states for no events available
18
+ *
19
+ * @example
20
+ * Basic protected route:
21
+ * ```tsx
22
+ * import { ProtectedRoute } from '@jmruthers/pace-core';
23
+ * import { Routes, Route } from 'react-router-dom';
24
+ *
25
+ * function App() {
26
+ * return (
27
+ * <Routes>
28
+ * <Route path="/login" element={<LoginPage />} />
29
+ * <Route element={<ProtectedRoute />}>
30
+ * <Route path="/dashboard" element={<DashboardPage />} />
31
+ * </Route>
32
+ * </Routes>
33
+ * );
34
+ * }
35
+ * ```
36
+ *
37
+ * @example
38
+ * Protected route without event requirement:
39
+ * ```tsx
40
+ * <Route element={<ProtectedRoute requireEvent={false} />}>
41
+ * <Route path="/settings" element={<SettingsPage />} />
42
+ * </Route>
43
+ * ```
44
+ *
45
+ * @example
46
+ * Protected route with custom no events message:
47
+ * ```tsx
48
+ * <Route element={
49
+ * <ProtectedRoute
50
+ * requireEvent={true}
51
+ * noEventsFallback={<CustomNoEventsMessage />}
52
+ * />
53
+ * }>
54
+ * <Route path="/dashboard" element={<DashboardPage />} />
55
+ * </Route>
56
+ * ```
57
+ *
58
+ * @accessibility
59
+ * - Proper loading states with screen reader support
60
+ * - Clear error messages
61
+ * - Keyboard navigation support
62
+ *
63
+ * @dependencies
64
+ * - React Router v6 - Routing functionality
65
+ * - useUnifiedAuth - Authentication context
66
+ * - useEvents - Event context
67
+ * - SessionRestorationLoader - Session restoration UI
68
+ * - LoadingSpinner - Loading state UI
69
+ */
70
+
71
+ import React, { useMemo } from 'react';
72
+ import { Navigate, Outlet } from 'react-router-dom';
73
+ import { useUnifiedAuth } from '../../providers/services/UnifiedAuthProvider';
74
+ import { useSessionRestoration } from '../../hooks/useSessionRestoration';
75
+ import { useEvents } from '../../hooks/useEvents';
76
+ import { LoadingSpinner } from '../LoadingSpinner/LoadingSpinner';
77
+ import { SessionRestorationLoader } from '../SessionRestorationLoader';
78
+ import { Alert, AlertDescription, AlertTitle } from '../Alert/Alert';
79
+
80
+ export interface ProtectedRouteProps {
81
+ /**
82
+ * Whether an event is required for routes inside this component.
83
+ * When true, routes will only render if an event is selected or can be selected.
84
+ * When false, routes render regardless of event state.
85
+ * @default true
86
+ */
87
+ requireEvent?: boolean;
88
+
89
+ /**
90
+ * Whether super admins can bypass event requirement.
91
+ * Note: This feature requires additional RBAC setup. For simple bypass, set requireEvent={false} instead.
92
+ * @default false
93
+ * @deprecated Use requireEvent={false} for routes that don't need events
94
+ */
95
+ allowSuperAdminBypass?: boolean;
96
+
97
+ /**
98
+ * Custom component to render when no events are available.
99
+ * If not provided, a default message is shown.
100
+ */
101
+ noEventsFallback?: React.ReactNode;
102
+
103
+ /**
104
+ * Custom component to render while events are loading.
105
+ * If not provided, a default loading spinner is shown.
106
+ */
107
+ loadingFallback?: React.ReactNode;
108
+
109
+ /**
110
+ * Login redirect path when user is not authenticated.
111
+ * @default '/login'
112
+ */
113
+ loginPath?: string;
114
+ }
115
+
116
+ /**
117
+ * ProtectedRoute component that handles authentication and optional event selection.
118
+ *
119
+ * This component solves the chicken-and-egg problem where apps check for `selectedEvent`
120
+ * before rendering, which blocks the event selector (typically in the header) from being visible.
121
+ *
122
+ * Strategy:
123
+ * 1. Check authentication first - redirect to login if not authenticated
124
+ * 2. Allow rendering during event loading - prevents blocking UI
125
+ * 3. If events exist but none selected - allow rendering so selector is visible
126
+ * 4. If no events available - show error message
127
+ * 5. Individual pages should handle "no selected event" state gracefully
128
+ *
129
+ * @param props - Configuration for route protection
130
+ * @returns React element with route protection logic
131
+ */
132
+ export function ProtectedRoute({
133
+ requireEvent = true,
134
+ allowSuperAdminBypass = false,
135
+ noEventsFallback,
136
+ loadingFallback,
137
+ loginPath = '/login'
138
+ }: ProtectedRouteProps) {
139
+ const { isAuthenticated, isLoading } = useUnifiedAuth();
140
+ const { selectedEvent, events, isLoading: eventLoading } = useEvents();
141
+ const sessionRestoration = useSessionRestoration();
142
+
143
+ const isRestoringSession = useMemo(() => {
144
+ return sessionRestoration.isRestoring &&
145
+ !sessionRestoration.restorationComplete &&
146
+ !sessionRestoration.restorationError &&
147
+ !sessionRestoration.hasTimedOut;
148
+ }, [
149
+ sessionRestoration.isRestoring,
150
+ sessionRestoration.restorationComplete,
151
+ sessionRestoration.restorationError,
152
+ sessionRestoration.hasTimedOut
153
+ ]);
154
+
155
+ // Show session restoration loader during restoration
156
+ if (isRestoringSession) {
157
+ return <SessionRestorationLoader />;
158
+ }
159
+
160
+ // Show loading state while auth is being determined
161
+ if (isLoading && !sessionRestoration.hasTimedOut) {
162
+ return loadingFallback || (
163
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
164
+ <LoadingSpinner />
165
+ </div>
166
+ );
167
+ }
168
+
169
+ // Redirect to login if not authenticated
170
+ if (!isAuthenticated) {
171
+ if (sessionRestoration.hasTimedOut || sessionRestoration.restorationError) {
172
+ console.warn('[ProtectedRoute] Session restoration failed, redirecting to login', {
173
+ timedOut: sessionRestoration.hasTimedOut,
174
+ error: sessionRestoration.restorationError?.message
175
+ });
176
+ }
177
+ return <Navigate to={loginPath} replace />;
178
+ }
179
+
180
+ // If event is not required, allow rendering
181
+ if (!requireEvent) {
182
+ return <Outlet />;
183
+ }
184
+
185
+ // Note: Super admin bypass would require useRBAC hook which adds complexity
186
+ // Apps that need super admin access without events should set requireEvent={false}
187
+ // For now, we keep it simple and always require events when requireEvent=true
188
+
189
+ // Allow rendering during event loading - prevents blocking UI while events load
190
+ if (eventLoading) {
191
+ return <Outlet />;
192
+ }
193
+
194
+ // If no events are available, show error message
195
+ if (!events || events.length === 0) {
196
+ return noEventsFallback || (
197
+ <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', padding: '2rem' }}>
198
+ <Alert variant="destructive" className="max-w-md">
199
+ <AlertTitle>No Events Available</AlertTitle>
200
+ <AlertDescription>
201
+ You don't have access to any events. Please contact your administrator if you believe this is an error.
202
+ </AlertDescription>
203
+ </Alert>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ // KEY FIX: Allow rendering when events exist but none selected
209
+ // This allows the event selector (typically in PaceAppLayout header) to be visible
210
+ // Individual pages should handle "no selected event" state gracefully
211
+ // Auto-selection will handle selecting the next event, or user can manually select
212
+
213
+ // If no event selected but events exist, allow rendering
214
+ // The event selector will be visible and user can select, or auto-selection will kick in
215
+ if (!selectedEvent) {
216
+ // Log for debugging - this is expected behavior, not an error
217
+ console.debug('[ProtectedRoute] Events available but none selected - allowing render so selector is visible');
218
+ return <Outlet />;
219
+ }
220
+
221
+ // Event is selected - allow rendering
222
+ return <Outlet />;
223
+ }
224
+
@@ -0,0 +1,3 @@
1
+ export { ProtectedRoute } from './ProtectedRoute';
2
+ export type { ProtectedRouteProps } from './ProtectedRoute';
3
+
@@ -180,9 +180,12 @@ export type { FooterProps } from './Footer';
180
180
  export * from './PublicLayout';
181
181
 
182
182
  // ============================================================================
183
- // SECURITY COMPONENTS (none currently exported)
183
+ // SECURITY COMPONENTS
184
184
  // ============================================================================
185
185
 
186
+ export { ProtectedRoute } from './ProtectedRoute';
187
+ export type { ProtectedRouteProps } from './ProtectedRoute';
188
+
186
189
  // ============================================================================
187
190
  // NAVIGATION COMPONENTS
188
191
  // ============================================================================
package/src/index.ts CHANGED
@@ -166,6 +166,9 @@ export type { PaceAppLayoutProps } from './components/PaceAppLayout/PaceAppLayou
166
166
  export { PaceLoginPage } from './components/PaceLoginPage/PaceLoginPage';
167
167
  export type { PaceLoginPageProps } from './components/PaceLoginPage/PaceLoginPage';
168
168
 
169
+ export { ProtectedRoute } from './components/ProtectedRoute/ProtectedRoute';
170
+ export type { ProtectedRouteProps } from './components/ProtectedRoute/ProtectedRoute';
171
+
169
172
  // UTILITY COMPONENTS
170
173
  export { ErrorBoundary } from './components/ErrorBoundary/ErrorBoundary';
171
174
  export { LoadingSpinner } from './components/LoadingSpinner/LoadingSpinner';
@@ -55,8 +55,10 @@ export interface Event {
55
55
  id: string;
56
56
  event_id: string;
57
57
  name: string;
58
+ event_name?: string;
58
59
  organisation_id: UUID;
59
- start_date?: string;
60
+ event_date?: string; // Date from database for sorting
61
+ start_date?: string; // Alias for event_date
60
62
  end_date?: string;
61
63
  status?: string;
62
64
  settings?: Record<string, any>;
@@ -347,13 +349,59 @@ export function AuthProvider({
347
349
  const orgEvents = (data || []).filter(e => e.organisation_id === selectedOrganisation.id);
348
350
  setEvents(orgEvents);
349
351
 
350
- // Auto-select first event if none selected
352
+ // Auto-select next event in the future by date if none selected
351
353
  if (!selectedEvent && orgEvents.length > 0) {
352
354
  const savedEventId = persistState ? localStorage.getItem(`pace_${appName}_event`) : null;
353
- const eventToSelect = savedEventId
354
- ? orgEvents.find(e => e.event_id === savedEventId) || orgEvents[0]
355
- : orgEvents[0];
356
- setSelectedEvent(eventToSelect);
355
+ let eventToSelect: Event | null = null;
356
+
357
+ if (savedEventId) {
358
+ // Try to restore persisted event
359
+ eventToSelect = orgEvents.find(e => e.event_id === savedEventId) || null;
360
+ }
361
+
362
+ // If no persisted event, select the next event in the future by date
363
+ if (!eventToSelect) {
364
+ const now = new Date();
365
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
366
+ const futureEvents = orgEvents
367
+ .filter((e): e is Event & { event_date: string } => {
368
+ if (!e.event_date) return false;
369
+ const eventDate = new Date(e.event_date);
370
+ const startOfEventDate = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate()).getTime();
371
+ return startOfEventDate >= startOfToday;
372
+ })
373
+ .sort((a, b) => {
374
+ const dateA = new Date(a.event_date);
375
+ const dateB = new Date(b.event_date);
376
+ return dateA.getTime() - dateB.getTime();
377
+ });
378
+
379
+ eventToSelect = futureEvents.length > 0 ? futureEvents[0] : null;
380
+ }
381
+
382
+ // Fallback to most recent past event if no future events found
383
+ if (!eventToSelect && orgEvents.length > 0) {
384
+ const now = new Date();
385
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
386
+ const pastEvents = orgEvents
387
+ .filter((e): e is Event & { event_date: string } => {
388
+ if (!e.event_date) return false;
389
+ const eventDate = new Date(e.event_date);
390
+ const startOfEventDate = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate()).getTime();
391
+ return startOfEventDate < startOfToday;
392
+ })
393
+ .sort((a, b) => {
394
+ const dateA = new Date(a.event_date);
395
+ const dateB = new Date(b.event_date);
396
+ return dateB.getTime() - dateA.getTime(); // Descending order (most recent first)
397
+ });
398
+
399
+ eventToSelect = pastEvents.length > 0 ? pastEvents[0] : orgEvents[0];
400
+ }
401
+
402
+ if (eventToSelect) {
403
+ setSelectedEvent(eventToSelect);
404
+ }
357
405
  }
358
406
  } catch (error) {
359
407
  console.error('[AuthProvider] Failed to load events:', error);
@@ -759,14 +807,60 @@ export function AuthProvider({
759
807
  const orgEvents = (data || []).filter(e => e.organisation_id === selectedOrganisation.id);
760
808
  setEvents(orgEvents);
761
809
 
762
- // Auto-select first event if none selected
763
- if (!selectedEvent && orgEvents.length > 0) {
764
- const savedEventId = persistState ? localStorage.getItem(`pace_${appName}_event`) : null;
765
- const eventToSelect = savedEventId
766
- ? orgEvents.find(e => e.event_id === savedEventId) || orgEvents[0]
767
- : orgEvents[0];
768
- setSelectedEvent(eventToSelect);
769
- }
810
+ // Auto-select next event in the future by date if none selected
811
+ if (!selectedEvent && orgEvents.length > 0) {
812
+ const savedEventId = persistState ? localStorage.getItem(`pace_${appName}_event`) : null;
813
+ let eventToSelect: Event | null = null;
814
+
815
+ if (savedEventId) {
816
+ // Try to restore persisted event
817
+ eventToSelect = orgEvents.find(e => e.event_id === savedEventId) || null;
818
+ }
819
+
820
+ // If no persisted event, select the next event in the future by date
821
+ if (!eventToSelect) {
822
+ const now = new Date();
823
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
824
+ const futureEvents = orgEvents
825
+ .filter((e): e is Event & { event_date: string } => {
826
+ if (!e.event_date) return false;
827
+ const eventDate = new Date(e.event_date);
828
+ const startOfEventDate = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate()).getTime();
829
+ return startOfEventDate >= startOfToday;
830
+ })
831
+ .sort((a, b) => {
832
+ const dateA = new Date(a.event_date);
833
+ const dateB = new Date(b.event_date);
834
+ return dateA.getTime() - dateB.getTime();
835
+ });
836
+
837
+ eventToSelect = futureEvents.length > 0 ? futureEvents[0] : null;
838
+ }
839
+
840
+ // Fallback to most recent past event if no future events found
841
+ if (!eventToSelect && orgEvents.length > 0) {
842
+ const now = new Date();
843
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
844
+ const pastEvents = orgEvents
845
+ .filter((e): e is Event & { event_date: string } => {
846
+ if (!e.event_date) return false;
847
+ const eventDate = new Date(e.event_date);
848
+ const startOfEventDate = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate()).getTime();
849
+ return startOfEventDate < startOfToday;
850
+ })
851
+ .sort((a, b) => {
852
+ const dateA = new Date(a.event_date);
853
+ const dateB = new Date(b.event_date);
854
+ return dateB.getTime() - dateA.getTime(); // Descending order (most recent first)
855
+ });
856
+
857
+ eventToSelect = pastEvents.length > 0 ? pastEvents[0] : orgEvents[0];
858
+ }
859
+
860
+ if (eventToSelect) {
861
+ setSelectedEvent(eventToSelect);
862
+ }
863
+ }
770
864
  } catch (error) {
771
865
  console.error('[AuthProvider] Failed to refresh events:', error);
772
866
  setEventError(error as Error);
@@ -409,11 +409,16 @@ export class EventService extends BaseService implements IEventService {
409
409
  return null;
410
410
  }
411
411
 
412
+ // Get start of today (midnight) to compare dates only (ignore time)
412
413
  const now = new Date();
414
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
415
+
413
416
  const futureEvents = eventsToUse.filter(event => {
414
417
  if (!event.event_date) return false;
415
418
  const eventDate = new Date(event.event_date);
416
- return eventDate >= now;
419
+ // Compare by date only (start of day), not by time
420
+ const startOfEventDate = new Date(eventDate.getFullYear(), eventDate.getMonth(), eventDate.getDate()).getTime();
421
+ return startOfEventDate >= startOfToday;
417
422
  });
418
423
 
419
424
  if (futureEvents.length === 0) {