@jmruthers/pace-core 0.6.4 → 0.6.5

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 (101) hide show
  1. package/dist/{DataTable-E7YQZD7D.js → DataTable-AOVNCPTX.js} +8 -8
  2. package/dist/{PublicPageProvider-DEMpysFR.d.ts → PublicPageProvider-QTFVrL-Z.d.ts} +65 -83
  3. package/dist/{UnifiedAuthProvider-QPXO24B4.js → UnifiedAuthProvider-4SBX4LU5.js} +4 -4
  4. package/dist/{api-6LVZTHDS.js → api-O6HTBX5Y.js} +3 -3
  5. package/dist/{chunk-I6DAQMWX.js → chunk-6COVEUS7.js} +130 -106
  6. package/dist/chunk-6COVEUS7.js.map +1 -0
  7. package/dist/{chunk-36LVWXB2.js → chunk-AFVQODI2.js} +37 -1
  8. package/dist/{chunk-36LVWXB2.js.map → chunk-AFVQODI2.js.map} +1 -1
  9. package/dist/{chunk-3LPHPB62.js → chunk-EFN2EIMK.js} +2 -2
  10. package/dist/{chunk-ATKZM7RX.js → chunk-G7QEZTYQ.js} +31 -31
  11. package/dist/{chunk-ATKZM7RX.js.map → chunk-G7QEZTYQ.js.map} +1 -1
  12. package/dist/{chunk-NN6WWZ5U.js → chunk-HU2C6SSC.js} +29 -18
  13. package/dist/chunk-HU2C6SSC.js.map +1 -0
  14. package/dist/{chunk-AVMLPIM7.js → chunk-IHB5DR3H.js} +102 -51
  15. package/dist/chunk-IHB5DR3H.js.map +1 -0
  16. package/dist/{chunk-7JPAB3T5.js → chunk-IVOFDYWT.js} +364 -208
  17. package/dist/chunk-IVOFDYWT.js.map +1 -0
  18. package/dist/{chunk-6SOIHG6Z.js → chunk-JGRYX5UX.js} +120 -20
  19. package/dist/chunk-JGRYX5UX.js.map +1 -0
  20. package/dist/{chunk-OEWDTMG7.js → chunk-NTM7ZSB6.js} +4 -4
  21. package/dist/chunk-NTM7ZSB6.js.map +1 -0
  22. package/dist/{chunk-5EC5MEWX.js → chunk-RGAWHO7N.js} +4 -4
  23. package/dist/chunk-RGAWHO7N.js.map +1 -0
  24. package/dist/{chunk-YKRAFF5K.js → chunk-UPPMRMYG.js} +3 -3
  25. package/dist/{chunk-YKRAFF5K.js.map → chunk-UPPMRMYG.js.map} +1 -1
  26. package/dist/components.d.ts +2 -3
  27. package/dist/components.js +24 -28
  28. package/dist/components.js.map +1 -1
  29. package/dist/{contextValidator-OOPCLPZW.js → contextValidator-5OGXSPKS.js} +2 -2
  30. package/dist/hooks.d.ts +3 -3
  31. package/dist/hooks.js +41 -139
  32. package/dist/hooks.js.map +1 -1
  33. package/dist/index.d.ts +27 -18
  34. package/dist/index.js +41 -50
  35. package/dist/index.js.map +1 -1
  36. package/dist/providers.js +3 -3
  37. package/dist/rbac/index.d.ts +16 -9
  38. package/dist/rbac/index.js +6 -6
  39. package/dist/{usePublicRouteParams-i3qtoBgg.d.ts → usePublicRouteParams-ClnV4tnv.d.ts} +8 -8
  40. package/dist/utils.js +1 -1
  41. package/docs/api/modules.md +210 -100
  42. package/package.json +1 -2
  43. package/scripts/validate-master.js +1 -1
  44. package/src/components/DataTable/__tests__/keyboard.test.tsx +15 -2
  45. package/src/components/DataTable/components/ImportModal.tsx +4 -6
  46. package/src/components/DataTable/components/ViewRowModal.tsx +4 -4
  47. package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +455 -96
  48. package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +122 -58
  49. package/src/components/DataTable/core/DataTableContext.tsx +1 -1
  50. package/src/components/DateTimeField/DateTimeField.tsx +17 -19
  51. package/src/components/DateTimeField/README.md +5 -2
  52. package/src/components/Dialog/Dialog.test.tsx +248 -228
  53. package/src/components/Dialog/Dialog.tsx +455 -325
  54. package/src/components/Dialog/index.ts +3 -3
  55. package/src/components/FileDisplay/FileDisplay.test.tsx +41 -0
  56. package/src/components/FileDisplay/FileDisplay.tsx +5 -5
  57. package/src/components/Form/Form.test.tsx +3 -2
  58. package/src/components/Form/Form.tsx +4 -5
  59. package/src/components/InactivityWarningModal/InactivityWarningModal.test.tsx +28 -28
  60. package/src/components/InactivityWarningModal/InactivityWarningModal.tsx +40 -54
  61. package/src/components/LoginForm/LoginForm.tsx +2 -2
  62. package/src/components/NavigationMenu/NavigationMenu.tsx +2 -2
  63. package/src/components/PaceAppLayout/PaceAppLayout.tsx +32 -39
  64. package/src/components/PaceAppLayout/README.md +10 -9
  65. package/src/components/PaceAppLayout/test-setup.tsx +40 -31
  66. package/src/components/PasswordChange/PasswordChangeForm.test.tsx +61 -0
  67. package/src/components/PasswordChange/PasswordChangeForm.tsx +20 -13
  68. package/src/components/PublicLayout/PublicLayout.test.tsx +7 -3
  69. package/src/components/PublicLayout/PublicPageLayout.tsx +5 -8
  70. package/src/components/UserMenu/UserMenu.test.tsx +38 -6
  71. package/src/components/UserMenu/UserMenu.tsx +36 -34
  72. package/src/components/index.ts +3 -4
  73. package/src/hooks/useEventTheme.ts +4 -4
  74. package/src/hooks/useEvents.ts +11 -7
  75. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  76. package/src/hooks/useOrganisationPermissions.ts +4 -4
  77. package/src/hooks/useOrganisations.ts +13 -7
  78. package/src/index.ts +11 -1
  79. package/src/rbac/README.md +20 -20
  80. package/src/rbac/hooks/useRBAC.test.ts +21 -3
  81. package/src/rbac/hooks/useRBAC.ts +4 -3
  82. package/src/rbac/hooks/useResourcePermissions.test.ts +125 -30
  83. package/src/rbac/hooks/useResourcePermissions.ts +57 -29
  84. package/src/rbac/permissions.ts +17 -17
  85. package/src/rbac/utils/contextValidator.ts +36 -0
  86. package/src/services/AuthService.ts +2 -5
  87. package/src/services/InactivityService.ts +139 -58
  88. package/src/styles/core.css +4 -0
  89. package/src/utils/formatting/formatTime.test.ts +3 -2
  90. package/dist/chunk-5EC5MEWX.js.map +0 -1
  91. package/dist/chunk-6SOIHG6Z.js.map +0 -1
  92. package/dist/chunk-7JPAB3T5.js.map +0 -1
  93. package/dist/chunk-AVMLPIM7.js.map +0 -1
  94. package/dist/chunk-I6DAQMWX.js.map +0 -1
  95. package/dist/chunk-NN6WWZ5U.js.map +0 -1
  96. package/dist/chunk-OEWDTMG7.js.map +0 -1
  97. /package/dist/{DataTable-E7YQZD7D.js.map → DataTable-AOVNCPTX.js.map} +0 -0
  98. /package/dist/{UnifiedAuthProvider-QPXO24B4.js.map → UnifiedAuthProvider-4SBX4LU5.js.map} +0 -0
  99. /package/dist/{api-6LVZTHDS.js.map → api-O6HTBX5Y.js.map} +0 -0
  100. /package/dist/{chunk-3LPHPB62.js.map → chunk-EFN2EIMK.js.map} +0 -0
  101. /package/dist/{contextValidator-OOPCLPZW.js.map → contextValidator-5OGXSPKS.js.map} +0 -0
