@jmruthers/pace-core 0.5.115 → 0.5.116

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 (234) hide show
  1. package/dist/{AuthService-CVgsgtaZ.d.ts → AuthService-D4646R4b.d.ts} +9 -4
  2. package/dist/{DataTable-H5KJCAIS.js → DataTable-ZOAKQ3SU.js} +10 -9
  3. package/dist/{UnifiedAuthProvider-KZZUO27W.js → UnifiedAuthProvider-YFN7YGVN.js} +4 -3
  4. package/dist/{api-PKU4PUBO.js → api-TNIBJWLM.js} +3 -3
  5. package/dist/{audit-H4YJJF7R.js → audit-T36HM7IM.js} +2 -2
  6. package/dist/{chunk-SYXOZQ4P.js → chunk-2GJ5GL77.js} +1 -1
  7. package/dist/chunk-2GJ5GL77.js.map +1 -0
  8. package/dist/{chunk-XYRZV7R5.js → chunk-2LM4QQGH.js} +30 -34
  9. package/dist/chunk-2LM4QQGH.js.map +1 -0
  10. package/dist/{chunk-3OGQLOJM.js → chunk-3DBFLLLU.js} +30 -1
  11. package/dist/chunk-3DBFLLLU.js.map +1 -0
  12. package/dist/{chunk-KTHLNIMA.js → chunk-ECOVPXYS.js} +13 -62
  13. package/dist/chunk-ECOVPXYS.js.map +1 -0
  14. package/dist/{chunk-OO3V7W4H.js → chunk-KA3PSVNV.js} +87 -40
  15. package/dist/chunk-KA3PSVNV.js.map +1 -0
  16. package/dist/{chunk-HKWQN44G.js → chunk-KMPWND3F.js} +15 -15
  17. package/dist/{chunk-L36JW4KV.js → chunk-LFS45U62.js} +2 -2
  18. package/dist/{chunk-NEONKMTU.js → chunk-LZYHAL7Y.js} +9 -4
  19. package/dist/{chunk-NEONKMTU.js.map → chunk-LZYHAL7Y.js.map} +1 -1
  20. package/dist/{chunk-BUN7NMV7.js → chunk-O3FTRYEU.js} +2 -2
  21. package/dist/{chunk-F6QB26OS.js → chunk-P3PUOL6B.js} +80 -8
  22. package/dist/chunk-P3PUOL6B.js.map +1 -0
  23. package/dist/{chunk-ZPXWJA4H.js → chunk-PHDAXDHB.js} +131 -5
  24. package/dist/chunk-PHDAXDHB.js.map +1 -0
  25. package/dist/chunk-UJI6WSMD.js +201 -0
  26. package/dist/{chunk-5CDJCTOO.js.map → chunk-UJI6WSMD.js.map} +1 -1
  27. package/dist/{chunk-OUU3SP6I.js → chunk-UKZWNQMB.js} +50 -7
  28. package/dist/{chunk-OUU3SP6I.js.map → chunk-UKZWNQMB.js.map} +1 -1
  29. package/dist/{chunk-7H75SHXZ.js → chunk-VN3OOE35.js} +2 -2
  30. package/dist/{chunk-QKIVSZ2O.js → chunk-WP5I5GLN.js} +2 -2
  31. package/dist/components.d.ts +1 -1
  32. package/dist/components.js +12 -11
  33. package/dist/components.js.map +1 -1
  34. package/dist/hooks.d.ts +1 -1
  35. package/dist/hooks.js +10 -9
  36. package/dist/hooks.js.map +1 -1
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.js +19 -16
  39. package/dist/index.js.map +1 -1
  40. package/dist/providers.d.ts +2 -2
  41. package/dist/providers.js +3 -2
  42. package/dist/rbac/index.d.ts +82 -1
  43. package/dist/rbac/index.js +13 -10
  44. package/dist/{useToast-DVT4dMtf.d.ts → useToast-Cs_g32bg.d.ts} +1 -1
  45. package/dist/utils.js +6 -4
  46. package/dist/utils.js.map +1 -1
  47. package/dist/validation.js +3 -1
  48. package/dist/validation.js.map +1 -1
  49. package/docs/README.md +4 -0
  50. package/docs/api/classes/ColumnFactory.md +1 -1
  51. package/docs/api/classes/ErrorBoundary.md +1 -1
  52. package/docs/api/classes/InvalidScopeError.md +1 -1
  53. package/docs/api/classes/MissingUserContextError.md +1 -1
  54. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  55. package/docs/api/classes/PermissionDeniedError.md +1 -1
  56. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  57. package/docs/api/classes/RBACAuditManager.md +35 -12
  58. package/docs/api/classes/RBACCache.md +1 -1
  59. package/docs/api/classes/RBACEngine.md +1 -1
  60. package/docs/api/classes/RBACError.md +1 -1
  61. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  62. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  63. package/docs/api/classes/StorageUtils.md +1 -1
  64. package/docs/api/enums/FileCategory.md +1 -1
  65. package/docs/api/interfaces/AggregateConfig.md +1 -1
  66. package/docs/api/interfaces/ButtonProps.md +1 -1
  67. package/docs/api/interfaces/CardProps.md +1 -1
  68. package/docs/api/interfaces/ColorPalette.md +1 -1
  69. package/docs/api/interfaces/ColorShade.md +1 -1
  70. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  71. package/docs/api/interfaces/DataRecord.md +1 -1
  72. package/docs/api/interfaces/DataTableAction.md +1 -1
  73. package/docs/api/interfaces/DataTableColumn.md +1 -1
  74. package/docs/api/interfaces/DataTableProps.md +1 -1
  75. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  76. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  77. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  78. package/docs/api/interfaces/EventAppRoleData.md +71 -0
  79. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  80. package/docs/api/interfaces/FileMetadata.md +1 -1
  81. package/docs/api/interfaces/FileReference.md +1 -1
  82. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  83. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  84. package/docs/api/interfaces/FileUploadProps.md +1 -1
  85. package/docs/api/interfaces/FooterProps.md +1 -1
  86. package/docs/api/interfaces/GrantEventAppRoleParams.md +122 -0
  87. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  88. package/docs/api/interfaces/InputProps.md +1 -1
  89. package/docs/api/interfaces/LabelProps.md +1 -1
  90. package/docs/api/interfaces/LoginFormProps.md +1 -1
  91. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  92. package/docs/api/interfaces/NavigationContextType.md +1 -1
  93. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  94. package/docs/api/interfaces/NavigationItem.md +1 -1
  95. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  96. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  97. package/docs/api/interfaces/Organisation.md +1 -1
  98. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  99. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  100. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  101. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  102. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  103. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  104. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  105. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  106. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  107. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  108. package/docs/api/interfaces/PaletteData.md +1 -1
  109. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  110. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  111. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  112. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  113. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  114. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  115. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  116. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  117. package/docs/api/interfaces/RBACConfig.md +1 -1
  118. package/docs/api/interfaces/RBACLogger.md +1 -1
  119. package/docs/api/interfaces/RevokeEventAppRoleParams.md +100 -0
  120. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  121. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  122. package/docs/api/interfaces/RoleManagementResult.md +52 -0
  123. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  124. package/docs/api/interfaces/RouteConfig.md +1 -1
  125. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  126. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  127. package/docs/api/interfaces/StorageConfig.md +1 -1
  128. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  129. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  130. package/docs/api/interfaces/StorageListOptions.md +1 -1
  131. package/docs/api/interfaces/StorageListResult.md +1 -1
  132. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  133. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  134. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  135. package/docs/api/interfaces/StyleImport.md +1 -1
  136. package/docs/api/interfaces/SwitchProps.md +1 -1
  137. package/docs/api/interfaces/ToastActionElement.md +1 -1
  138. package/docs/api/interfaces/ToastProps.md +1 -1
  139. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  140. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  141. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  142. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  143. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  144. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  145. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  146. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  147. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  148. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  149. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  150. package/docs/api/interfaces/UserEventAccess.md +1 -1
  151. package/docs/api/interfaces/UserMenuProps.md +1 -1
  152. package/docs/api/interfaces/UserProfile.md +1 -1
  153. package/docs/api/modules.md +41 -14
  154. package/docs/architecture/rpc-function-standards.md +193 -0
  155. package/package.json +1 -1
  156. package/src/__tests__/TEST_STANDARD.md +244 -2
  157. package/src/components/DataTable/__tests__/a11y.basic.test.tsx +46 -16
  158. package/src/components/DataTable/__tests__/keyboard.test.tsx +276 -217
  159. package/src/components/DataTable/components/DataTableCore.tsx +29 -2
  160. package/src/components/DataTable/components/DataTableToolbar.tsx +3 -2
  161. package/src/components/DataTable/components/EditableRow.tsx +18 -1
  162. package/src/components/DataTable/components/ViewRowModal.tsx +1 -1
  163. package/src/components/DataTable/components/__tests__/AccessDeniedPage.test.tsx +735 -0
  164. package/src/components/DataTable/components/__tests__/BulkOperationsDropdown.test.tsx +572 -0
  165. package/src/components/DataTable/components/__tests__/ColumnVisibilityDropdown.test.tsx +708 -0
  166. package/src/components/DataTable/components/__tests__/DataTableErrorBoundary.test.tsx +451 -0
  167. package/src/components/DataTable/components/__tests__/DataTableModals.test.tsx +456 -0
  168. package/src/components/DataTable/components/__tests__/EditableRow.test.tsx +454 -0
  169. package/src/components/DataTable/components/__tests__/ExpandButton.test.tsx +462 -0
  170. package/src/components/DataTable/components/__tests__/FilterRow.test.tsx +423 -0
  171. package/src/components/DataTable/components/__tests__/GroupHeader.test.tsx +393 -0
  172. package/src/components/DataTable/components/__tests__/GroupingDropdown.test.tsx +617 -0
  173. package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +734 -0
  174. package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +412 -0
  175. package/src/components/DataTable/hooks/useTableHandlers.ts +4 -0
  176. package/src/components/EventSelector/EventSelector.tsx +5 -25
  177. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +12 -7
  178. package/src/components/PaceAppLayout/PaceAppLayout.tsx +4 -0
  179. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +7 -2
  180. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +13 -8
  181. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +109 -100
  182. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +18 -13
  183. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +17 -12
  184. package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +2 -0
  185. package/src/components/PaceLoginPage/PaceLoginPage.tsx +11 -1
  186. package/src/components/PasswordReset/PasswordChangeForm.test.tsx +2 -2
  187. package/src/components/ProtectedRoute/ProtectedRoute.test.tsx +648 -0
  188. package/src/components/ProtectedRoute/ProtectedRoute.tsx +10 -7
  189. package/src/components/PublicLayout/__tests__/PublicErrorBoundary.test.tsx +4 -12
  190. package/src/components/Select/Select.tsx +8 -0
  191. package/src/components/Toast/Toast.tsx +1 -1
  192. package/src/hooks/__tests__/usePublicEvent.simple.test.ts +367 -3
  193. package/src/hooks/__tests__/usePublicFileDisplay.test.ts +916 -0
  194. package/src/hooks/useEventTheme.ts +49 -18
  195. package/src/hooks/usePermissionCache.ts +5 -3
  196. package/src/hooks/useSecureDataAccess.ts +11 -1
  197. package/src/hooks/useToast.ts +1 -1
  198. package/src/providers/services/EventServiceProvider.tsx +15 -8
  199. package/src/rbac/__tests__/cache-invalidation.test.ts +385 -0
  200. package/src/rbac/audit.test.ts +206 -0
  201. package/src/rbac/audit.ts +37 -2
  202. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +26 -23
  203. package/src/rbac/errors.test.ts +340 -0
  204. package/src/rbac/hooks/index.ts +9 -0
  205. package/src/rbac/hooks/useResolvedScope.test.ts +1063 -0
  206. package/src/rbac/hooks/useRoleManagement.test.ts +908 -0
  207. package/src/rbac/hooks/useRoleManagement.ts +255 -0
  208. package/src/services/AuthService.ts +10 -0
  209. package/src/services/EventService.ts +111 -50
  210. package/src/services/__tests__/AuthService.test.ts +1 -1
  211. package/src/services/__tests__/EventService.test.ts +60 -45
  212. package/src/services/interfaces/IEventService.ts +1 -1
  213. package/src/utils/__tests__/deviceFingerprint.unit.test.ts +320 -0
  214. package/src/utils/__tests__/logger.unit.test.ts +398 -0
  215. package/src/utils/__tests__/validation.unit.test.ts +225 -1
  216. package/src/utils/file-reference.test.ts +214 -0
  217. package/dist/chunk-3OGQLOJM.js.map +0 -1
  218. package/dist/chunk-5CDJCTOO.js +0 -190
  219. package/dist/chunk-F6QB26OS.js.map +0 -1
  220. package/dist/chunk-KTHLNIMA.js.map +0 -1
  221. package/dist/chunk-OO3V7W4H.js.map +0 -1
  222. package/dist/chunk-SYXOZQ4P.js.map +0 -1
  223. package/dist/chunk-XYRZV7R5.js.map +0 -1
  224. package/dist/chunk-ZPXWJA4H.js.map +0 -1
  225. package/src/rbac/audit-enhanced.ts +0 -351
  226. /package/dist/{DataTable-H5KJCAIS.js.map → DataTable-ZOAKQ3SU.js.map} +0 -0
  227. /package/dist/{UnifiedAuthProvider-KZZUO27W.js.map → UnifiedAuthProvider-YFN7YGVN.js.map} +0 -0
  228. /package/dist/{api-PKU4PUBO.js.map → api-TNIBJWLM.js.map} +0 -0
  229. /package/dist/{audit-H4YJJF7R.js.map → audit-T36HM7IM.js.map} +0 -0
  230. /package/dist/{chunk-HKWQN44G.js.map → chunk-KMPWND3F.js.map} +0 -0
  231. /package/dist/{chunk-L36JW4KV.js.map → chunk-LFS45U62.js.map} +0 -0
  232. /package/dist/{chunk-BUN7NMV7.js.map → chunk-O3FTRYEU.js.map} +0 -0
  233. /package/dist/{chunk-7H75SHXZ.js.map → chunk-VN3OOE35.js.map} +0 -0
  234. /package/dist/{chunk-QKIVSZ2O.js.map → chunk-WP5I5GLN.js.map} +0 -0
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @file RBAC Role Management Hook
3
+ * @package @jmruthers/pace-core
4
+ * @module RBAC/Hooks
5
+ * @since 2.1.0
6
+ *
7
+ * React hook for managing RBAC roles safely using RPC functions.
8
+ * This hook provides a secure, type-safe interface for granting and revoking roles
9
+ * that ensures proper audit trails and security checks.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * import { useRoleManagement } from '@jmruthers/pace-core/rbac';
14
+ *
15
+ * function UserRolesComponent() {
16
+ * const { revokeEventAppRole, grantEventAppRole, isLoading, error } = useRoleManagement();
17
+ *
18
+ * const handleRevokeRole = async (roleId: string, roleData: EventAppRoleData) => {
19
+ * const result = await revokeEventAppRole({
20
+ * userId: roleData.user_id,
21
+ * organisationId: roleData.organisation_id,
22
+ * eventId: roleData.event_id,
23
+ * appId: roleData.app_id,
24
+ * role: roleData.role
25
+ * });
26
+ *
27
+ * if (result.success) {
28
+ * toast({ title: 'Role revoked successfully' });
29
+ * } else {
30
+ * toast({ title: 'Failed to revoke role', variant: 'destructive' });
31
+ * }
32
+ * };
33
+ *
34
+ * return <button onClick={() => handleRevokeRole(roleId, roleData)}>Revoke Role</button>;
35
+ * }
36
+ * ```
37
+ */
38
+
39
+ import { useState, useCallback } from 'react';
40
+ import { useUnifiedAuth } from '../../providers/services/UnifiedAuthProvider';
41
+ import type { UUID } from '../types';
42
+
43
+ export interface EventAppRoleData {
44
+ user_id: UUID;
45
+ organisation_id: UUID;
46
+ event_id: string;
47
+ app_id: UUID;
48
+ role: 'viewer' | 'participant' | 'planner' | 'event_admin';
49
+ }
50
+
51
+ export interface RevokeEventAppRoleParams extends EventAppRoleData {
52
+ revoked_by?: UUID;
53
+ }
54
+
55
+ export interface GrantEventAppRoleParams extends EventAppRoleData {
56
+ granted_by?: UUID;
57
+ valid_from?: string;
58
+ valid_to?: string | null;
59
+ }
60
+
61
+ export interface RoleManagementResult {
62
+ success: boolean;
63
+ message?: string;
64
+ error?: string;
65
+ roleId?: UUID;
66
+ }
67
+
68
+ export function useRoleManagement() {
69
+ const { user, supabase } = useUnifiedAuth();
70
+ const [isLoading, setIsLoading] = useState(false);
71
+ const [error, setError] = useState<Error | null>(null);
72
+
73
+ if (!supabase) {
74
+ throw new Error('useRoleManagement requires a Supabase client. Ensure UnifiedAuthProvider is configured.');
75
+ }
76
+
77
+ /**
78
+ * Revoke an event app role using the secure RPC function
79
+ *
80
+ * This function uses the `revoke_event_app_role` RPC which:
81
+ * - Runs with SECURITY DEFINER privileges
82
+ * - Includes proper permission checks
83
+ * - Automatically populates audit fields (revoked_by, timestamps)
84
+ * - Complies with Row-Level Security policies
85
+ *
86
+ * @param params - Role revocation parameters
87
+ * @returns Promise resolving to operation result
88
+ */
89
+ const revokeEventAppRole = useCallback(async (
90
+ params: RevokeEventAppRoleParams
91
+ ): Promise<RoleManagementResult> => {
92
+ setIsLoading(true);
93
+ setError(null);
94
+
95
+ try {
96
+ const { data, error: rpcError } = await supabase.rpc('revoke_event_app_role', {
97
+ p_user_id: params.user_id,
98
+ p_organisation_id: params.organisation_id,
99
+ p_event_id: params.event_id,
100
+ p_app_id: params.app_id,
101
+ p_role: params.role,
102
+ p_revoked_by: params.revoked_by || user?.id || undefined
103
+ });
104
+
105
+ if (rpcError) {
106
+ throw new Error(rpcError.message || 'Failed to revoke role');
107
+ }
108
+
109
+ return {
110
+ success: data === true,
111
+ message: data === true ? 'Role revoked successfully' : 'No role found to revoke',
112
+ error: data === false ? 'No matching role found' : undefined
113
+ };
114
+ } catch (err) {
115
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
116
+ setError(err instanceof Error ? err : new Error(errorMessage));
117
+ return {
118
+ success: false,
119
+ error: errorMessage
120
+ };
121
+ } finally {
122
+ setIsLoading(false);
123
+ }
124
+ }, [user?.id]);
125
+
126
+ /**
127
+ * Grant an event app role using the secure RPC function
128
+ *
129
+ * This function uses the `grant_event_app_role` RPC which:
130
+ * - Runs with SECURITY DEFINER privileges
131
+ * - Includes proper permission checks
132
+ * - Automatically populates audit fields (granted_by, timestamps)
133
+ * - Complies with Row-Level Security policies
134
+ *
135
+ * @param params - Role grant parameters
136
+ * @returns Promise resolving to operation result with role ID
137
+ */
138
+ const grantEventAppRole = useCallback(async (
139
+ params: GrantEventAppRoleParams
140
+ ): Promise<RoleManagementResult> => {
141
+ setIsLoading(true);
142
+ setError(null);
143
+
144
+ try {
145
+ const { data, error: rpcError } = await supabase.rpc('grant_event_app_role', {
146
+ p_user_id: params.user_id,
147
+ p_organisation_id: params.organisation_id,
148
+ p_event_id: params.event_id,
149
+ p_app_id: params.app_id,
150
+ p_role: params.role,
151
+ p_granted_by: params.granted_by || user?.id || undefined,
152
+ p_valid_from: params.valid_from,
153
+ p_valid_to: params.valid_to
154
+ });
155
+
156
+ if (rpcError) {
157
+ throw new Error(rpcError.message || 'Failed to grant role');
158
+ }
159
+
160
+ if (!data) {
161
+ return {
162
+ success: false,
163
+ error: 'Failed to grant role - no role ID returned'
164
+ };
165
+ }
166
+
167
+ return {
168
+ success: true,
169
+ message: 'Role granted successfully',
170
+ roleId: data as UUID
171
+ };
172
+ } catch (err) {
173
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
174
+ setError(err instanceof Error ? err : new Error(errorMessage));
175
+ return {
176
+ success: false,
177
+ error: errorMessage
178
+ };
179
+ } finally {
180
+ setIsLoading(false);
181
+ }
182
+ }, [user?.id]);
183
+
184
+ /**
185
+ * Revoke an event app role by role ID (alternative method)
186
+ *
187
+ * This fetches the role by ID first to get the required context (role name, event_id, app_id),
188
+ * then uses the unified `rbac_role_revoke` function to revoke it.
189
+ *
190
+ * @param roleId - The role ID to revoke
191
+ * @returns Promise resolving to operation result
192
+ */
193
+ const revokeRoleById = useCallback(async (
194
+ roleId: UUID
195
+ ): Promise<RoleManagementResult> => {
196
+ setIsLoading(true);
197
+ setError(null);
198
+
199
+ try {
200
+ // First, fetch the role by ID to get the required context
201
+ const { data: roleData, error: fetchError } = await supabase
202
+ .from('rbac_event_app_roles')
203
+ .select('user_id, role, event_id, app_id')
204
+ .eq('id', roleId)
205
+ .single();
206
+
207
+ if (fetchError || !roleData) {
208
+ throw new Error(fetchError?.message || 'Role not found');
209
+ }
210
+
211
+ // Construct context_id in the format required by rbac_role_revoke: "event_id:app_id"
212
+ const contextId = `${roleData.event_id}:${roleData.app_id}`;
213
+
214
+ // Now call rbac_role_revoke with the required parameters
215
+ const { data, error: rpcError } = await supabase.rpc('rbac_role_revoke', {
216
+ p_user_id: roleData.user_id,
217
+ p_role_type: 'event_app',
218
+ p_role_name: roleData.role,
219
+ p_context_id: contextId,
220
+ p_revoked_by: user?.id || undefined
221
+ });
222
+
223
+ if (rpcError) {
224
+ throw new Error(rpcError.message || 'Failed to revoke role');
225
+ }
226
+
227
+ // rbac_role_revoke returns a table with success, message, revoked_count, error_code
228
+ const result = Array.isArray(data) && data.length > 0 ? data[0] : null;
229
+
230
+ return {
231
+ success: result?.success === true,
232
+ message: result?.message || undefined,
233
+ error: result?.success === false ? (result?.message || result?.error_code || 'Unknown error') : undefined
234
+ };
235
+ } catch (err) {
236
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
237
+ setError(err instanceof Error ? err : new Error(errorMessage));
238
+ return {
239
+ success: false,
240
+ error: errorMessage
241
+ };
242
+ } finally {
243
+ setIsLoading(false);
244
+ }
245
+ }, [user?.id, supabase]);
246
+
247
+ return {
248
+ revokeEventAppRole,
249
+ grantEventAppRole,
250
+ revokeRoleById,
251
+ isLoading,
252
+ error
253
+ };
254
+ }
255
+
@@ -416,6 +416,16 @@ export class AuthService extends BaseService implements IAuthService {
416
416
  this.session = session;
417
417
  this.user = session.user ?? null;
418
418
  this.authError = null;
419
+
420
+ // Reset restoration state if valid session arrives after earlier failure
421
+ // This clears stale errors when session eventually succeeds
422
+ const hasTimeoutError = this.sessionRestorationState.restorationError?.name === 'SessionRestorationTimeoutError';
423
+ if (this.sessionRestorationState.isRestoring ||
424
+ this.sessionRestorationState.restorationError ||
425
+ (hasTimeoutError && session)) {
426
+ this.finishSessionRestoration();
427
+ return;
428
+ }
419
429
  }
420
430
 
421
431
  if (this.sessionRestorationState.isRestoring) {
@@ -15,6 +15,7 @@ import { Event } from '../types/unified';
15
15
  import { Organisation } from '../types/organisation';
16
16
  import { DebugLogger } from '../utils/debugLogger';
17
17
  import { applyPalette, clearPalette } from '../theming/runtime';
18
+ import { secureStorage } from '../utils/secureStorage';
18
19
 
19
20
  export class EventService extends BaseService implements IEventService {
20
21
  private events: Event[] = [];
@@ -53,17 +54,40 @@ export class EventService extends BaseService implements IEventService {
53
54
  this.setSelectedEventId = setSelectedEventId;
54
55
  }
55
56
 
57
+ // Helper method to get user-scoped storage key
58
+ private getStorageKey(userId: string | null): string {
59
+ if (!userId) {
60
+ // Return a temporary key that won't match any user
61
+ return 'pace-core-selected-event-no-user';
62
+ }
63
+ return `pace-core-selected-event-${userId}`;
64
+ }
65
+
56
66
  // Update dependencies
57
- updateDependencies(
67
+ async updateDependencies(
58
68
  supabaseClient: SupabaseClient,
59
69
  user: User | null,
60
70
  session: Session | null,
61
71
  appName: string,
62
72
  selectedOrganisation: Organisation | null,
63
73
  setSelectedEventId: (eventId: string | null) => void
64
- ): void {
74
+ ): Promise<void> {
65
75
  const previousOrgId = this.selectedOrganisation?.id;
66
76
  const newOrgId = selectedOrganisation?.id;
77
+ const previousUserId = this.user?.id || null;
78
+ const newUserId = user?.id || null;
79
+
80
+ // If user changed, clear previous user's event selection from storage
81
+ if (previousUserId !== newUserId) {
82
+ if (previousUserId !== null) {
83
+ await this.clearEventSelectionForUser(previousUserId);
84
+ }
85
+ // If user is now null (logout), clear current selection state
86
+ if (newUserId === null) {
87
+ this.selectedEvent = null;
88
+ this.setSelectedEventId?.(null);
89
+ }
90
+ }
67
91
 
68
92
  this.supabaseClient = supabaseClient;
69
93
  this.user = user;
@@ -133,23 +157,27 @@ export class EventService extends BaseService implements IEventService {
133
157
 
134
158
  this.selectedEvent = event;
135
159
  this.setSelectedEventId?.(event.event_id);
136
- this.persistEventSelection(event.event_id);
160
+ // Persist asynchronously (don't await to avoid blocking)
161
+ this.persistEventSelection(event.event_id).catch(error => {
162
+ console.warn('[EventService] Failed to persist event selection:', error);
163
+ });
137
164
  // Reset the user cleared flag when selecting an event
138
165
  this.userClearedEventRef = false;
139
- // Apply theme for the newly selected event
140
- this.updateThemeForSelectedEvent();
166
+ // Theme application is now handled by useEventTheme() hook
167
+ // No need to call updateThemeForSelectedEvent() here
141
168
  } else {
142
169
  this.selectedEvent = null;
143
170
  this.setSelectedEventId?.(null);
144
- // Clear both sessionStorage and localStorage
145
- sessionStorage.removeItem('pace-core-selected-event');
146
- localStorage.removeItem('pace-core-selected-event');
171
+ // Clear from secure storage (don't await to avoid blocking)
172
+ this.clearEventSelection().catch(error => {
173
+ console.warn('[EventService] Failed to clear event selection:', error);
174
+ });
147
175
  // Reset the auto-selection flag when clearing the event
148
176
  this.hasAutoSelectedRef = false;
149
177
  // Mark that user explicitly cleared the event to prevent auto-selection
150
178
  this.userClearedEventRef = true;
151
- // Clear theme when event is cleared
152
- this.updateThemeForSelectedEvent();
179
+ // Theme clearing is now handled by useEventTheme() hook
180
+ // No need to call updateThemeForSelectedEvent() here
153
181
  }
154
182
  this.notify();
155
183
  }
@@ -163,31 +191,31 @@ export class EventService extends BaseService implements IEventService {
163
191
 
164
192
  async loadPersistedEvent(events: Event[]): Promise<boolean> {
165
193
  try {
166
- // Try sessionStorage first (tab-specific)
167
- let persistedEventId = sessionStorage.getItem('pace-core-selected-event');
194
+ const userId = this.user?.id || null;
168
195
 
169
- // Fallback to localStorage if no sessionStorage value (for new tabs)
170
- if (!persistedEventId) {
171
- persistedEventId = localStorage.getItem('pace-core-selected-event');
172
- // If we found a value in localStorage, also store it in sessionStorage for this tab
173
- if (persistedEventId) {
174
- sessionStorage.setItem('pace-core-selected-event', persistedEventId);
175
- }
196
+ // Don't load persisted event if no user is authenticated
197
+ if (!userId) {
198
+ return false;
176
199
  }
177
200
 
201
+ const storageKey = this.getStorageKey(userId);
202
+
203
+ // Retrieve from secure storage (will automatically decrypt)
204
+ const persistedEventId = await secureStorage.getItem(storageKey);
205
+
178
206
  if (persistedEventId && events.length > 0) {
207
+ // Validate that event exists in user's accessible events
179
208
  const persistedEvent = events.find(event => event.event_id === persistedEventId);
180
- if (persistedEvent) {
181
- this.selectedEvent = persistedEvent;
182
- this.setSelectedEventId?.(persistedEventId);
183
- // Skip theme application during session restoration/login
184
- // Theme will be applied by useEventTheme hook once user navigates away from login
185
- this.updateThemeForSelectedEvent(true);
186
- return true;
187
- } else {
188
- // Clear invalid persisted event
189
- sessionStorage.removeItem('pace-core-selected-event');
190
- localStorage.removeItem('pace-core-selected-event');
209
+
210
+ if (persistedEvent) {
211
+ // Use setSelectedEvent() to go through same path as EventSelector
212
+ // This ensures consistent behavior and proper notification
213
+ // Theme will be applied by useEventTheme hook once user navigates away from login
214
+ this.setSelectedEvent(persistedEvent);
215
+ return true;
216
+ } else {
217
+ // Event no longer accessible to user, clear invalid persisted event
218
+ await secureStorage.removeItem(storageKey);
191
219
  }
192
220
  }
193
221
  } catch (error) {
@@ -210,22 +238,26 @@ export class EventService extends BaseService implements IEventService {
210
238
  return await this.loadPersistedEvent(this.events);
211
239
  }
212
240
 
213
- persistEventSelection(eventId: string): void {
241
+ async persistEventSelection(eventId: string): Promise<void> {
214
242
  try {
215
- // Store in sessionStorage for tab-specific persistence
216
- sessionStorage.setItem('pace-core-selected-event', eventId);
217
- // Also store in localStorage as fallback for new tabs
218
- localStorage.setItem('pace-core-selected-event', eventId);
243
+ const userId = this.user?.id || null;
244
+ const storageKey = this.getStorageKey(userId);
245
+
246
+ // Store with encryption using secureStorage
247
+ await secureStorage.setItem(storageKey, eventId, { encrypt: true });
219
248
  } catch (error) {
220
249
  console.warn('[EventService] Failed to persist event selection:', error);
221
250
  }
222
251
  }
223
252
 
224
- clearEventSelection(): void {
253
+ async clearEventSelection(): Promise<void> {
225
254
  try {
226
- // Clear from both storage locations
227
- sessionStorage.removeItem('pace-core-selected-event');
228
- localStorage.removeItem('pace-core-selected-event');
255
+ const userId = this.user?.id || null;
256
+ const storageKey = this.getStorageKey(userId);
257
+
258
+ // Clear from secure storage
259
+ await secureStorage.removeItem(storageKey);
260
+
229
261
  // Clear the selected event
230
262
  this.selectedEvent = null;
231
263
  this.setSelectedEventId?.(null);
@@ -234,20 +266,49 @@ export class EventService extends BaseService implements IEventService {
234
266
  }
235
267
  }
236
268
 
269
+ /**
270
+ * Clear event selection for a specific user (used when user logs out or changes)
271
+ */
272
+ async clearEventSelectionForUser(userId: string | null): Promise<void> {
273
+ try {
274
+ if (!userId) return;
275
+
276
+ const storageKey = this.getStorageKey(userId);
277
+ await secureStorage.removeItem(storageKey);
278
+ } catch (error) {
279
+ console.warn('[EventService] Failed to clear event selection for user:', error);
280
+ }
281
+ }
282
+
237
283
  autoSelectNextEvent(events: Event[]): void {
238
284
  const nextEvent = this.getNextEventByDate(events);
239
285
  if (nextEvent) {
240
- this.selectedEvent = nextEvent;
241
- this.setSelectedEventId?.(nextEvent.event_id);
242
- this.persistEventSelection(nextEvent.event_id);
243
- // Apply theme for auto-selected event
244
- this.updateThemeForSelectedEvent();
286
+ // Use setSelectedEvent() to ensure consistent behavior
287
+ // Theme will be applied by useEventTheme() hook
288
+ this.setSelectedEvent(nextEvent);
245
289
  }
246
290
  }
247
291
 
248
292
  // Lifecycle methods
249
293
  async initialize(): Promise<void> {
250
294
  await super.initialize();
295
+
296
+ // Clean up old global storage keys (backward compatibility)
297
+ // This ensures old data doesn't leak to new users
298
+ try {
299
+ // Remove old plain storage key
300
+ if (typeof sessionStorage !== 'undefined') {
301
+ sessionStorage.removeItem('pace-core-selected-event');
302
+ }
303
+ if (typeof localStorage !== 'undefined') {
304
+ localStorage.removeItem('pace-core-selected-event');
305
+ // Also remove old encrypted format if it exists
306
+ localStorage.removeItem('_sec_pace-core-selected-event');
307
+ }
308
+ } catch (error) {
309
+ console.warn('[EventService] Failed to clean up old storage keys:', error);
310
+ }
311
+
251
312
  // Load persisted event during initialization (don't skip)
252
313
  // This ensures the last viewed event is restored before auto-selection happens
253
314
  await this.fetchEvents(false);
@@ -342,9 +403,9 @@ export class EventService extends BaseService implements IEventService {
342
403
  const nextEvent = this.getNextEventByDate(transformedEvents);
343
404
  if (nextEvent) {
344
405
  this.hasAutoSelectedRef = true;
345
- this.selectedEvent = nextEvent;
346
- this.setSelectedEventId?.(nextEvent.event_id);
347
- this.persistEventSelection(nextEvent.event_id);
406
+ // Use setSelectedEvent() to ensure consistent behavior
407
+ // Theme will be applied by useEventTheme() hook
408
+ this.setSelectedEvent(nextEvent);
348
409
  }
349
410
  }
350
411
  } else {
@@ -353,9 +414,9 @@ export class EventService extends BaseService implements IEventService {
353
414
  const nextEvent = this.getNextEventByDate(transformedEvents);
354
415
  if (nextEvent) {
355
416
  this.hasAutoSelectedRef = true;
356
- this.selectedEvent = nextEvent;
357
- this.setSelectedEventId?.(nextEvent.event_id);
358
- this.persistEventSelection(nextEvent.event_id);
417
+ // Use setSelectedEvent() to ensure consistent behavior
418
+ // Theme will be applied by useEventTheme() hook
419
+ this.setSelectedEvent(nextEvent);
359
420
  }
360
421
  }
361
422
  }
@@ -689,7 +689,7 @@ describe('AuthService', () => {
689
689
  p_session_type: 'login',
690
690
  p_event_id: null,
691
691
  p_app_id: 'app-id-123', // Should be resolved from appName
692
- p_user_agent: expect.any(String), // navigator.userAgent
692
+ // p_user_agent, p_device_fingerprint, p_ip_address may be undefined in test environment
693
693
  }));
694
694
  }
695
695