@@ -46,7 +46,7 @@ import { useOrganisations } from '../../hooks/useOrganisations';
46
46
  import { useEvents } from '../../hooks/useEvents';
47
47
  import { useResolvedScope } from './useResolvedScope';
48
48
  import { useCan } from './usePermissions';
49
- import type { Scope } from '../types';
49
+ import type { Scope, Permission } from '../types';
50
50
 
51
51
  export interface UseResourcePermissionsOptions {
52
52
  /** Whether to check read permissions (default: false) */
@@ -80,15 +80,22 @@ export interface ResourcePermissions {
80
80
  * and provides a simple API for permission checking.
81
81
  *
82
82
  * **Page Permission Support:**
83
- * When an `appId` is available in the resolved scope, the resource name is passed
84
- * as `pageId` to enable page-based permission checks. This allows the hook to work
85
- * with both resource-based permissions (when appId is not available) and page-based
86
- * permissions (when appId is available and the resource is a registered page).
83
+ * When an `appId` is available in the resolved scope, the hook automatically:
84
+ * 1. Waits for scope resolution to complete (including `appId` being set)
85
+ * 2. Constructs permission strings with the `page.` prefix (e.g., `create:page.planning`)
86
+ * 3. Passes the resource name as `pageId` to enable page-based permission checks
87
87
  *
88
- * The RPC function `rbac_check_permission_simplified` will automatically resolve
89
- * the page name to a page ID and check page permissions if the resource matches
90
- * a registered page in `rbac_app_pages`. If the resource is not a registered page,
91
- * it will fall back to resource-based permission checking.
88
+ * This ensures permission strings match the format returned by `rbac_permissions_get`
89
+ * (e.g., `create:page.planning`) rather than resource-based format (e.g., `create:planning`).
90
+ *
91
+ * **Scope Resolution Timing:**
92
+ * The hook waits for scope resolution to complete before constructing permission strings.
93
+ * This prevents timing issues where permission checks use the wrong format (e.g., `delete:planning`
94
+ * instead of `delete:page.planning`) when `appId` is not yet available in the scope.
95
+ *
96
+ * The RPC function `rbac_check_permission_simplified` will resolve the page name to a page ID
97
+ * and check page permissions if the resource matches a registered page in `rbac_app_pages`.
98
+ * If the resource is not a registered page, it will fall back to resource-based permission checking.
92
99
  *
93
100
  * @param resource - The resource name (e.g., 'contacts', 'risks', 'planning')
94
101
  * Can be a resource name or a page name registered in rbac_app_pages
@@ -157,26 +164,42 @@ export function useResourcePermissions(
157
164
  selectedEventId: selectedEvent?.event_id || null
158
165
  });
159
166
 
160
- // Create fallback scope if resolvedScope is not available
167
+ // CRITICAL FIX: Only use resolvedScope when it's available (not during loading)
168
+ // This ensures we wait for appId to be resolved before constructing permission strings
169
+ // If resolvedScope is null (still loading), we can't determine if we should use page permissions
170
+ // so we must wait for scope resolution to complete
161
171
  const scope: Scope = resolvedScope || {
162
172
  organisationId: selectedOrganisation?.id || '',
163
173
  eventId: selectedEvent?.event_id || undefined,
164
174
  appId: undefined
165
175
  };
166
176
 
167
- // If we have an appId in scope, pass the resource name as pageId to enable page permission checks
168
- // The RPC function rbac_check_permission_simplified will resolve the page name to a page ID
169
- // and check page permissions if the resource is a registered page
170
- // This allows useResourcePermissions to work with both resource-based and page-based permissions
171
- const pageId = scope.appId ? resource : undefined;
177
+ // CRITICAL FIX: Only use page permissions when appId is actually available in resolvedScope
178
+ // If scope is still loading (resolvedScope is null), we can't know if appId will be available
179
+ // so we must wait for scope resolution before constructing page permission strings
180
+ // This prevents using wrong permission format (delete:planning instead of delete:page.planning)
181
+ const hasAppId = !!resolvedScope?.appId;
182
+ const pageId = hasAppId ? resource : undefined;
183
+
184
+ // When appId is available in resolved scope, construct permission strings with page. prefix
185
+ // This matches the format that rbac_permissions_get returns (e.g., 'create:page.planning')
186
+ // and ensures consistent permission checking for page-based resources
187
+ // IMPORTANT: Only use page format when appId is actually resolved, not during loading
188
+ const isPagePermission = hasAppId && !!pageId;
189
+ const createPermission = isPagePermission ? `create:page.${resource}` : `create:${resource}`;
190
+ const updatePermission = isPagePermission ? `update:page.${resource}` : `update:${resource}`;
191
+ const deletePermission = isPagePermission ? `delete:page.${resource}` : `delete:${resource}`;
192
+ const readPermission = isPagePermission ? `read:page.${resource}` : `read:${resource}`;
172
193
 
173
194
  // Permission checks for create, update, delete
174
195
  // Pass null for super admin status (not checked yet - hook will check if needed)
175
196
  // PERFORMANCE: These hooks will each check super admin separately - could be optimized in future
197
+ // CRITICAL: useCan will wait for appId when pageId is provided (it checks needsAppIdForPageName)
198
+ // But we must ensure permission strings are correct before calling useCan
176
199
  const { can: canCreateResult, isLoading: createLoading, error: createError } = useCan(
177
200
  user?.id || '',
178
201
  scope,
179
- `create:${resource}` as const,
202
+ createPermission as Permission,
180
203
  pageId, // Pass resource name as pageId when appId is available to enable page permission checks
181
204
  true, // useCache
182
205
  null, // precomputedSuperAdmin - not checked yet
@@ -186,7 +209,7 @@ export function useResourcePermissions(
186
209
  const { can: canUpdateResult, isLoading: updateLoading, error: updateError } = useCan(
187
210
  user?.id || '',
188
211
  scope,
189
- `update:${resource}` as const,
212
+ updatePermission as Permission,
190
213
  pageId, // Pass resource name as pageId when appId is available to enable page permission checks
191
214
  true, // useCache
192
215
  null, // precomputedSuperAdmin - not checked yet
@@ -196,7 +219,7 @@ export function useResourcePermissions(
196
219
  const { can: canDeleteResult, isLoading: deleteLoading, error: deleteError } = useCan(
197
220
  user?.id || '',
198
221
  scope,
199
- `delete:${resource}` as const,
222
+ deletePermission as Permission,
200
223
  pageId, // Pass resource name as pageId when appId is available to enable page permission checks
201
224
  true, // useCache
202
225
  null, // precomputedSuperAdmin - not checked yet
@@ -207,7 +230,7 @@ export function useResourcePermissions(
207
230
  const { can: canReadResult, isLoading: readLoading, error: readError } = useCan(
208
231
  user?.id || '',
209
232
  scope,
210
- `read:${resource}` as const,
233
+ readPermission as Permission,
211
234
  pageId, // Pass resource name as pageId when appId is available to enable page permission checks
212
235
  true, // useCache
213
236
  null, // precomputedSuperAdmin - not checked yet
@@ -215,9 +238,14 @@ export function useResourcePermissions(
215
238
  );
216
239
 
217
240
  // Aggregate loading states - any permission check or scope resolution loading
241
+ // CRITICAL: When requireScope is true, we must wait for scope resolution to complete
242
+ // so we can determine the correct permission format (page vs resource permissions)
243
+ // This prevents using wrong permission format (delete:planning instead of delete:page.planning)
218
244
  const isLoading = useMemo(() => {
219
- return scopeLoading || createLoading || updateLoading || deleteLoading || (enableRead && readLoading);
220
- }, [scopeLoading, createLoading, updateLoading, deleteLoading, readLoading, enableRead]);
245
+ // If scope resolution is required, wait for it to complete
246
+ const waitingForScope = requireScope && scopeLoading;
247
+ return waitingForScope || createLoading || updateLoading || deleteLoading || (enableRead && readLoading);
248
+ }, [scopeLoading, requireScope, createLoading, updateLoading, deleteLoading, readLoading, enableRead]);
221
249
 
222
250
  // Aggregate errors - prefer scope error, then any permission error
223
251
  const error = useMemo(() => {
@@ -239,19 +267,19 @@ export function useResourcePermissions(
239
267
  if (res !== resource) {
240
268
  return false;
241
269
  }
242
- return canCreateResult;
270
+ return canCreateResult; // canCreateResult is already the boolean 'can' value from useCan
243
271
  },
244
272
  canUpdate: (res: string) => {
245
273
  if (res !== resource) {
246
274
  return false;
247
275
  }
248
- return canUpdateResult;
276
+ return canUpdateResult; // canUpdateResult is already the boolean 'can' value from useCan
249
277
  },
250
278
  canDelete: (res: string) => {
251
279
  if (res !== resource) {
252
280
  return false;
253
281
  }
254
- return canDeleteResult;
282
+ return canDeleteResult; // canDeleteResult is already the boolean 'can' value from useCan
255
283
  },
256
284
  canRead: (res: string) => {
257
285
  if (!enableRead) {
@@ -260,17 +288,17 @@ export function useResourcePermissions(
260
288
  if (res !== resource) {
261
289
  return false;
262
290
  }
263
- return canReadResult;
291
+ return canReadResult; // canReadResult is already the boolean 'can' value from useCan
264
292
  },
265
293
  scope,
266
294
  isLoading,
267
295
  error
268
296
  }), [
269
297
  resource,
270
- canCreateResult,
271
- canUpdateResult,
272
- canDeleteResult,
273
- canReadResult,
298
+ canCreateResult, // This is already the boolean 'can' value
299
+ canUpdateResult, // This is already the boolean 'can' value
300
+ canDeleteResult, // This is already the boolean 'can' value
301
+ canReadResult, // This is already the boolean 'can' value
274
302
  enableRead,
275
303
  scope,
276
304
  isLoading,
@@ -106,35 +106,35 @@ export const EVENT_APP_PERMISSIONS = {
106
106
  // ============================================================================
107
107
 
108
108
  export const PAGE_PERMISSIONS = {
109
- // General page access
109
+ // General page access (generic - used for wildcard checks)
110
110
  READ_PAGE: 'read:page' as Permission,
111
111
  CREATE_PAGE: 'create:page' as Permission,
112
112
  UPDATE_PAGE: 'update:page' as Permission,
113
113
  DELETE_PAGE: 'delete:page' as Permission,
114
114
 
115
115
  // Admin pages
116
- READ_ADMIN: 'read:admin' as Permission,
117
- CREATE_ADMIN: 'create:admin' as Permission,
118
- UPDATE_ADMIN: 'update:admin' as Permission,
119
- DELETE_ADMIN: 'delete:admin' as Permission,
116
+ READ_ADMIN: 'read:page.admin' as Permission,
117
+ CREATE_ADMIN: 'create:page.admin' as Permission,
118
+ UPDATE_ADMIN: 'update:page.admin' as Permission,
119
+ DELETE_ADMIN: 'delete:page.admin' as Permission,
120
120
 
121
121
  // Dashboard pages
122
- READ_DASHBOARD: 'read:dashboard' as Permission,
123
- CREATE_DASHBOARD: 'create:dashboard' as Permission,
124
- UPDATE_DASHBOARD: 'update:dashboard' as Permission,
125
- DELETE_DASHBOARD: 'delete:dashboard' as Permission,
122
+ READ_DASHBOARD: 'read:page.dashboard' as Permission,
123
+ CREATE_DASHBOARD: 'create:page.dashboard' as Permission,
124
+ UPDATE_DASHBOARD: 'update:page.dashboard' as Permission,
125
+ DELETE_DASHBOARD: 'delete:page.dashboard' as Permission,
126
126
 
127
127
  // Settings pages
128
- READ_SETTINGS: 'read:settings' as Permission,
129
- CREATE_SETTINGS: 'create:settings' as Permission,
130
- UPDATE_SETTINGS: 'update:settings' as Permission,
131
- DELETE_SETTINGS: 'delete:settings' as Permission,
128
+ READ_SETTINGS: 'read:page.settings' as Permission,
129
+ CREATE_SETTINGS: 'create:page.settings' as Permission,
130
+ UPDATE_SETTINGS: 'update:page.settings' as Permission,
131
+ DELETE_SETTINGS: 'delete:page.settings' as Permission,
132
132
 
133
133
  // Reports pages
134
- READ_REPORTS: 'read:reports' as Permission,
135
- CREATE_REPORTS: 'create:reports' as Permission,
136
- UPDATE_REPORTS: 'update:reports' as Permission,
137
- DELETE_REPORTS: 'delete:reports' as Permission,
134
+ READ_REPORTS: 'read:page.reports' as Permission,
135
+ CREATE_REPORTS: 'create:page.reports' as Permission,
136
+ UPDATE_REPORTS: 'update:page.reports' as Permission,
137
+ DELETE_REPORTS: 'delete:page.reports' as Permission,
138
138
  } as const;
139
139
 
140
140
  // ============================================================================
@@ -89,7 +89,19 @@ export class ContextValidator {
89
89
  if (effectiveScopeType === 'both') {
90
90
  // For 'both' pages, we need at least one context (org or event)
91
91
  // Both will be checked during permission evaluation
92
+ // For PORTAL/ADMIN apps, both contexts are optional
92
93
  if (!scope.organisationId && !scope.eventId) {
94
+ if (allowsOptionalContexts(appName)) {
95
+ return {
96
+ isValid: true,
97
+ resolvedScope: {
98
+ organisationId: undefined,
99
+ eventId: undefined,
100
+ appId: scope.appId
101
+ },
102
+ error: null
103
+ };
104
+ }
93
105
  return {
94
106
  isValid: false,
95
107
  resolvedScope: null,
@@ -123,6 +135,18 @@ export class ContextValidator {
123
135
  // Handle 'event' scope - requires event context
124
136
  if (effectiveScopeType === 'event') {
125
137
  if (!scope.eventId) {
138
+ // For PORTAL/ADMIN apps, event context is optional
139
+ if (allowsOptionalContexts(appName)) {
140
+ return {
141
+ isValid: true,
142
+ resolvedScope: {
143
+ organisationId: scope.organisationId,
144
+ eventId: undefined,
145
+ appId: scope.appId
146
+ },
147
+ error: null
148
+ };
149
+ }
126
150
  return {
127
151
  isValid: false,
128
152
  resolvedScope: null,
@@ -167,6 +191,18 @@ export class ContextValidator {
167
191
  // Handle 'organisation' scope - requires organisation context
168
192
  if (effectiveScopeType === 'organisation') {
169
193
  if (!scope.organisationId) {
194
+ // For PORTAL/ADMIN apps, organisation context is optional
195
+ if (allowsOptionalContexts(appName)) {
196
+ return {
197
+ isValid: true,
198
+ resolvedScope: {
199
+ organisationId: undefined,
200
+ eventId: scope.eventId,
201
+ appId: scope.appId
202
+ },
203
+ error: null
204
+ };
205
+ }
170
206
  return {
171
207
  isValid: false,
172
208
  resolvedScope: null,
@@ -57,11 +57,8 @@ export class AuthService extends BaseService implements IAuthService {
57
57
 
58
58
  // Auth state getters
59
59
  getUser(): User | null {
60
- if (this.user) {
61
- logger.debug('AuthService', `getUser() [ID:${this.instanceId}] returning user: ${this.user.id}`);
62
- } else {
63
- logger.debug('AuthService', `getUser() [ID:${this.instanceId}] returning null`);
64
- }
60
+ // Removed debug logging - getUser() is called frequently and state changes
61
+ // are already logged in the auth state change handler
65
62
  return this.user;
66
63
  }
67
64
 
@@ -291,10 +291,10 @@ export class InactivityService extends BaseService implements IInactivityService
291
291
  private setupEventHandlers(): void {
292
292
  if (typeof window === 'undefined') return;
293
293
 
294
- let idleTimer: NodeJS.Timeout | null = null;
295
294
  let warningTimer: NodeJS.Timeout | null = null;
295
+ let logoutTimer: NodeJS.Timeout | null = null;
296
+ let countdownInterval: NodeJS.Timeout | null = null;
296
297
  let lastActivity = Date.now();
297
- let pollInterval: NodeJS.Timeout | null = null;
298
298
 
299
299
  // Store previous state for comparison
300
300
  let prevIsIdle = false;
@@ -303,19 +303,29 @@ export class InactivityService extends BaseService implements IInactivityService
303
303
  let prevInactivityTimeRemaining = 0;
304
304
  let prevTimeRemaining = this.idleTimeoutMs;
305
305
 
306
- // Poll every 10 seconds to check for state changes
307
- const pollInactivityState = () => {
306
+ // Calculate time until warning and logout
307
+ const getTimeUntilWarning = () => {
308
+ const timeSinceActivity = Date.now() - lastActivity;
309
+ return Math.max(0, (this.idleTimeoutMs - this.warnBeforeMs) - timeSinceActivity);
310
+ };
311
+
312
+ const getTimeUntilLogout = () => {
313
+ const timeSinceActivity = Date.now() - lastActivity;
314
+ return Math.max(0, this.idleTimeoutMs - timeSinceActivity);
315
+ };
316
+
317
+ // Update state and notify if changed
318
+ const updateState = (forceNotify = false) => {
308
319
  const now = Date.now();
309
320
  const timeSinceActivity = now - lastActivity;
310
321
  const timeUntilIdle = this.idleTimeoutMs - timeSinceActivity;
311
- const timeUntilWarning = (this.idleTimeoutMs - this.warnBeforeMs) - timeSinceActivity;
312
322
 
313
- // Calculate new state values based on time since last activity
314
- const newIsIdle = timeSinceActivity >= (this.idleTimeoutMs - this.warnBeforeMs);
315
- const newShowWarning = newIsIdle && timeSinceActivity < this.idleTimeoutMs;
323
+ // Calculate new state values
324
+ const newIsIdle = timeSinceActivity >= this.idleTimeoutMs;
325
+ const newShowWarning = timeSinceActivity >= (this.idleTimeoutMs - this.warnBeforeMs) && !newIsIdle;
316
326
  const newShowInactivityWarning = newShowWarning;
317
327
  const newInactivityTimeRemaining = newShowWarning
318
- ? Math.ceil((this.idleTimeoutMs - timeSinceActivity) / 1000)
328
+ ? Math.ceil(timeUntilIdle / 1000)
319
329
  : 0;
320
330
  const newTimeRemaining = Math.max(0, timeUntilIdle);
321
331
 
@@ -324,11 +334,11 @@ export class InactivityService extends BaseService implements IInactivityService
324
334
  prevIsIdle !== newIsIdle ||
325
335
  prevShowWarning !== newShowWarning ||
326
336
  prevShowInactivityWarning !== newShowInactivityWarning ||
327
- prevInactivityTimeRemaining !== newInactivityTimeRemaining ||
337
+ (newShowWarning && prevInactivityTimeRemaining !== newInactivityTimeRemaining) ||
328
338
  prevTimeRemaining !== newTimeRemaining;
329
339
 
330
- // Only update and notify if state changed
331
- if (stateChanged) {
340
+ // Only update and notify if state changed or forced
341
+ if (stateChanged || forceNotify) {
332
342
  this._isIdle = newIsIdle;
333
343
  this._showWarning = newShowWarning;
334
344
  this._showInactivityWarning = newShowInactivityWarning;
@@ -342,76 +352,147 @@ export class InactivityService extends BaseService implements IInactivityService
342
352
  prevInactivityTimeRemaining = newInactivityTimeRemaining;
343
353
  prevTimeRemaining = newTimeRemaining;
344
354
 
345
- this.notify();
355
+ if (stateChanged) {
356
+ this.notify();
357
+ }
346
358
 
347
359
  // Handle idle logout if needed
348
- if (newIsIdle && timeSinceActivity >= this.idleTimeoutMs) {
360
+ if (newIsIdle) {
349
361
  this.handleIdleLogout();
350
362
  }
351
363
  }
352
-
353
- // Update timers based on current state
354
- if (idleTimer) {
355
- clearTimeout(idleTimer);
356
- idleTimer = null;
357
- }
358
-
364
+ };
365
+
366
+ // Schedule next state check based on current state
367
+ const scheduleNextCheck = () => {
368
+ // Clear existing timers
359
369
  if (warningTimer) {
360
370
  clearTimeout(warningTimer);
361
371
  warningTimer = null;
362
372
  }
363
373
 
364
- // Set up timers for next state transitions
365
- if (!newIsIdle && timeUntilIdle > 0) {
366
- idleTimer = setTimeout(() => {
367
- pollInactivityState();
368
- }, Math.min(10000, timeUntilIdle));
374
+ if (logoutTimer) {
375
+ clearTimeout(logoutTimer);
376
+ logoutTimer = null;
369
377
  }
370
378
 
371
- if (newShowWarning && timeUntilIdle > 0) {
372
- warningTimer = setTimeout(() => {
379
+ if (countdownInterval) {
380
+ clearInterval(countdownInterval);
381
+ countdownInterval = null;
382
+ }
383
+
384
+ const timeUntilWarning = getTimeUntilWarning();
385
+ const timeUntilLogout = getTimeUntilLogout();
386
+ const timeSinceActivity = Date.now() - lastActivity;
387
+
388
+ // If already idle, nothing to schedule
389
+ if (timeSinceActivity >= this.idleTimeoutMs) {
390
+ return;
391
+ }
392
+
393
+ // If warning should be shown, start countdown interval
394
+ if (timeSinceActivity >= (this.idleTimeoutMs - this.warnBeforeMs)) {
395
+ // Update state to show warning
396
+ updateState();
397
+
398
+ // Start countdown interval to update remaining time every second
399
+ countdownInterval = setInterval(() => {
400
+ updateState();
401
+
402
+ // Check if we've reached logout time
403
+ if (Date.now() - lastActivity >= this.idleTimeoutMs) {
404
+ if (countdownInterval) {
405
+ clearInterval(countdownInterval);
406
+ countdownInterval = null;
407
+ }
408
+ this.handleIdleLogout();
409
+ }
410
+ }, 1000); // Update every second during warning period
411
+
412
+ // Schedule logout
413
+ logoutTimer = setTimeout(() => {
414
+ if (countdownInterval) {
415
+ clearInterval(countdownInterval);
416
+ countdownInterval = null;
417
+ }
373
418
  this.handleIdleLogout();
374
- }, timeUntilIdle);
419
+ }, timeUntilLogout);
420
+ } else {
421
+ // User is still active - schedule warning timer
422
+ // Use adaptive interval: check more frequently as we approach warning time
423
+ // Check every 60 seconds when far from warning, every 10 seconds when close
424
+ const timeUntilWarningMs = timeUntilWarning;
425
+ const adaptiveInterval = timeUntilWarningMs > 5 * 60 * 1000
426
+ ? 60 * 1000 // Check every 60 seconds when > 5 minutes away
427
+ : timeUntilWarningMs > 60 * 1000
428
+ ? 10 * 1000 // Check every 10 seconds when < 5 minutes but > 1 minute away
429
+ : 1000; // Check every second when < 1 minute away
430
+
431
+ warningTimer = setTimeout(() => {
432
+ updateState();
433
+ scheduleNextCheck(); // Reschedule for next check
434
+ }, Math.min(adaptiveInterval, timeUntilWarningMs));
375
435
  }
376
436
  };
377
437
 
378
438
  const resetTimers = () => {
379
- // Only update lastActivity - don't notify yet
439
+ // Update last activity time
380
440
  lastActivity = Date.now();
381
441
 
382
- // Clear timers
383
- if (idleTimer) {
384
- clearTimeout(idleTimer);
385
- idleTimer = null;
386
- }
387
-
442
+ // Clear all timers
388
443
  if (warningTimer) {
389
444
  clearTimeout(warningTimer);
390
445
  warningTimer = null;
391
446
  }
392
447
 
393
- // Reset state values (will be checked on next poll)
448
+ if (logoutTimer) {
449
+ clearTimeout(logoutTimer);
450
+ logoutTimer = null;
451
+ }
452
+
453
+ if (countdownInterval) {
454
+ clearInterval(countdownInterval);
455
+ countdownInterval = null;
456
+ }
457
+
458
+ // Reset state values
459
+ const hadWarning = this._showWarning || this._showInactivityWarning;
394
460
  this._showInactivityWarning = false;
395
461
  this._inactivityTimeRemaining = 0;
396
462
  this._isIdle = false;
397
463
  this._showWarning = false;
464
+ this._timeRemaining = this.idleTimeoutMs;
398
465
 
399
- // Update previous state to match
466
+ // Update previous state
400
467
  prevIsIdle = false;
401
468
  prevShowWarning = false;
402
469
  prevShowInactivityWarning = false;
403
470
  prevInactivityTimeRemaining = 0;
404
471
  prevTimeRemaining = this.idleTimeoutMs;
405
472
 
406
- // Poll will check and notify if needed
473
+ // Notify if we were showing a warning (state changed)
474
+ if (hadWarning) {
475
+ this.notify();
476
+ }
477
+
478
+ // Reschedule next check
479
+ scheduleNextCheck();
407
480
  };
408
481
 
409
- // Activity detection - only updates lastActivity, doesn't notify
482
+ // Activity detection - throttled to avoid excessive calls
410
483
  const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'];
484
+ let activityThrottleTimer: NodeJS.Timeout | null = null;
411
485
 
412
486
  const handleActivity = () => {
413
- resetTimers();
414
- // Don't call notify - polling will handle state updates
487
+ // Throttle activity detection to once per second max
488
+ if (activityThrottleTimer) {
489
+ return;
490
+ }
491
+
492
+ activityThrottleTimer = setTimeout(() => {
493
+ activityThrottleTimer = null;
494
+ resetTimers();
495
+ }, 1000);
415
496
  };
416
497
 
417
498
  // Add event listeners
@@ -419,29 +500,30 @@ export class InactivityService extends BaseService implements IInactivityService
419
500
  document.addEventListener(event, handleActivity, true);
420
501
  });
421
502
 
422
- // Start polling every 10 seconds
423
- pollInterval = setInterval(() => {
424
- pollInactivityState();
425
- }, 10000); // 10 seconds
426
-
427
- // Initial poll
428
- pollInactivityState();
503
+ // Initial state update and schedule first check
504
+ updateState(true); // Force initial notification
505
+ scheduleNextCheck();
429
506
 
430
507
  // Store cleanup function
431
508
  this.cleanupHandlers = () => {
432
- if (idleTimer) {
433
- clearTimeout(idleTimer);
434
- idleTimer = null;
435
- }
436
-
437
509
  if (warningTimer) {
438
510
  clearTimeout(warningTimer);
439
511
  warningTimer = null;
440
512
  }
441
513
 
442
- if (pollInterval) {
443
- clearInterval(pollInterval);
444
- pollInterval = null;
514
+ if (logoutTimer) {
515
+ clearTimeout(logoutTimer);
516
+ logoutTimer = null;
517
+ }
518
+
519
+ if (countdownInterval) {
520
+ clearInterval(countdownInterval);
521
+ countdownInterval = null;
522
+ }
523
+
524
+ if (activityThrottleTimer) {
525
+ clearTimeout(activityThrottleTimer);
526
+ activityThrottleTimer = null;
445
527
  }
446
528
 
447
529
  activityEvents.forEach(event => {
@@ -457,6 +539,5 @@ export class InactivityService extends BaseService implements IInactivityService
457
539
  };
458
540
 
459
541
  this._isTracking = true;
460
- this.notify(); // Initial notification only
461
542
  }
462
543
  }
@@ -202,6 +202,10 @@
202
202
  font-size: 0.875em;
203
203
  color: var(--color-main-600);
204
204
  }
205
+
206
+ dialog::backdrop {
207
+ background-color: oklch(from var(--color-main-700) l c h / 0.5);
208
+ }
205
209
 
206
210
  .appGradient {
207
211
  background: linear-gradient(145deg, var(--color-main-100) 10%, var(--color-main-200) 30%, var(--color-main-200) 70%, var(--color-main-300) 90%);
@@ -161,8 +161,9 @@ describe('formatTime Utility', () => {
161
161
  }
162
162
  const end = performance.now();
163
163
 
164
- // Should complete in reasonable time (less than 100ms for 1000 calls)
165
- expect(end - start).toBeLessThan(100);
164
+ // Should complete in reasonable time (less than 200ms for 1000 calls)
165
+ // Increased threshold to account for test environment variability
166
+ expect(end - start).toBeLessThan(200);
166
167
  });
167
168
  });
168
169