@payez/next-mvp 4.8.1 → 4.8.2

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 (302) hide show
  1. package/POST-INSTALL.md +408 -408
  2. package/README.md +859 -859
  3. package/dist/components/account/MobileNavDrawer.js +18 -18
  4. package/dist/components/account/UserAvatarMenu.js +1 -1
  5. package/dist/components/admin/AlertSettingsTab.js +40 -40
  6. package/dist/components/admin/LoggingSettingsTab.js +28 -28
  7. package/dist/lib/session-store.js +21 -21
  8. package/dist/lib/startup-init.js +19 -19
  9. package/dist/pages/login/page.js +28 -28
  10. package/dist/pages/settings/EnhancedSettingsPage.js +17 -17
  11. package/dist/vibe/sessions.js +28 -3
  12. package/package.json +2 -2
  13. package/scripts/check-publish-eol.mjs +33 -15
  14. package/src/api/auth-handler.ts +550 -550
  15. package/src/api/index.ts +17 -17
  16. package/src/api-handlers/account/change-password.ts +170 -170
  17. package/src/api-handlers/account/masked-info.ts +46 -46
  18. package/src/api-handlers/account/profile.ts +99 -99
  19. package/src/api-handlers/account/recovery/forward.ts +117 -117
  20. package/src/api-handlers/account/recovery/initiate.ts +59 -59
  21. package/src/api-handlers/account/recovery/send-code.ts +21 -21
  22. package/src/api-handlers/account/recovery/verify-code.ts +20 -20
  23. package/src/api-handlers/account/reset-password.ts +87 -87
  24. package/src/api-handlers/account/send-code.ts +70 -70
  25. package/src/api-handlers/account/update-phone.ts +84 -84
  26. package/src/api-handlers/account/validate-password.ts +139 -139
  27. package/src/api-handlers/account/verify-email.ts +134 -134
  28. package/src/api-handlers/account/verify-sms.ts +134 -134
  29. package/src/api-handlers/admin/analytics.ts +452 -452
  30. package/src/api-handlers/admin/audit.ts +232 -232
  31. package/src/api-handlers/admin/index.ts +63 -63
  32. package/src/api-handlers/admin/redis-sessions.ts +259 -259
  33. package/src/api-handlers/admin/sessions.ts +327 -327
  34. package/src/api-handlers/admin/site-logs.ts +388 -388
  35. package/src/api-handlers/admin/stats.ts +260 -260
  36. package/src/api-handlers/admin/users.ts +251 -251
  37. package/src/api-handlers/admin/vibe-data.ts +333 -333
  38. package/src/api-handlers/anon/preferences.ts +123 -123
  39. package/src/api-handlers/auth/credential-login-email-code.ts +92 -92
  40. package/src/api-handlers/auth/credential-login.ts +948 -948
  41. package/src/api-handlers/auth/jwks.ts +31 -31
  42. package/src/api-handlers/auth/login.ts +252 -252
  43. package/src/api-handlers/auth/passkey-signin.ts +116 -116
  44. package/src/api-handlers/auth/refresh.ts +829 -829
  45. package/src/api-handlers/auth/signout.ts +201 -201
  46. package/src/api-handlers/auth/status.ts +23 -23
  47. package/src/api-handlers/auth/update-session.ts +144 -144
  48. package/src/api-handlers/auth/validate.ts +45 -45
  49. package/src/api-handlers/auth/verify-code.ts +116 -116
  50. package/src/api-handlers/mfa/backup-code-verify.ts +170 -170
  51. package/src/api-handlers/mfa/backup-codes.ts +42 -42
  52. package/src/api-handlers/mfa/methods.ts +47 -47
  53. package/src/api-handlers/mfa/totp-confirm.ts +43 -43
  54. package/src/api-handlers/mfa/totp-enroll.ts +43 -43
  55. package/src/api-handlers/mfa/totp-verify.ts +172 -172
  56. package/src/api-handlers/oauth/resume.ts +182 -182
  57. package/src/api-handlers/onboarding/send-email-code.ts +18 -18
  58. package/src/api-handlers/onboarding/set-password.ts +21 -21
  59. package/src/api-handlers/onboarding/verify-code.ts +20 -20
  60. package/src/api-handlers/onboarding/verify-welcome-email.ts +17 -17
  61. package/src/api-handlers/passkeys/list.ts +32 -32
  62. package/src/api-handlers/passkeys/manage.ts +55 -55
  63. package/src/api-handlers/passkeys/register-options.ts +43 -43
  64. package/src/api-handlers/passkeys/register-verify.ts +42 -42
  65. package/src/api-handlers/session/refresh-viability.ts +56 -56
  66. package/src/api-handlers/session/viability.ts +238 -238
  67. package/src/api-handlers/test/force-expire.ts +167 -167
  68. package/src/auth/auth-decision.ts +292 -292
  69. package/src/auth/better-auth.ts +1688 -1688
  70. package/src/auth/idp-oauth.ts +310 -310
  71. package/src/auth/password-reset-bridge.ts +98 -98
  72. package/src/auth/revocation-fields.ts +16 -16
  73. package/src/auth/route-config.ts +236 -236
  74. package/src/auth/safe-callback.ts +199 -199
  75. package/src/auth/session-cookie-clear.ts +30 -30
  76. package/src/auth/session-reset-link.ts +159 -159
  77. package/src/auth/session-response-sanitizer.ts +151 -151
  78. package/src/auth/types/auth-types.ts +555 -555
  79. package/src/auth/types/index.ts +7 -7
  80. package/src/auth/unauthenticated-routes.ts +2 -2
  81. package/src/auth/utils/idp-client.ts +364 -364
  82. package/src/auth/utils/index.ts +6 -6
  83. package/src/auth/utils/token-utils.ts +243 -243
  84. package/src/client/AuthContext.tsx +192 -192
  85. package/src/client/better-auth-client.ts +70 -70
  86. package/src/client/fetch-with-auth.ts +47 -47
  87. package/src/client/fetchWithSession.ts +21 -21
  88. package/src/client/index.ts +13 -13
  89. package/src/client/useAnonSession.ts +131 -131
  90. package/src/components/SessionSync.tsx +138 -138
  91. package/src/components/SignalRHealthCheck.tsx +131 -131
  92. package/src/components/account/MobileNavDrawer.tsx +305 -305
  93. package/src/components/account/UserAvatarMenu.tsx +277 -277
  94. package/src/components/account/index.ts +13 -13
  95. package/src/components/admin/AlertSettingsTab.tsx +728 -728
  96. package/src/components/admin/AnalyticsTab.tsx +703 -703
  97. package/src/components/admin/DataBrowserTab.tsx +508 -508
  98. package/src/components/admin/LoggingSettingsTab.tsx +665 -665
  99. package/src/components/admin/SessionsTab.tsx +421 -421
  100. package/src/components/admin/StatsTab.tsx +379 -379
  101. package/src/components/admin/VibeAdminContext.tsx +87 -87
  102. package/src/components/admin/VibeAdminLayout.tsx +187 -187
  103. package/src/components/admin/index.ts +59 -59
  104. package/src/components/auth/CredentialSignInForm.tsx +240 -240
  105. package/src/components/auth/FederatedAuthSection.tsx +129 -129
  106. package/src/components/auth/ModeAwareLoginPage.tsx +146 -146
  107. package/src/components/auth/ModeAwareSignupPage.tsx +200 -200
  108. package/src/components/auth/PasswordResetBridge.tsx +30 -30
  109. package/src/components/auth/TraditionalAuthSection.tsx +103 -103
  110. package/src/components/recovery/CompleteStep.tsx +36 -36
  111. package/src/components/recovery/InitiateRecoveryStep.tsx +68 -68
  112. package/src/components/recovery/RequestedStep.tsx +30 -30
  113. package/src/components/recovery/SelectMethodStep.tsx +73 -73
  114. package/src/components/recovery/SetPasswordStep.tsx +118 -118
  115. package/src/components/recovery/VerifyCodeStep.tsx +90 -90
  116. package/src/components/reserved/ReservedRecoveryWarning.tsx +160 -160
  117. package/src/components/reserved/ReservedStatusBox.tsx +118 -118
  118. package/src/components/ui/BetaBadge.tsx +58 -58
  119. package/src/components/ui/Footer.tsx +93 -93
  120. package/src/config/env.ts +55 -55
  121. package/src/config/logger.ts +62 -62
  122. package/src/config/logging-config.ts +82 -82
  123. package/src/config/unauthenticated-routes.ts +19 -19
  124. package/src/config/vibe-log-transport.ts +264 -264
  125. package/src/edge/internal-api-url.ts +65 -65
  126. package/src/edge/middleware.ts +42 -42
  127. package/src/hooks/useAuth.ts +117 -117
  128. package/src/hooks/useAuthSettings.ts +109 -109
  129. package/src/hooks/useAvailableProviders.ts +143 -143
  130. package/src/hooks/usePasswordValidation.ts +127 -127
  131. package/src/hooks/useProfile.ts +82 -82
  132. package/src/hooks/usePublicAuthSettings.ts +171 -171
  133. package/src/hooks/useSessionExpiration.ts +101 -101
  134. package/src/hooks/useViabilitySession.ts +337 -337
  135. package/src/index.ts +64 -64
  136. package/src/lib/anon-session.ts +213 -213
  137. package/src/lib/api-handler.ts +643 -643
  138. package/src/lib/app-slug.ts +228 -228
  139. package/src/lib/authorize-resume.ts +172 -172
  140. package/src/lib/bearer-fetch.ts +31 -31
  141. package/src/lib/client-key.ts +88 -88
  142. package/src/lib/code-step-copy.ts +83 -83
  143. package/src/lib/credential-sign-in-client.ts +101 -101
  144. package/src/lib/demo-mode.ts +18 -18
  145. package/src/lib/device-id.ts +67 -67
  146. package/src/lib/ensure-fresh-access-token.ts +509 -509
  147. package/src/lib/geolocation.ts +265 -265
  148. package/src/lib/idp-client-config.ts +1005 -1005
  149. package/src/lib/idp-fetch.ts +169 -169
  150. package/src/lib/idp-url-guard.ts +37 -37
  151. package/src/lib/internal-api.ts +171 -171
  152. package/src/lib/jwt-decode-client.ts +45 -45
  153. package/src/lib/jwt-decode.ts +82 -82
  154. package/src/lib/lock-window.ts +49 -49
  155. package/src/lib/login-error-message.ts +41 -41
  156. package/src/lib/never-in-response.ts +64 -64
  157. package/src/lib/nextauth-secret.ts +168 -168
  158. package/src/lib/onboarding-proxy.ts +57 -57
  159. package/src/lib/password-rules.ts +50 -50
  160. package/src/lib/rate-limit-service.ts +9 -9
  161. package/src/lib/redis.ts +105 -105
  162. package/src/lib/refresh-token-validator.ts +64 -64
  163. package/src/lib/roles.ts +177 -177
  164. package/src/lib/safe-error-response.ts +149 -149
  165. package/src/lib/secret-validation.ts +8 -8
  166. package/src/lib/session-cookie-shape.ts +185 -185
  167. package/src/lib/session-store.ts +1092 -1092
  168. package/src/lib/session.ts +34 -34
  169. package/src/lib/single-flight.ts +64 -64
  170. package/src/lib/site-logger.ts +245 -245
  171. package/src/lib/standardized-client-api.ts +901 -901
  172. package/src/lib/startup-init.ts +276 -276
  173. package/src/lib/test-aware-get-token.ts +41 -41
  174. package/src/lib/token-expiry.ts +40 -40
  175. package/src/lib/token-lifecycle.ts +546 -546
  176. package/src/lib/token-log-id.ts +69 -69
  177. package/src/lib/twofa-rejection.ts +91 -91
  178. package/src/lib/types/api-responses.ts +336 -336
  179. package/src/lib/user-agent-parser.ts +252 -252
  180. package/src/logging/api/admin-analytics.ts +51 -51
  181. package/src/logging/api/audit-log.ts +53 -53
  182. package/src/logging/components/AdminAnalyticsLayout.tsx +49 -49
  183. package/src/logging/components/AuditLogViewer.tsx +125 -125
  184. package/src/logging/components/ErrorMetricsCard.tsx +98 -98
  185. package/src/logging/components/HealthMetricsCard.tsx +70 -70
  186. package/src/logging/hooks/useAdminAnalytics.ts +22 -22
  187. package/src/logging/hooks/useAuditLog.ts +24 -24
  188. package/src/logging/hooks/useErrorMetrics.ts +40 -40
  189. package/src/logging/hooks/useHealthMetrics.ts +44 -44
  190. package/src/logging/index.ts +18 -18
  191. package/src/logging/types/analytics.ts +81 -81
  192. package/src/logging/types/audit.ts +31 -31
  193. package/src/logging/types/index.ts +3 -3
  194. package/src/middleware/create-middleware.ts +1141 -1141
  195. package/src/middleware/rbac-check.ts +279 -279
  196. package/src/middleware/twofa-presets.ts +224 -224
  197. package/src/models/DecodedAccessToken.ts +16 -16
  198. package/src/models/SessionModel.ts +331 -331
  199. package/src/pages/admin-login/page.tsx +111 -111
  200. package/src/pages/admin-page-permissions/PagePermissionsAdminPage.tsx +527 -527
  201. package/src/pages/admin-page-permissions/index.ts +7 -7
  202. package/src/pages/admin-roles/RolesAdminPage.tsx +533 -533
  203. package/src/pages/admin-roles/index.ts +9 -9
  204. package/src/pages/admin-roles/modals.tsx +469 -469
  205. package/src/pages/client-admin/ClientSiteAdminPage.tsx +382 -382
  206. package/src/pages/client-admin/index.ts +33 -33
  207. package/src/pages/coming-soon/page.tsx +83 -83
  208. package/src/pages/login/page.tsx +338 -338
  209. package/src/pages/onboarding/index.ts +11 -11
  210. package/src/pages/onboarding/page.tsx +212 -212
  211. package/src/pages/profile/EnhancedProfilePage.tsx +514 -514
  212. package/src/pages/profile/index.ts +9 -9
  213. package/src/pages/profile/page.tsx +166 -166
  214. package/src/pages/recovery/page.tsx +267 -267
  215. package/src/pages/roles/MyRolesPage.tsx +211 -211
  216. package/src/pages/roles/components.tsx +294 -294
  217. package/src/pages/roles/index.ts +17 -17
  218. package/src/pages/security/EnhancedSecurityPage.tsx +660 -660
  219. package/src/pages/security/index.ts +12 -12
  220. package/src/pages/security/page.tsx +507 -507
  221. package/src/pages/service-unavailable/page.tsx +63 -63
  222. package/src/pages/settings/EnhancedSettingsPage.tsx +642 -642
  223. package/src/pages/settings/index.ts +9 -9
  224. package/src/pages/settings/page.tsx +47 -47
  225. package/src/pages/showcase/ShowcasePage.tsx +532 -532
  226. package/src/pages/showcase/index.ts +13 -13
  227. package/src/pages/test-env/EmergencyLogoutPage.tsx +192 -192
  228. package/src/pages/test-env/JwtInspectPage.tsx +449 -449
  229. package/src/pages/test-env/RefreshTokenPage.tsx +178 -178
  230. package/src/pages/test-env/TestEnvPage.tsx +130 -130
  231. package/src/pages/test-env/guarded.tsx +81 -81
  232. package/src/pages/test-env/index.ts +77 -77
  233. package/src/pages/test-env/redact-credentials.ts +96 -96
  234. package/src/pages/verify-code/page.tsx +746 -746
  235. package/src/routes/account/index.ts +31 -31
  236. package/src/routes/account/masked-info.ts +36 -36
  237. package/src/routes/account/send-code.ts +39 -39
  238. package/src/routes/account/update-phone.ts +13 -13
  239. package/src/routes/account/verify-email.ts +40 -40
  240. package/src/routes/account/verify-sms.ts +40 -40
  241. package/src/routes/auth/credential-login-email-code.ts +6 -6
  242. package/src/routes/auth/credential-login.ts +6 -6
  243. package/src/routes/auth/index.ts +22 -22
  244. package/src/routes/auth/logout.ts +111 -111
  245. package/src/routes/auth/nextauth.ts +45 -45
  246. package/src/routes/auth/passkey-signin.ts +13 -13
  247. package/src/routes/auth/refresh.ts +77 -77
  248. package/src/routes/auth/session-reset.ts +37 -37
  249. package/src/routes/auth/session.ts +261 -261
  250. package/src/routes/auth/settings.ts +181 -181
  251. package/src/routes/auth/viability.ts +519 -519
  252. package/src/routes/index.ts +17 -17
  253. package/src/routes/mfa/backup-code-verify.ts +1 -1
  254. package/src/routes/mfa/backup-codes.ts +10 -10
  255. package/src/routes/mfa/methods.ts +22 -22
  256. package/src/routes/mfa/totp-confirm.ts +10 -10
  257. package/src/routes/mfa/totp-enroll.ts +10 -10
  258. package/src/routes/mfa/totp-verify.ts +14 -14
  259. package/src/routes/onboarding/index.ts +15 -15
  260. package/src/routes/onboarding/send-email-code.ts +2 -2
  261. package/src/routes/onboarding/set-password.ts +2 -2
  262. package/src/routes/onboarding/verify-code.ts +2 -2
  263. package/src/routes/onboarding/verify-welcome-email.ts +2 -2
  264. package/src/routes/passkeys/list.ts +10 -10
  265. package/src/routes/passkeys/manage.ts +23 -23
  266. package/src/routes/passkeys/register-options.ts +10 -10
  267. package/src/routes/passkeys/register-verify.ts +10 -10
  268. package/src/routes/session/index.ts +7 -7
  269. package/src/routes/session/refresh-viability.ts +17 -17
  270. package/src/server/auth-guard.ts +203 -203
  271. package/src/server/auth.ts +486 -486
  272. package/src/server/decode-session.ts +309 -309
  273. package/src/server/slim-middleware.ts +117 -117
  274. package/src/server/verified-session-token.ts +103 -103
  275. package/src/server/with-auth.ts +93 -93
  276. package/src/services/signalrActivityService.ts +258 -258
  277. package/src/stores/authStore.ts +1900 -1900
  278. package/src/templates/instrumentation.ts +60 -60
  279. package/src/theme/ThemeProvider.tsx +39 -39
  280. package/src/theme/default.ts +33 -33
  281. package/src/theme/index.ts +31 -31
  282. package/src/theme/types.ts +69 -69
  283. package/src/theme/useTheme.ts +57 -57
  284. package/src/theme/utils.ts +40 -40
  285. package/src/types/api.ts +12 -12
  286. package/src/types/auth.d.ts +23 -23
  287. package/src/types/auth.ts +48 -48
  288. package/src/types/logging.ts +11 -11
  289. package/src/types/recovery.ts +54 -54
  290. package/src/utils/api.ts +401 -401
  291. package/src/utils/circuitBreaker.ts +40 -40
  292. package/src/utils/error-message.ts +108 -108
  293. package/src/utils/layout/reservedSpace.ts +124 -124
  294. package/src/utils/logout.ts +30 -30
  295. package/src/vibe/client.ts +877 -877
  296. package/src/vibe/enterprise-auth.ts +208 -208
  297. package/src/vibe/errors.ts +209 -209
  298. package/src/vibe/generic.ts +339 -339
  299. package/src/vibe/hooks/index.ts +367 -367
  300. package/src/vibe/index.ts +133 -133
  301. package/src/vibe/sessions.ts +925 -899
  302. package/src/vibe/types.ts +577 -577
@@ -1,1900 +1,1900 @@
1
- /**
2
- * 🚀 CENTRALIZED AUTH STORE - THE SINGLE SOURCE OF TRUTH
3
- *
4
- * This Zustand store replaces ALL scattered useState patterns for auth-related state.
5
- * No more prop drilling, no more duplicate loading states, no more auth chaos.
6
- *
7
- * Features:
8
- * - Centralized session, token, and user state
9
- * - Built-in API calling with auto token refresh
10
- * - Loading state management for all async operations
11
- * - Type-safe throughout
12
- * - Integrates seamlessly with existing NextAuth
13
- */
14
-
15
- import { create } from 'zustand';
16
- import { devtools } from 'zustand/middleware';
17
- import { authClient } from '../client/better-auth-client';
18
- import { AppSession, isValidSession, sanitizeSession } from '../lib/session';
19
- import { authLogger } from '../config/logger';
20
- import { ENV_CONFIG } from '../config/env';
21
- import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
22
- import { signalRActivityService } from '../services/signalrActivityService';
23
- import {
24
- getSessionCookieName,
25
- getSecureSessionCookieName,
26
- getCsrfCookieName,
27
- getSecureCsrfCookieName
28
- } from '../lib/app-slug';
29
- import { tokenLogId } from '../lib/token-log-id';
30
-
31
- // ===============================
32
- // INTERFACES & TYPES
33
- // ===============================
34
-
35
- export interface User {
36
- id: string;
37
- email: string;
38
- roles: string[];
39
- twoFactorSessionVerified: boolean;
40
- requiresTwoFactor: boolean;
41
- twoFactorMethod?: string;
42
- authenticationMethods?: string[];
43
- authenticationLevel?: string;
44
-
45
- // Administrative States (from user_state_management.md)
46
- isApproved: boolean;
47
- isSuspended: boolean;
48
- lockoutEnabled: boolean;
49
- lockoutEnd?: Date | null;
50
-
51
- // Suspension Metadata
52
- pausedAt?: Date | null;
53
- pausedBy?: string | null;
54
- suspensionReason?: string | null;
55
- }
56
-
57
- // SignalR Event Types
58
- export interface UserStateChangeEvent {
59
- userId: string;
60
- action: 'APPROVE' | 'DISAPPROVE' | 'PAUSE' | 'RESUME' | 'HALT' | 'UNLOCK';
61
- newState: {
62
- isApproved?: boolean;
63
- isSuspended?: boolean;
64
- lockoutEnabled?: boolean;
65
- lockoutEnd?: string | null;
66
- pausedAt?: string | null;
67
- pausedBy?: string | null;
68
- suspensionReason?: string | null;
69
- };
70
- reason?: string;
71
- changedBy: string;
72
- timestamp: string;
73
- }
74
-
75
- export interface SecurityNotificationEvent {
76
- type: 'USER_LOCKOUT' | 'IP_THROTTLE' | 'BRUTE_FORCE' | 'DISTRIBUTED_ATTACK';
77
- userId?: string;
78
- ipAddress?: string;
79
- message: string;
80
- severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
81
- timestamp: string;
82
- }
83
-
84
- export interface AuthState {
85
- // Core Auth State
86
- session: AppSession | null;
87
- user: User | null;
88
- accessToken: string | null;
89
- refreshToken: string | null;
90
- isAuthenticated: boolean;
91
- isInitialized: boolean;
92
-
93
- // Loading States (replaces useState hell)
94
- isLoading: boolean;
95
- isRefreshingToken: boolean;
96
-
97
- // Data Loading States
98
- isLoadingUserStats: boolean;
99
- isLoadingClients: boolean;
100
- isLoadingRoles: boolean;
101
- isLoadingUsers: boolean;
102
- isLoadingUserDetails: Record<string, boolean>;
103
- isLoadingRoleCategories: boolean;
104
- isLoadingUserAssignments: Record<string, boolean>;
105
- isLoadingClientAuthorizations: Record<string, boolean>;
106
-
107
- // Error States
108
- error: string | null;
109
- tokenError: string | null;
110
-
111
- // Cached Data (eliminates redundant API calls)
112
- userStats: any | null;
113
- clients: any[] | null;
114
- roles: any[] | null;
115
- users: any[] | null;
116
- userDetails: Record<string, any>;
117
- userAssignments: Record<string, any>;
118
- roleCategories: any[] | null;
119
- clientAuthorizations: Record<string, any[] | undefined>;
120
-
121
- // Cache timestamps
122
- userStatsLastFetch: number | null;
123
- clientsLastFetch: number | null;
124
- rolesLastFetch: number | null;
125
- usersLastFetch: number | null;
126
- roleCategoriesLastFetch: number | null;
127
-
128
- // Real-time SignalR Connection
129
- signalrConnection: HubConnection | null;
130
- signalrConnectionState: HubConnectionState;
131
- isConnectedToSignalR: boolean;
132
- }
133
-
134
- export interface AuthActions {
135
- // Session Management
136
- setSession: (session: AppSession | null) => void;
137
- clearSession: () => void;
138
- refreshSession: () => Promise<void>;
139
- refreshTokens: () => Promise<void>;
140
- rehydrateSessionAfterRefresh: () => Promise<void>;
141
-
142
- // Authentication Actions
143
- signIn: (credentials: { email: string; password: string }) => Promise<boolean>;
144
- signOut: () => Promise<void>;
145
- forceLogoutAndRedirect: (reason: string) => Promise<void>;
146
-
147
- // API Actions with Built-in Token Management
148
- apiCall: <T = any>(url: string, options?: RequestInit, maxRetries?: number) => Promise<T>;
149
- makeApiCall: <T = any>(url: string, options?: RequestInit, attempt?: number) => Promise<T>;
150
-
151
- // Data Fetching Actions (replaces useEffect + useState patterns)
152
- fetchUserStats: (force?: boolean) => Promise<void>;
153
- fetchClients: (force?: boolean) => Promise<void>;
154
- fetchRoles: (force?: boolean) => Promise<void>;
155
- fetchUsers: (params?: any, force?: boolean) => Promise<void>;
156
- fetchUserDetails: (userId: string, force?: boolean) => Promise<void>;
157
- fetchUserClientAuthorizations: (userId: string, force?: boolean) => Promise<void>;
158
- fetchUserRoleAssignments: (userId: string, force?: boolean) => Promise<void>;
159
- fetchRoleCategories: (force?: boolean) => Promise<void>;
160
-
161
- // CRUD Operations (replaces direct API calls)
162
- createUser: (userData: any) => Promise<any>;
163
- updateUser: (userId: string, updates: any) => Promise<any>;
164
- deleteUser: (userId: string) => Promise<void>;
165
- createRole: (roleData: any) => Promise<any>;
166
- updateRole: (roleId: string, updates: any) => Promise<any>;
167
- deleteRole: (roleId: string) => Promise<void>;
168
- assignUserToRole: (userId: string, roleId: string) => Promise<void>;
169
- removeUserFromRole: (userId: string, roleId: string) => Promise<void>;
170
- assignUserToClient: (userId: string, clientId: string) => Promise<void>;
171
- removeUserFromClient: (userId: string, clientId: string) => Promise<void>;
172
-
173
- // Utility Actions
174
- hasRole: (role: string) => boolean;
175
- hasAnyRole: (roles: string[]) => boolean;
176
- hasAllRoles: (roles: string[]) => boolean;
177
- isFullyAuthenticated: () => boolean;
178
-
179
- // Error Management
180
- setError: (error: string | null) => void;
181
- clearError: () => void;
182
-
183
- // Admin User State Management Actions
184
- approveUser: (userId: string, reason?: string) => Promise<void>;
185
- disapproveUser: (userId: string, reason?: string) => Promise<void>;
186
- pauseUser: (userId: string, reason?: string) => Promise<void>;
187
- resumeUser: (userId: string) => Promise<void>;
188
- haltUser: (userId: string, reason?: string) => Promise<void>;
189
- unlockUser: (userId: string) => Promise<void>;
190
-
191
- // User State Utilities
192
- canUserAccess: () => boolean;
193
- getUserStateDisplay: () => string;
194
- isUserLocked: () => boolean;
195
-
196
- // Real-time SignalR Management
197
- initializeSignalR: () => Promise<void>;
198
- disconnectSignalR: () => Promise<void>;
199
- handleUserStateChanged: (data: UserStateChangeEvent) => void;
200
- }
201
-
202
- export type AuthStore = AuthState & AuthActions;
203
-
204
- // ===============================
205
- // CACHE CONFIGURATION
206
- // ===============================
207
-
208
- const CACHE_DURATION = {
209
- USER_STATS: 5 * 60 * 1000, // 5 minutes
210
- CLIENTS: 10 * 60 * 1000, // 10 minutes
211
- ROLES: 15 * 60 * 1000, // 15 minutes
212
- USERS: 5 * 60 * 1000, // 5 minutes (dynamic data)
213
- USER_DETAILS: 2 * 60 * 1000, // 2 minutes (detailed data)
214
- ROLE_CATEGORIES: 30 * 60 * 1000, // 30 minutes (stable data)
215
- USER_ASSIGNMENTS: 2 * 60 * 1000, // 2 minutes (assignment data changes frequently)
216
- };
217
-
218
- // ===============================
219
- // ZUSTAND STORE IMPLEMENTATION
220
- // ===============================
221
-
222
- export const useAuthStore = create<AuthStore>()(
223
- devtools(
224
- (set, get) => ({
225
- // ===============================
226
- // INITIAL STATE
227
- // ===============================
228
- session: null,
229
- user: null,
230
- accessToken: null,
231
- refreshToken: null,
232
- isAuthenticated: false,
233
- isInitialized: false,
234
-
235
- // Loading States
236
- isLoading: true,
237
- isRefreshingToken: false,
238
- isLoadingUserStats: false,
239
- isLoadingClients: false,
240
- isLoadingRoles: false,
241
- isLoadingUsers: false,
242
- isLoadingUserDetails: {},
243
- isLoadingRoleCategories: false,
244
- isLoadingUserAssignments: {},
245
- isLoadingClientAuthorizations: {},
246
-
247
- // Error States
248
- error: null,
249
- tokenError: null,
250
-
251
- // Cached Data
252
- userStats: null,
253
- clients: null,
254
- roles: null,
255
- users: null,
256
- userDetails: {},
257
- userAssignments: {},
258
- roleCategories: null,
259
- clientAuthorizations: {},
260
-
261
- // Cache Timestamps
262
- userStatsLastFetch: null,
263
- clientsLastFetch: null,
264
- rolesLastFetch: null,
265
- usersLastFetch: null,
266
- roleCategoriesLastFetch: null,
267
-
268
- // SignalR Connection State
269
- signalrConnection: null,
270
- signalrConnectionState: HubConnectionState.Disconnected,
271
- isConnectedToSignalR: false,
272
-
273
- // ===============================
274
- // SESSION MANAGEMENT ACTIONS
275
- // ===============================
276
-
277
- setSession: (session: AppSession | null) => {
278
- // CRITICAL FIX: Validate and sanitize the session before setting it
279
- // This prevents storing sessions with empty user IDs or other invalid data
280
- const cleanSession = sanitizeSession(session);
281
-
282
- if (!cleanSession) {
283
- // Session is invalid (empty userId, empty email, or missing accessToken)
284
- authLogger.warn('[AuthStore] Rejecting invalid session', {
285
- hasSession: false,
286
- reason: 'invalid_or_partial_session',
287
- hasIncomingSession: !!session,
288
- incomingUserId: session?.user?.id || '(empty)',
289
- incomingUserEmail: session?.user?.email || '(empty)',
290
- hasAccessToken: !!(session as any)?.accessToken
291
- });
292
-
293
- // Clear the session state completely
294
- set({
295
- session: null,
296
- user: null,
297
- accessToken: null,
298
- refreshToken: null,
299
- isAuthenticated: false,
300
- isInitialized: true,
301
- isLoading: false,
302
- error: 'Invalid session data',
303
- });
304
-
305
- return;
306
- }
307
-
308
- // Session is valid - proceed with setting it
309
- authLogger.info('[AuthStore] Setting valid session:', {
310
- hasSession: true,
311
- userId: cleanSession.user?.id,
312
- userEmail: cleanSession.user?.email
313
- });
314
-
315
- const user: User = {
316
- id: cleanSession.user?.id || '',
317
- email: cleanSession.user?.email || '',
318
- roles: Array.isArray((cleanSession.user as any)?.roles) ? (cleanSession.user as any).roles : [],
319
- twoFactorSessionVerified: (cleanSession.user as any)?.twoFactorSessionVerified || false,
320
- requiresTwoFactor: (cleanSession.user as any)?.requiresTwoFactor || false,
321
- twoFactorMethod: (cleanSession.user as any)?.twoFactorMethod,
322
- authenticationMethods: (cleanSession.user as any).authenticationMethods,
323
- authenticationLevel: (cleanSession.user as any).authenticationLevel,
324
-
325
- // Administrative States (with safe defaults)
326
- isApproved: (cleanSession.user as any).isApproved ?? true,
327
- isSuspended: (cleanSession.user as any).isSuspended ?? false,
328
- lockoutEnabled: (cleanSession.user as any).lockoutEnabled ?? false,
329
- lockoutEnd: (cleanSession.user as any).lockoutEnd ? new Date((cleanSession.user as any).lockoutEnd) : null,
330
-
331
- // Suspension Metadata
332
- pausedAt: (cleanSession.user as any).pausedAt ? new Date((cleanSession.user as any).pausedAt) : null,
333
- pausedBy: (cleanSession.user as any).pausedBy || null,
334
- suspensionReason: (cleanSession.user as any).suspensionReason || null,
335
- };
336
-
337
- set({
338
- session: cleanSession,
339
- user,
340
- accessToken: (cleanSession as any).accessToken || null,
341
- refreshToken: (cleanSession as any).refreshToken || null,
342
- // FIXED: Use strict validation - both accessToken AND user.id must be non-empty
343
- isAuthenticated: true, // Already validated by sanitizeSession
344
- isInitialized: true,
345
- isLoading: false,
346
- error: (cleanSession as any).error || null,
347
- });
348
-
349
- // Auto-initialize SignalR for authenticated users
350
- // Use a longer delay and add error handling to prevent login blocking
351
- setTimeout(async () => {
352
- try {
353
- await get().initializeSignalR();
354
- } catch (error) {
355
- // Don't let SignalR initialization errors block the login process
356
- authLogger.warn('[AuthStore] SignalR initialization failed (non-blocking):', error);
357
- }
358
- }, 500); // Longer delay to ensure login flow completes first
359
- },
360
-
361
- clearSession: () => {
362
- authLogger.debug('[AuthStore] Clearing session');
363
-
364
- // Disconnect SignalR before clearing session
365
- get().disconnectSignalR();
366
-
367
- set({
368
- session: null,
369
- user: null,
370
- accessToken: null,
371
- refreshToken: null,
372
- isAuthenticated: false,
373
- error: null,
374
- tokenError: null,
375
- // Keep cache data but clear sensitive auth data
376
- });
377
- },
378
-
379
- refreshSession: async () => {
380
- const state = get();
381
- if (!state.session?.sessionToken || !state.user?.id || state.isRefreshingToken) {
382
- return;
383
- }
384
-
385
- set({ isRefreshingToken: true, tokenError: null });
386
-
387
- try {
388
- // Call refresh endpoint directly (no middleware chaos)
389
- const stateBefore = get();
390
- const refreshResponse = await fetch('/api/auth/refresh', {
391
- method: 'POST',
392
- headers: {
393
- 'Content-Type': 'application/json',
394
- 'X-Session-Token': stateBefore.session?.sessionToken || '',
395
- 'X-Request-Source': 'authStore.refreshSession'
396
- },
397
- credentials: 'include'
398
- });
399
-
400
- if (!refreshResponse.ok) {
401
- const refreshError = await refreshResponse.json().catch(() => ({ message: 'Token refresh failed' }));
402
- throw new Error(refreshError.message || 'Token refresh failed');
403
- }
404
-
405
- // TODO: Get updated session from Redis after successful refresh (temp disabled for client build)
406
- // For now, assume refresh was successful
407
-
408
- // TODO: Update session with new tokens from Redis (temp disabled)
409
- // For now, assume session tokens are already updated by the refresh endpoint
410
-
411
- authLogger.info('[AuthStore] Token refresh successful via direct /api/session/refresh');
412
- } catch (error) {
413
- const errorMessage = error instanceof Error ? error.message : 'Token refresh failed';
414
- authLogger.error('[AuthStore] Token refresh failed:', error);
415
-
416
- set({
417
- tokenError: errorMessage,
418
- isAuthenticated: false,
419
- });
420
-
421
- // If refresh fails, sign out
422
- await get().signOut();
423
- } finally {
424
- set({ isRefreshingToken: false });
425
- }
426
- },
427
-
428
- // ===============================
429
- // AUTHENTICATION ACTIONS
430
- // ===============================
431
-
432
- signIn: async (credentials: { email: string; password: string }) => {
433
- set({ isLoading: true, error: null });
434
-
435
- try {
436
- // Use Better Auth signIn
437
- const result = await authClient.signIn.email({
438
- email: credentials.email,
439
- password: credentials.password,
440
- });
441
-
442
- if (result?.data) {
443
- authLogger.info('[AuthStore] Sign in successful');
444
- return true;
445
- } else {
446
- const errorMessage = (result as any)?.error
447
- ? (typeof (result as any).error === 'object' ? ((result as any).error as any).message : String((result as any).error))
448
- : 'Sign in failed';
449
- set({ error: errorMessage, isLoading: false });
450
- return false;
451
- }
452
- } catch (error) {
453
- const errorMessage = error instanceof Error ? error.message : 'Sign in failed';
454
- authLogger.error('[AuthStore] Sign in error:', error);
455
- set({ error: errorMessage, isLoading: false });
456
- return false;
457
- }
458
- },
459
-
460
- signOut: async () => {
461
- authLogger.info('[AuthStore] Starting sign out process');
462
-
463
- // Clear local state immediately
464
- get().clearSession();
465
-
466
- // Clear cached data
467
- set({
468
- userStats: null,
469
- clients: null,
470
- roles: null,
471
- userStatsLastFetch: null,
472
- clientsLastFetch: null,
473
- rolesLastFetch: null,
474
- });
475
-
476
- try {
477
- // Use Better Auth signOut
478
- await authClient.signOut();
479
- authLogger.info('[AuthStore] Sign out completed');
480
- } catch (error) {
481
- authLogger.error('[AuthStore] Sign out error:', error);
482
- // Even if signOut fails, we've cleared local state
483
- }
484
- },
485
-
486
- forceLogoutAndRedirect: async (reason: string) => {
487
- const state = get();
488
- // Same truncation convention as the server-side session logs (session-store.ts,
489
- // refresh.ts): a session token is a bearer credential, and this line goes to the
490
- // browser console (and whatever aggregates authLogger client-side) - full value
491
- // was leftover debug logging (DotNetPert-Scout, mail 58678).
492
- // PAY-1505: the ternary that used to stand here fell back to the RAW token on its false arm.
493
- // Safe only by the unenforced property that the arm is reached when the token is falsy -
494
- // exactly the shape the source guard exists to refuse. tokenLogId already answers 'none'
495
- // for an absent token, so the fallback is not needed.
496
- const sessionTokenPrefix = tokenLogId(state.session?.sessionToken);
497
-
498
- // AGGRESSIVE LOGGING TO TRACK EXECUTION
499
- console.error('🚨 FORCE LOGOUT INITIATED 🚨', {
500
- reason,
501
- userId: state.user?.id,
502
- sessionToken: sessionTokenPrefix,
503
- timestamp: new Date().toISOString()
504
- });
505
-
506
- authLogger.error('[AuthStore] Force logout initiated', {
507
- reason,
508
- userId: state.user?.id,
509
- sessionToken: sessionTokenPrefix
510
- });
511
-
512
- try {
513
- // TODO: Step 1: Mark session as force-invalidated in Redis (temp disabled for client build)
514
-
515
- // Step 2: Clear local state immediately
516
- get().clearSession();
517
-
518
- // Step 3: Clear all cached data
519
- set({
520
- userStats: null,
521
- clients: null,
522
- roles: null,
523
- userStatsLastFetch: null,
524
- clientsLastFetch: null,
525
- rolesLastFetch: null,
526
- });
527
-
528
- // Step 4: Clear session cache (temp disabled for client build)
529
- // TODO: Re-enable when session-cache module is available
530
- // const { SessionCache } = await import('@/lib/session-cache');
531
- // SessionCache.clearCache();
532
- // SessionCache.clearServerSessionData();
533
-
534
- // Step 5: AGGRESSIVELY clear NextAuth cookies immediately before signOut (app-slug prefixed)
535
- if (typeof document !== 'undefined') {
536
- const cookiesToClear = [
537
- getSessionCookieName(),
538
- getSecureSessionCookieName(),
539
- getCsrfCookieName(),
540
- getSecureCsrfCookieName()
541
- ];
542
-
543
- authLogger.info('[AuthStore] Aggressively clearing NextAuth cookies immediately');
544
-
545
- cookiesToClear.forEach(cookieName => {
546
- try {
547
- const expiredDate = 'Thu, 01 Jan 1970 00:00:00 GMT';
548
- const clearPatterns = [
549
- `${cookieName}=; expires=${expiredDate}; path=/`,
550
- `${cookieName}=; expires=${expiredDate}; path=/; domain=${window.location.hostname}`,
551
- `${cookieName}=; expires=${expiredDate}; path=/; domain=.${window.location.hostname}`,
552
- ];
553
-
554
- if (window.location.protocol === 'https:') {
555
- clearPatterns.push(
556
- `${cookieName}=; expires=${expiredDate}; path=/; secure`,
557
- `${cookieName}=; expires=${expiredDate}; path=/; domain=${window.location.hostname}; secure`,
558
- `${cookieName}=; expires=${expiredDate}; path=/; domain=.${window.location.hostname}; secure`
559
- );
560
- }
561
-
562
- clearPatterns.forEach(pattern => {
563
- document.cookie = pattern;
564
- });
565
- } catch (cookieError) {
566
- authLogger.warn(`Failed to clear cookie ${cookieName}:`, cookieError);
567
- }
568
- });
569
- }
570
-
571
- // Step 6: Force Better Auth signOut (this should clear the session cookie)
572
- await authClient.signOut();
573
-
574
- authLogger.info('[AuthStore] Force logout completed, redirecting to login');
575
-
576
- // Step 7: Longer delay to ensure everything is processed
577
- await new Promise(resolve => setTimeout(resolve, 1000));
578
-
579
- // Step 8: Force redirect with cache busting - use window.location.href for immediate effect
580
- const loginUrl = `/account-auth/login?error=SessionExpired&reason=${reason}&t=${Date.now()}`;
581
- authLogger.info('[AuthStore] Redirecting to login:', loginUrl);
582
-
583
- // Double redirect approach to ensure it works
584
- window.location.replace(loginUrl);
585
- window.location.href = loginUrl;
586
-
587
- } catch (error) {
588
- authLogger.error('[AuthStore] Error during force logout:', error);
589
-
590
- // Fallback: still redirect even if cleanup fails
591
- const loginUrl = `/account-auth/login?error=ForceLogoutError&reason=${reason}&t=${Date.now()}`;
592
- window.location.replace(loginUrl);
593
- }
594
- },
595
-
596
- // ===============================
597
- // API CALLING WITH AUTO TOKEN REFRESH
598
- // ===============================
599
-
600
- // Single centralized refresh method with coordinated server-side coordination
601
- refreshTokens: async (): Promise<void> => {
602
- const state = get();
603
-
604
- // If already refreshing, wait for it to complete
605
- if (state.isRefreshingToken) {
606
- authLogger.info('[AuthStore] Client-side token refresh already in progress, waiting for completion');
607
-
608
- // Wait for the refresh to complete by polling the flag
609
- const maxWaitMs = 15000; // 15 seconds max wait
610
- const startTime = Date.now();
611
-
612
- while (get().isRefreshingToken && (Date.now() - startTime) < maxWaitMs) {
613
- await new Promise(resolve => setTimeout(resolve, 100));
614
- }
615
-
616
- // Check if refresh was successful by verifying we still have a valid session
617
- const updatedState = get();
618
- if (updatedState.isRefreshingToken) {
619
- authLogger.warn('[AuthStore] Client-side token refresh timed out');
620
- throw new Error('Token refresh timed out - another refresh operation is stuck');
621
- }
622
-
623
- if (!updatedState.isAuthenticated || !updatedState.session) {
624
- authLogger.error('[AuthStore] Token refresh failed - session invalid after refresh');
625
- throw new Error('Token refresh failed - session invalid after refresh');
626
- }
627
-
628
- authLogger.info('[AuthStore] Token refresh completed by another caller');
629
- return;
630
- }
631
-
632
- // Set refreshing flag for client-side coordination
633
- set({ isRefreshingToken: true });
634
- authLogger.info('[AuthStore] Starting coordinated token refresh');
635
-
636
- try {
637
- // COORDINATED REFRESH: The server now handles distributed locking
638
- // We just need to make the refresh call and let the server coordinate
639
-
640
- // PAY-2217: the session token is NOT read from Better Auth's getSession() any more. The HTTP body no
641
- // longer carries it (wrapBetterAuthHandler), and this read was already dead: getSession() returns
642
- // { session: { token } }, never a top-level `sessionToken`, so the 3 x 150 ms retry loop that used to sit
643
- // here always ran to exhaustion. The server resolves the session from the cookie and REJECTS an
644
- // x-session-token header when it cannot (refresh.ts, 246007), so the header is advisory at best.
645
- const resolveSessionTokenForHeader = async (): Promise<string | undefined> =>
646
- get().session?.sessionToken as string | undefined;
647
-
648
- const sessionTokenHeader = await resolveSessionTokenForHeader();
649
-
650
- const refreshResponse = await fetch('/api/auth/refresh', {
651
- method: 'POST',
652
- headers: {
653
- 'Content-Type': 'application/json',
654
- 'X-Client-Refresh': 'true',
655
- 'X-Request-Source': 'authStore',
656
- ...(sessionTokenHeader ? { 'X-Session-Token': sessionTokenHeader } : {})
657
- },
658
- credentials: 'include'
659
- });
660
-
661
- if (!refreshResponse.ok) {
662
- if (refreshResponse.status === 429 || refreshResponse.status === 409) {
663
- const retryAfter = refreshResponse.headers.get('Retry-After');
664
- const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : 2000;
665
- authLogger.info('[AuthStore] Server coordinated refresh in progress, waiting and retrying', {
666
- status: refreshResponse.status,
667
- retryAfterMs: waitMs
668
- });
669
- await new Promise(resolve => setTimeout(resolve, Math.min(waitMs, 5000)));
670
- await get().rehydrateSessionAfterRefresh();
671
- authLogger.info('[AuthStore] Successfully coordinated with server-side refresh');
672
- return;
673
- }
674
-
675
- const refreshError = await refreshResponse.json().catch(() => ({ message: 'Token refresh failed' }));
676
- authLogger.error('[AuthStore] Token refresh failed:', refreshError);
677
-
678
- set({
679
- session: null,
680
- accessToken: null,
681
- refreshToken: null,
682
- isAuthenticated: false,
683
- user: null
684
- });
685
-
686
- await get().forceLogoutAndRedirect('TokenRefreshFailed');
687
- throw new Error(`Token refresh failed: ${refreshError.message}`);
688
- }
689
-
690
- authLogger.info('[AuthStore] Coordinated token refresh successful on backend, rehydrating session');
691
- await get().rehydrateSessionAfterRefresh();
692
-
693
- } catch (error) {
694
- authLogger.error('[AuthStore] Coordinated token refresh exception:', error);
695
-
696
- const errorMessage = error instanceof Error ? error.message : String(error);
697
- const isCoordinationError = errorMessage.includes('coordination') ||
698
- errorMessage.includes('timeout') ||
699
- errorMessage.includes('another refresh');
700
-
701
- if (!isCoordinationError) {
702
- set({
703
- session: null,
704
- accessToken: null,
705
- refreshToken: null,
706
- isAuthenticated: false,
707
- user: null
708
- });
709
- await get().forceLogoutAndRedirect('TokenRefreshException');
710
- }
711
-
712
- throw error;
713
- } finally {
714
- set({ isRefreshingToken: false });
715
- }
716
- },
717
-
718
- // Atomic session rehydration after token refresh
719
- // This ensures the authStore gets the fresh tokens from the session store
720
- rehydrateSessionAfterRefresh: async (): Promise<void> => {
721
- const maxRetries = 3;
722
- const retryDelay = 500; // 500ms between retries
723
-
724
- authLogger.info('[AuthStore] Starting session rehydration after token refresh');
725
-
726
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
727
- try {
728
- // Force Better Auth to reload the session
729
- authLogger.debug(`[AuthStore] Rehydration attempt ${attempt}/${maxRetries}`);
730
-
731
- // Force session refresh via Better Auth
732
- const { data: freshSession } = await authClient.getSession();
733
-
734
- if (!freshSession) {
735
- throw new Error('No session returned from Better Auth after refresh');
736
- }
737
-
738
- if (!(freshSession as any).accessToken) {
739
- throw new Error('Fresh session missing access token');
740
- }
741
-
742
- // Verify the token is actually fresh (not expired)
743
- try {
744
- const { jwtDecode } = await import('@/lib/jwt-decode');
745
- const decoded = jwtDecode((freshSession as any).accessToken);
746
-
747
- if (!decoded?.exp) {
748
- throw new Error('Fresh token missing expiration claim');
749
- }
750
-
751
- const tokenExpiry = decoded.exp * 1000;
752
- const now = Date.now();
753
- const timeUntilExpiry = tokenExpiry - now;
754
-
755
- // Token should be fresh (not expiring in next 5 minutes)
756
- if (timeUntilExpiry < (5 * 60 * 1000)) {
757
- throw new Error(`Fresh token still expires soon: ${timeUntilExpiry}ms`);
758
- }
759
-
760
- authLogger.info('[AuthStore] Session rehydration successful', {
761
- attempt,
762
- tokenExpiresAt: new Date(tokenExpiry).toISOString(),
763
- timeUntilExpiry: `${Math.round(timeUntilExpiry / 1000)}s`,
764
- subject: decoded.sub
765
- });
766
-
767
- // Update the authStore with the fresh session atomically
768
- get().setSession(freshSession);
769
-
770
- authLogger.info('[AuthStore] AuthStore updated with fresh tokens');
771
- return; // Success!
772
-
773
- } catch (tokenError) {
774
- throw new Error(`Token validation failed: ${tokenError instanceof Error ? tokenError.message : String(tokenError)}`);
775
- }
776
-
777
- } catch (error) {
778
- const errorMessage = error instanceof Error ? error.message : String(error);
779
- authLogger.warn(`[AuthStore] Rehydration attempt ${attempt} failed:`, errorMessage);
780
-
781
- // If this is the last attempt, throw the error
782
- if (attempt === maxRetries) {
783
- authLogger.error('[AuthStore] Session rehydration failed after all retries');
784
- throw new Error(`Session rehydration failed after ${maxRetries} attempts: ${errorMessage}`);
785
- }
786
-
787
- // Wait before retrying
788
- authLogger.debug(`[AuthStore] Waiting ${retryDelay}ms before retry ${attempt + 1}`);
789
- await new Promise(resolve => setTimeout(resolve, retryDelay));
790
- }
791
- }
792
- },
793
-
794
- apiCall: async <T = any>(url: string, options: RequestInit = {}, maxRetries = 3): Promise<T> => {
795
- // UNIVERSAL RETRY WRAPPER: Handle 503 retries at the store level
796
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
797
- try {
798
- return await get().makeApiCall<T>(url, options, attempt);
799
- } catch (error: any) {
800
- const isRetryableError = error.isRetryable || error.message?.includes('Token refresh in progress');
801
-
802
- if (isRetryableError && attempt < maxRetries) {
803
- const retryAfter = error.retryAfter || 1;
804
- authLogger.info(`[AuthStore] API call attempt ${attempt} failed with retryable error, retrying in ${retryAfter}s`, {
805
- url,
806
- error: error.message,
807
- attempt,
808
- maxRetries
809
- });
810
- await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
811
- continue;
812
- }
813
-
814
- // Not retryable or max attempts reached
815
- throw error;
816
- }
817
- }
818
-
819
- // This should never be reached but TypeScript needs it
820
- throw new Error('API call failed after all retry attempts');
821
- },
822
-
823
- makeApiCall: async <T = any>(url: string, options: RequestInit = {}, attempt = 1): Promise<T> => {
824
- const state = get();
825
-
826
- // COORDINATED AUTH: Check authentication and refresh coordination
827
- if (!state.isAuthenticated || !state.session?.sessionToken || !state.user?.id) {
828
- throw new Error('Not authenticated');
829
- }
830
-
831
- // COORDINATED REFRESH: Check if token refresh is in progress
832
- if (state.isRefreshingToken) {
833
- authLogger.info('[AuthStore] API call detected refresh in progress, waiting for completion', { url, attempt });
834
-
835
- // Wait for refresh to complete before making API call
836
- const maxWaitMs = 10000; // 10 seconds max wait
837
- const startTime = Date.now();
838
-
839
- while (get().isRefreshingToken && (Date.now() - startTime) < maxWaitMs) {
840
- await new Promise(resolve => setTimeout(resolve, 100));
841
- }
842
-
843
- const updatedState = get();
844
- if (updatedState.isRefreshingToken) {
845
- authLogger.warn('[AuthStore] API call timed out waiting for refresh', { url, attempt });
846
- throw new Error('Request failed - token refresh in progress');
847
- }
848
-
849
- if (!updatedState.isAuthenticated || !updatedState.session) {
850
- authLogger.error('[AuthStore] Session lost during refresh wait', { url, attempt });
851
- throw new Error('Authentication lost during token refresh');
852
- }
853
-
854
- authLogger.info('[AuthStore] API call proceeding after refresh completion', { url, attempt });
855
- }
856
-
857
- // COORDINATED API CALL: Make API call using NextAuth session cookies
858
- // Server-side handlers will coordinate any needed token refresh
859
- const response = await fetch(url, {
860
- ...options,
861
- credentials: 'include', // Ensure session cookies are sent
862
- headers: {
863
- 'Content-Type': 'application/json',
864
- 'X-Client-Call': 'true', // Indicate this is a client-initiated call
865
- 'X-Request-Source': 'authStore',
866
- ...options.headers,
867
- },
868
- });
869
-
870
- if (!response.ok) {
871
- // COORDINATED ERROR HANDLING: Handle various coordination scenarios
872
- if (response.status === 401) {
873
- authLogger.info('[AuthStore] Received 401, attempting coordinated refresh and retry', { url, attempt });
874
-
875
- // If not currently refreshing and we haven't retried yet, attempt refresh then retry once
876
- if (!get().isRefreshingToken && (attempt ?? 1) < 2) {
877
- try {
878
- await get().refreshTokens();
879
- authLogger.info('[AuthStore] Refresh complete, retrying API call once', { url, nextAttempt: (attempt ?? 1) + 1 });
880
- return await get().makeApiCall<T>(url, options, (attempt ?? 1) + 1);
881
- } catch (refreshError) {
882
- authLogger.error('[AuthStore] Refresh attempt failed after 401', refreshError);
883
- // fall through to force logout below
884
- }
885
- } else if (get().isRefreshingToken) {
886
- authLogger.info('[AuthStore] 401 received during in-progress refresh - treating as coordination issue');
887
- }
888
-
889
- // If we reach here, refresh was not attempted or failed; force logout
890
- await get().forceLogoutAndRedirect('ApiCall401');
891
- throw new Error('Authentication failed');
892
- }
893
-
894
- // Handle server coordination responses
895
- if (response.status === 503) {
896
- const retryAfter = response.headers.get('Retry-After');
897
- authLogger.info('[AuthStore] Received 503 (service unavailable), likely token refresh in progress', {
898
- url,
899
- retryAfter
900
- });
901
-
902
- // This is a retryable condition - let the calling code handle retries
903
- const error = new Error('Token refresh in progress');
904
- (error as any).retryAfter = parseInt(retryAfter || '1', 10);
905
- (error as any).isRetryable = true;
906
- throw error;
907
- }
908
-
909
- if (response.status === 429) {
910
- const retryAfter = response.headers.get('Retry-After');
911
- authLogger.info('[AuthStore] Received 429 (rate limit/coordination), server busy', {
912
- url,
913
- retryAfter
914
- });
915
- throw new Error(`Server busy - retry after ${retryAfter || '1'} second(s)`);
916
- }
917
-
918
- if (response.status === 409) {
919
- // Distinguish business conflict (e.g., duplicate email/username) from refresh coordination
920
- let body: any = null;
921
- try {
922
- body = await response.clone().json();
923
- } catch {}
924
-
925
- const code = body?.error?.code || body?.code;
926
- const message: string | undefined = body?.error?.message || body?.message;
927
- const msgLower = typeof message === 'string' ? message.toLowerCase() : '';
928
-
929
- // PayEz-standard conflicts we want to surface to the UI
930
- if (
931
- code === 'RESOURCE_CONFLICT' ||
932
- code === 'USERNAME_ALREADY_EXISTS' ||
933
- code === 'EMAIL_ALREADY_EXISTS' ||
934
- (msgLower.includes('duplicate') || msgLower.includes('already taken'))
935
- ) {
936
- const err = new Error(message || 'Resource conflict');
937
- (err as any).status = 409;
938
- (err as any).data = body;
939
- throw err;
940
- }
941
-
942
- // Otherwise treat as coordination conflict (e.g., refresh lock)
943
- authLogger.info('[AuthStore] Received 409 (conflict) without recognizable business code; treating as refresh coordination issue', { url });
944
- const err = new Error('Request conflict - token refresh in progress on server');
945
- (err as any).status = 409;
946
- (err as any).data = body;
947
- throw err;
948
- }
949
-
950
- // Handle other HTTP errors: throw an error with details
951
- const errorData = await response.json().catch(() => ({}));
952
- const errorMessage = errorData.message || errorData.error || response.statusText || 'Request failed';
953
- const error = new Error(errorMessage);
954
- (error as any).status = response.status;
955
- (error as any).data = errorData;
956
- throw error;
957
- }
958
-
959
- return await response.json();
960
- },
961
-
962
- // ===============================
963
- // DATA FETCHING ACTIONS
964
- // ===============================
965
-
966
- fetchUserStats: async (force = false) => {
967
- const state = get();
968
- const now = Date.now();
969
-
970
- // Check cache unless forced
971
- if (!force && state.userStats && state.userStatsLastFetch) {
972
- const age = now - state.userStatsLastFetch;
973
- if (age < CACHE_DURATION.USER_STATS) {
974
- authLogger.debug('[AuthStore] Using cached user stats');
975
- return;
976
- }
977
- }
978
-
979
- set({ isLoadingUserStats: true });
980
-
981
- try {
982
- const data = await get().apiCall('/api/activity/user-stats');
983
- set({
984
- userStats: data.data || data,
985
- userStatsLastFetch: now,
986
- isLoadingUserStats: false,
987
- });
988
- authLogger.debug('[AuthStore] User stats fetched successfully');
989
- } catch (error) {
990
- const errorMessage = error instanceof Error ? error.message : String(error);
991
-
992
- // Don't log errors if we're already signed out (forced logout scenario)
993
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
994
- authLogger.error('[AuthStore] Failed to fetch user stats:', error);
995
- }
996
-
997
- set({ isLoadingUserStats: false });
998
-
999
- // Don't throw if we're in a forced logout scenario
1000
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1001
- throw error;
1002
- }
1003
- }
1004
- },
1005
-
1006
- fetchClients: async (force = false) => {
1007
- const state = get();
1008
- const now = Date.now();
1009
-
1010
- // Check cache unless forced
1011
- if (!force && state.clients && state.clientsLastFetch) {
1012
- const age = now - state.clientsLastFetch;
1013
- if (age < CACHE_DURATION.CLIENTS) {
1014
- authLogger.debug('[AuthStore] Using cached clients');
1015
- return;
1016
- }
1017
- }
1018
-
1019
- set({ isLoadingClients: true });
1020
-
1021
- try {
1022
- const data = await get().apiCall('/api/admin/clients');
1023
- // API returns pure array - no envelope
1024
- set({
1025
- clients: Array.isArray(data) ? data : [],
1026
- clientsLastFetch: now,
1027
- isLoadingClients: false,
1028
- });
1029
- authLogger.debug('[AuthStore] Clients fetched successfully');
1030
- } catch (error) {
1031
- const errorMessage = error instanceof Error ? error.message : String(error);
1032
-
1033
- // Don't log errors if we're already signed out (forced logout scenario)
1034
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1035
- authLogger.error('[AuthStore] Failed to fetch clients:', error);
1036
- }
1037
-
1038
- set({ isLoadingClients: false });
1039
-
1040
- // Don't throw if we're in a forced logout scenario
1041
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1042
- throw error;
1043
- }
1044
- }
1045
- },
1046
-
1047
- fetchRoles: async (force = false) => {
1048
- const state = get();
1049
- const now = Date.now();
1050
-
1051
- // Check cache unless forced
1052
- if (!force && state.roles && state.rolesLastFetch) {
1053
- const age = now - state.rolesLastFetch;
1054
- if (age < CACHE_DURATION.ROLES) {
1055
- authLogger.debug('[AuthStore] Using cached roles');
1056
- return;
1057
- }
1058
- }
1059
-
1060
- set({ isLoadingRoles: true });
1061
-
1062
- try {
1063
- const data = await get().apiCall('/api/admin/roles');
1064
- // API returns pure array - no envelope
1065
- set({
1066
- roles: Array.isArray(data) ? data : [],
1067
- rolesLastFetch: now,
1068
- isLoadingRoles: false,
1069
- });
1070
- authLogger.debug('[AuthStore] Roles fetched successfully');
1071
- } catch (error) {
1072
- const errorMessage = error instanceof Error ? error.message : String(error);
1073
-
1074
- // Don't log errors if we're already signed out (forced logout scenario)
1075
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1076
- authLogger.error('[AuthStore] Failed to fetch roles:', error);
1077
- }
1078
-
1079
- set({ isLoadingRoles: false });
1080
-
1081
- // Don't throw if we're in a forced logout scenario
1082
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1083
- throw error;
1084
- }
1085
- }
1086
- },
1087
-
1088
- fetchUsers: async (params: any = {}, force = false) => {
1089
- const state = get();
1090
- const now = Date.now();
1091
-
1092
- // Check cache unless forced (users data changes frequently, shorter cache)
1093
- if (!force && state.users && state.usersLastFetch) {
1094
- const age = now - state.usersLastFetch;
1095
- if (age < CACHE_DURATION.USERS) {
1096
- authLogger.debug('[AuthStore] Using cached users');
1097
- return;
1098
- }
1099
- }
1100
-
1101
- set({ isLoadingUsers: true });
1102
-
1103
- try {
1104
- // Always use POST for /api/admin/users with appropriate payload
1105
- const url = `/api/admin/users`;
1106
- // Build proper UserGridRequest with all required fields
1107
- const gridParams = {
1108
- page_number: params.page || 1,
1109
- page_size: params.pageSize || 25,
1110
- search: "",
1111
- search_field: "",
1112
- sort_field: "",
1113
- sort_order: "",
1114
- status: null,
1115
- merchant_id: "",
1116
- client_assignment_status: null
1117
- };
1118
- const options = { method: 'POST', body: JSON.stringify(gridParams) };
1119
-
1120
- const data = await get().apiCall(url, options);
1121
-
1122
- // API returns pure array - no envelope
1123
- const users = Array.isArray(data) ? data : [];
1124
-
1125
- set({
1126
- users: Array.isArray(users) ? users : [],
1127
- usersLastFetch: now,
1128
- isLoadingUsers: false,
1129
- });
1130
- authLogger.debug('[AuthStore] Users fetched successfully');
1131
- } catch (error) {
1132
- const errorMessage = error instanceof Error ? error.message : String(error);
1133
- authLogger.error('[AuthStore] Failed to fetch users:', error);
1134
- set({ isLoadingUsers: false });
1135
-
1136
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1137
- throw error;
1138
- }
1139
- }
1140
- },
1141
-
1142
- fetchUserDetails: async (userId: string, force = false) => {
1143
- const state = get();
1144
- const now = Date.now();
1145
-
1146
- // Check cache unless forced
1147
- const cacheKey = `user_${userId}`;
1148
- const lastFetch = state.userDetails[`${cacheKey}_lastFetch`];
1149
- if (!force && state.userDetails[userId] && lastFetch) {
1150
- const age = now - lastFetch;
1151
- if (age < CACHE_DURATION.USER_DETAILS) {
1152
- authLogger.debug(`[AuthStore] Using cached user details for ${userId}`);
1153
- return;
1154
- }
1155
- }
1156
-
1157
- set({
1158
- isLoadingUserDetails: { ...state.isLoadingUserDetails, [userId]: true }
1159
- });
1160
-
1161
- try {
1162
- const data = await get().apiCall(`/api/admin/users/${userId}`);
1163
-
1164
- set({
1165
- userDetails: {
1166
- ...state.userDetails,
1167
- [userId]: data.data || data,
1168
- [`${cacheKey}_lastFetch`]: now
1169
- },
1170
- isLoadingUserDetails: { ...state.isLoadingUserDetails, [userId]: false }
1171
- });
1172
- authLogger.debug(`[AuthStore] User details fetched for ${userId}`);
1173
- } catch (error) {
1174
- const errorMessage = error instanceof Error ? error.message : String(error);
1175
- authLogger.error(`[AuthStore] Failed to fetch user details for ${userId}:`, error);
1176
-
1177
- set({
1178
- isLoadingUserDetails: { ...state.isLoadingUserDetails, [userId]: false }
1179
- });
1180
-
1181
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1182
- throw error;
1183
- }
1184
- }
1185
- },
1186
-
1187
- fetchUserClientAuthorizations: async (userId: string, force = false) => {
1188
- const state = get();
1189
- const now = Date.now();
1190
- const cacheKey = `auth_${userId}`;
1191
-
1192
- // Check if already loading
1193
- if (state.isLoadingClientAuthorizations[userId]) {
1194
- return;
1195
- }
1196
-
1197
- // Check cache unless forced
1198
- if (!force && state.clientAuthorizations[userId]) {
1199
- authLogger.debug(`[AuthStore] Using cached client authorizations for ${userId}`);
1200
- return;
1201
- }
1202
-
1203
- set({
1204
- isLoadingClientAuthorizations: { ...state.isLoadingClientAuthorizations, [userId]: true }
1205
- });
1206
-
1207
- try {
1208
- const data = await get().apiCall(`/api/admin/users/client-authorizations?user_id=${userId}`);
1209
-
1210
- // Handle various response formats
1211
- let authorizations: any[] = [];
1212
- if (data.data) {
1213
- authorizations = data.data.authorizations || data.data || [];
1214
- } else {
1215
- authorizations = data.authorizations || data || [];
1216
- }
1217
-
1218
- set({
1219
- clientAuthorizations: {
1220
- ...state.clientAuthorizations,
1221
- [userId]: Array.isArray(authorizations) ? authorizations : []
1222
- },
1223
- isLoadingClientAuthorizations: { ...state.isLoadingClientAuthorizations, [userId]: false }
1224
- });
1225
- authLogger.debug(`[AuthStore] Client authorizations fetched for ${userId}`);
1226
- } catch (error) {
1227
- const errorMessage = error instanceof Error ? error.message : String(error);
1228
- authLogger.error(`[AuthStore] Failed to fetch client authorizations for ${userId}:`, error);
1229
-
1230
- set({
1231
- clientAuthorizations: {
1232
- ...state.clientAuthorizations,
1233
- [userId]: []
1234
- },
1235
- isLoadingClientAuthorizations: { ...state.isLoadingClientAuthorizations, [userId]: false }
1236
- });
1237
-
1238
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1239
- throw error;
1240
- }
1241
- }
1242
- },
1243
-
1244
- fetchUserRoleAssignments: async (userId: string, force = false) => {
1245
- const state = get();
1246
- const cacheKey = `assignments_${userId}`;
1247
-
1248
- if (state.isLoadingUserAssignments[userId]) {
1249
- return;
1250
- }
1251
-
1252
- if (!force && state.userAssignments[userId]) {
1253
- authLogger.debug(`[AuthStore] Using cached role assignments for ${userId}`);
1254
- return;
1255
- }
1256
-
1257
- set({
1258
- isLoadingUserAssignments: { ...state.isLoadingUserAssignments, [userId]: true }
1259
- });
1260
-
1261
- try {
1262
- const data = await get().apiCall(`/api/admin/users/role-assignments?user_id=${userId}`);
1263
-
1264
- let assignments: any[] = [];
1265
- if (data.data) {
1266
- assignments = data.data.assignments || data.data || [];
1267
- } else {
1268
- assignments = data.assignments || data || [];
1269
- }
1270
-
1271
- set({
1272
- userAssignments: {
1273
- ...state.userAssignments,
1274
- [userId]: Array.isArray(assignments) ? assignments : []
1275
- },
1276
- isLoadingUserAssignments: { ...state.isLoadingUserAssignments, [userId]: false }
1277
- });
1278
- authLogger.debug(`[AuthStore] Role assignments fetched for ${userId}`);
1279
- } catch (error) {
1280
- const errorMessage = error instanceof Error ? error.message : String(error);
1281
- authLogger.error(`[AuthStore] Failed to fetch role assignments for ${userId}:`, error);
1282
-
1283
- set({
1284
- userAssignments: {
1285
- ...state.userAssignments,
1286
- [userId]: []
1287
- },
1288
- isLoadingUserAssignments: { ...state.isLoadingUserAssignments, [userId]: false }
1289
- });
1290
-
1291
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1292
- throw error;
1293
- }
1294
- }
1295
- },
1296
-
1297
- fetchRoleCategories: async (force = false) => {
1298
- const state = get();
1299
- const now = Date.now();
1300
-
1301
- // Check cache unless forced (role categories are fairly stable)
1302
- if (!force && state.roleCategories && state.roleCategoriesLastFetch) {
1303
- const age = now - state.roleCategoriesLastFetch;
1304
- if (age < CACHE_DURATION.ROLE_CATEGORIES) {
1305
- authLogger.debug('[AuthStore] Using cached role categories');
1306
- return;
1307
- }
1308
- }
1309
-
1310
- set({ isLoadingRoleCategories: true });
1311
-
1312
- try {
1313
- const data = await get().apiCall('/api/admin/roles/categories');
1314
-
1315
- set({
1316
- roleCategories: Array.isArray(data) ? data : (data.data || []),
1317
- roleCategoriesLastFetch: now,
1318
- isLoadingRoleCategories: false,
1319
- });
1320
- authLogger.debug('[AuthStore] Role categories fetched successfully');
1321
- } catch (error) {
1322
- const errorMessage = error instanceof Error ? error.message : String(error);
1323
- authLogger.error('[AuthStore] Failed to fetch role categories:', error);
1324
-
1325
- set({ isLoadingRoleCategories: false });
1326
-
1327
- if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1328
- throw error;
1329
- }
1330
- }
1331
- },
1332
-
1333
- // ===============================
1334
- // CRUD OPERATIONS
1335
- // ===============================
1336
-
1337
- createUser: async (userData: any) => {
1338
- try {
1339
- const result = await get().apiCall('/api/admin/users', {
1340
- method: 'POST',
1341
- body: JSON.stringify(userData)
1342
- });
1343
-
1344
- // Invalidate users cache to force refresh
1345
- set({ usersLastFetch: null });
1346
- authLogger.info('[AuthStore] User created successfully');
1347
- return result;
1348
- } catch (error) {
1349
- authLogger.error('[AuthStore] Failed to create user:', error);
1350
- throw error;
1351
- }
1352
- },
1353
-
1354
- updateUser: async (userId: string, updates: any) => {
1355
- try {
1356
- const result = await get().apiCall(`/api/admin/users/${userId}`, {
1357
- method: 'PUT',
1358
- body: JSON.stringify(updates)
1359
- });
1360
-
1361
- // Invalidate relevant caches
1362
- const state = get();
1363
- set({
1364
- usersLastFetch: null,
1365
- userDetails: {
1366
- ...state.userDetails,
1367
- [userId]: undefined // Force refresh of this user's details
1368
- }
1369
- });
1370
-
1371
- authLogger.info(`[AuthStore] User ${userId} updated successfully`);
1372
- return result;
1373
- } catch (error) {
1374
- authLogger.error(`[AuthStore] Failed to update user ${userId}:`, error);
1375
- throw error;
1376
- }
1377
- },
1378
-
1379
- deleteUser: async (userId: string) => {
1380
- try {
1381
- await get().apiCall(`/api/admin/users/${userId}`, {
1382
- method: 'DELETE'
1383
- });
1384
-
1385
- // Clean up all related data for this user
1386
- const state = get();
1387
- const newUserDetails = { ...state.userDetails };
1388
- const newUserAssignments = { ...state.userAssignments };
1389
- const newClientAuthorizations = { ...state.clientAuthorizations };
1390
-
1391
- delete newUserDetails[userId];
1392
- delete newUserAssignments[userId];
1393
- delete newClientAuthorizations[userId];
1394
-
1395
- set({
1396
- usersLastFetch: null, // Force refresh
1397
- userDetails: newUserDetails,
1398
- userAssignments: newUserAssignments,
1399
- clientAuthorizations: newClientAuthorizations
1400
- });
1401
-
1402
- authLogger.info(`[AuthStore] User ${userId} deleted successfully`);
1403
- } catch (error) {
1404
- authLogger.error(`[AuthStore] Failed to delete user ${userId}:`, error);
1405
- throw error;
1406
- }
1407
- },
1408
-
1409
- createRole: async (roleData: any) => {
1410
- try {
1411
- const result = await get().apiCall('/api/admin/roles', {
1412
- method: 'POST',
1413
- body: JSON.stringify(roleData)
1414
- });
1415
-
1416
- // Invalidate roles cache
1417
- set({ rolesLastFetch: null });
1418
- authLogger.info('[AuthStore] Role created successfully');
1419
- return result;
1420
- } catch (error) {
1421
- authLogger.error('[AuthStore] Failed to create role:', error);
1422
- throw error;
1423
- }
1424
- },
1425
-
1426
- updateRole: async (roleId: string, updates: any) => {
1427
- try {
1428
- const result = await get().apiCall(`/api/admin/roles/${roleId}`, {
1429
- method: 'PUT',
1430
- body: JSON.stringify(updates)
1431
- });
1432
-
1433
- set({ rolesLastFetch: null });
1434
- authLogger.info(`[AuthStore] Role ${roleId} updated successfully`);
1435
- return result;
1436
- } catch (error) {
1437
- authLogger.error(`[AuthStore] Failed to update role ${roleId}:`, error);
1438
- throw error;
1439
- }
1440
- },
1441
-
1442
- deleteRole: async (roleId: string) => {
1443
- try {
1444
- await get().apiCall(`/api/admin/roles/${roleId}`, {
1445
- method: 'DELETE'
1446
- });
1447
-
1448
- set({ rolesLastFetch: null });
1449
- authLogger.info(`[AuthStore] Role ${roleId} deleted successfully`);
1450
- } catch (error) {
1451
- authLogger.error(`[AuthStore] Failed to delete role ${roleId}:`, error);
1452
- throw error;
1453
- }
1454
- },
1455
-
1456
- assignUserToRole: async (userId: string, roleId: string) => {
1457
- try {
1458
- await get().apiCall('/api/admin/users/assign-role', {
1459
- method: 'POST',
1460
- body: JSON.stringify({ userId, roleId })
1461
- });
1462
-
1463
- // Invalidate user assignments cache
1464
- const state = get();
1465
- set({
1466
- userAssignments: {
1467
- ...state.userAssignments,
1468
- [userId]: undefined // Force refresh
1469
- },
1470
- usersLastFetch: null // Also refresh users list
1471
- });
1472
-
1473
- authLogger.info(`[AuthStore] User ${userId} assigned to role ${roleId}`);
1474
- } catch (error) {
1475
- authLogger.error(`[AuthStore] Failed to assign user ${userId} to role ${roleId}:`, error);
1476
- throw error;
1477
- }
1478
- },
1479
-
1480
- removeUserFromRole: async (userId: string, roleId: string) => {
1481
- try {
1482
- await get().apiCall('/api/admin/users/remove-role', {
1483
- method: 'POST',
1484
- body: JSON.stringify({ userId, roleId })
1485
- });
1486
-
1487
- // Invalidate user assignments cache
1488
- const state = get();
1489
- set({
1490
- userAssignments: {
1491
- ...state.userAssignments,
1492
- [userId]: undefined // Force refresh
1493
- },
1494
- usersLastFetch: null // Also refresh users list
1495
- });
1496
-
1497
- authLogger.info(`[AuthStore] User ${userId} removed from role ${roleId}`);
1498
- } catch (error) {
1499
- authLogger.error(`[AuthStore] Failed to remove user ${userId} from role ${roleId}:`, error);
1500
- throw error;
1501
- }
1502
- },
1503
-
1504
- assignUserToClient: async (userId: string, clientId: string) => {
1505
- try {
1506
- await get().apiCall('/api/admin/users/assign-client', {
1507
- method: 'POST',
1508
- body: JSON.stringify({ userId, clientId })
1509
- });
1510
-
1511
- // Invalidate user client authorizations cache
1512
- const state = get();
1513
- set({
1514
- clientAuthorizations: {
1515
- ...state.clientAuthorizations,
1516
- [userId]: undefined // Force refresh
1517
- },
1518
- usersLastFetch: null
1519
- });
1520
-
1521
- authLogger.info(`[AuthStore] User ${userId} assigned to client ${clientId}`);
1522
- } catch (error) {
1523
- authLogger.error(`[AuthStore] Failed to assign user ${userId} to client ${clientId}:`, error);
1524
- throw error;
1525
- }
1526
- },
1527
-
1528
- removeUserFromClient: async (userId: string, clientId: string) => {
1529
- try {
1530
- await get().apiCall('/api/admin/users/remove-client', {
1531
- method: 'POST',
1532
- body: JSON.stringify({ userId, clientId })
1533
- });
1534
-
1535
- // Invalidate user client authorizations cache
1536
- const state = get();
1537
- set({
1538
- clientAuthorizations: {
1539
- ...state.clientAuthorizations,
1540
- [userId]: undefined // Force refresh
1541
- },
1542
- usersLastFetch: null
1543
- });
1544
-
1545
- authLogger.info(`[AuthStore] User ${userId} removed from client ${clientId}`);
1546
- } catch (error) {
1547
- authLogger.error(`[AuthStore] Failed to remove user ${userId} from client ${clientId}:`, error);
1548
- throw error;
1549
- }
1550
- },
1551
-
1552
- // ===============================
1553
- // UTILITY ACTIONS
1554
- // ===============================
1555
-
1556
- hasRole: (role: string) => {
1557
- const state = get();
1558
- return state.user?.roles?.includes(role) || false;
1559
- },
1560
-
1561
- hasAnyRole: (roles: string[]) => {
1562
- const state = get();
1563
- if (!state.user?.roles) return false;
1564
- return roles.some(role => state.user!.roles.includes(role));
1565
- },
1566
-
1567
- hasAllRoles: (roles: string[]) => {
1568
- const state = get();
1569
- if (!state.user?.roles) return false;
1570
- return roles.every(role => state.user!.roles.includes(role));
1571
- },
1572
-
1573
- isFullyAuthenticated: () => {
1574
- const state = get();
1575
- return state.isAuthenticated &&
1576
- (!state.user?.requiresTwoFactor || state.user.twoFactorSessionVerified);
1577
- },
1578
-
1579
- // ===============================
1580
- // ERROR MANAGEMENT
1581
- // ===============================
1582
-
1583
- setError: (error: string | null) => {
1584
- set({ error });
1585
- },
1586
-
1587
- clearError: () => {
1588
- set({ error: null, tokenError: null });
1589
- },
1590
-
1591
- // ===============================
1592
- // ADMIN USER STATE MANAGEMENT ACTIONS
1593
- // ===============================
1594
-
1595
- approveUser: async (userId: string, reason?: string) => {
1596
- try {
1597
- await get().apiCall('/api/Admin/users/toggle-approval', {
1598
- method: 'POST',
1599
- body: JSON.stringify({
1600
- user_id: parseInt(userId),
1601
- is_approved: true,
1602
- reason: reason || 'Administrative approval'
1603
- })
1604
- });
1605
-
1606
- authLogger.info('[AuthStore] User approved:', { userId, reason });
1607
-
1608
- // The backend should broadcast the SignalR event, but we could also
1609
- // optimistically update local state here if needed
1610
- } catch (error) {
1611
- authLogger.error('[AuthStore] Failed to approve user:', error);
1612
- throw error;
1613
- }
1614
- },
1615
-
1616
- disapproveUser: async (userId: string, reason?: string) => {
1617
- try {
1618
- await get().apiCall('/api/Admin/users/toggle-approval', {
1619
- method: 'POST',
1620
- body: JSON.stringify({
1621
- user_id: parseInt(userId),
1622
- is_approved: false,
1623
- reason: reason || 'Administrative disapproval'
1624
- })
1625
- });
1626
- authLogger.info('[AuthStore] User disapproved:', { userId, reason });
1627
- } catch (error) {
1628
- authLogger.error('[AuthStore] Failed to disapprove user:', error);
1629
- throw error;
1630
- }
1631
- },
1632
-
1633
- pauseUser: async (userId: string, reason?: string) => {
1634
- try {
1635
- await get().apiCall('/api/Admin/users/pause', {
1636
- method: 'POST',
1637
- body: JSON.stringify({
1638
- user_id: parseInt(userId),
1639
- reason: reason || 'Administrative suspension'
1640
- })
1641
- });
1642
- authLogger.info('[AuthStore] User paused:', { userId, reason });
1643
- } catch (error) {
1644
- authLogger.error('[AuthStore] Failed to pause user:', error);
1645
- throw error;
1646
- }
1647
- },
1648
-
1649
- resumeUser: async (userId: string) => {
1650
- try {
1651
- await get().apiCall('/api/Admin/users/resume', {
1652
- method: 'POST',
1653
- body: JSON.stringify({
1654
- user_id: parseInt(userId)
1655
- })
1656
- });
1657
- authLogger.info('[AuthStore] User resumed:', { userId });
1658
- } catch (error) {
1659
- authLogger.error('[AuthStore] Failed to resume user:', error);
1660
- throw error;
1661
- }
1662
- },
1663
-
1664
- haltUser: async (userId: string, reason?: string) => {
1665
- try {
1666
- await get().apiCall('/api/Admin/users/halt', {
1667
- method: 'POST',
1668
- body: JSON.stringify({
1669
- user_id: parseInt(userId),
1670
- reason: reason || 'Security lockout'
1671
- })
1672
- });
1673
- authLogger.info('[AuthStore] User halted:', { userId, reason });
1674
- } catch (error) {
1675
- authLogger.error('[AuthStore] Failed to halt user:', error);
1676
- throw error;
1677
- }
1678
- },
1679
-
1680
- unlockUser: async (userId: string) => {
1681
- try {
1682
- await get().apiCall('/api/Admin/users/unlock', {
1683
- method: 'POST',
1684
- body: JSON.stringify({
1685
- user_id: parseInt(userId)
1686
- })
1687
- });
1688
- authLogger.info('[AuthStore] User unlocked:', { userId });
1689
- } catch (error) {
1690
- authLogger.error('[AuthStore] Failed to unlock user:', error);
1691
- throw error;
1692
- }
1693
- },
1694
-
1695
- // ===============================
1696
- // USER STATE UTILITY FUNCTIONS
1697
- // ===============================
1698
-
1699
- canUserAccess: () => {
1700
- const state = get();
1701
- if (!state.user) return false;
1702
-
1703
- // State priority: LOCKOUT > SUSPENSION > APPROVAL
1704
- if (state.user.lockoutEnabled) {
1705
- // Check if lockout has expired
1706
- if (state.user.lockoutEnd && new Date() > state.user.lockoutEnd) {
1707
- return true; // Lockout expired
1708
- }
1709
- return false; // Still locked
1710
- }
1711
-
1712
- if (state.user.isSuspended) {
1713
- return false; // Suspended
1714
- }
1715
-
1716
- return state.user.isApproved; // Must be approved
1717
- },
1718
-
1719
- getUserStateDisplay: () => {
1720
- const state = get();
1721
- if (!state.user) return 'Unknown';
1722
-
1723
- // State priority: LOCKOUT > SUSPENSION > APPROVAL
1724
- if (state.user.lockoutEnabled) {
1725
- if (state.user.lockoutEnd && new Date() > state.user.lockoutEnd) {
1726
- return 'Lockout Expired';
1727
- }
1728
- return 'Locked Out';
1729
- }
1730
-
1731
- if (state.user.isSuspended) {
1732
- return 'Suspended';
1733
- }
1734
-
1735
- return state.user.isApproved ? 'Approved' : 'Unapproved';
1736
- },
1737
-
1738
- isUserLocked: () => {
1739
- const state = get();
1740
- if (!state.user) return false;
1741
-
1742
- if (!state.user.lockoutEnabled) return false;
1743
-
1744
- // Check if lockout has expired
1745
- if (state.user.lockoutEnd && new Date() > state.user.lockoutEnd) {
1746
- return false; // Lockout expired
1747
- }
1748
-
1749
- return true; // Still locked
1750
- },
1751
-
1752
- // ===============================
1753
- // REAL-TIME SIGNALR MANAGEMENT
1754
- // ===============================
1755
-
1756
- initializeSignalR: async () => {
1757
- const state = get();
1758
-
1759
- // Don't initialize if already connected or not authenticated
1760
- if (state.signalrConnection || !state.isAuthenticated || !state.accessToken) {
1761
- authLogger.debug('[AuthStore] Skipping SignalR - already connected or not authenticated');
1762
- return;
1763
- }
1764
-
1765
- // Check if user has admin role for ActivityHub access
1766
- const hasAdminRole = state.user?.roles?.includes('payez_admin') || false;
1767
- if (!hasAdminRole) {
1768
- authLogger.info('[AuthStore] Skipping SignalR - user lacks admin role for ActivityHub');
1769
- return;
1770
- }
1771
-
1772
- authLogger.info('[AuthStore] Initializing SignalR connection');
1773
-
1774
- try {
1775
- // Use the existing signalRActivityService instead of creating a duplicate connection
1776
- // to the same health hub - this avoids connection errors flooding the console
1777
-
1778
- // Start the health service if not already running (with timeout)
1779
- const idpBaseUrl = process.env.IDP_URL;
1780
- if (!idpBaseUrl) {
1781
- throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
1782
- }
1783
- const startPromise = signalRActivityService.start(idpBaseUrl);
1784
- const timeoutPromise = new Promise((_, reject) =>
1785
- setTimeout(() => reject(new Error('SignalR start timeout')), 5000)
1786
- );
1787
-
1788
- await Promise.race([startPromise, timeoutPromise]);
1789
-
1790
- // Subscribe to health status changes from the dedicated health service
1791
- const unsubscribe = signalRActivityService.subscribe((status) => {
1792
- // Map health service status to our connection state
1793
- const connectionState = status.isHealthy ?
1794
- HubConnectionState.Connected :
1795
- status.message.includes('reconnecting') ?
1796
- HubConnectionState.Reconnecting :
1797
- HubConnectionState.Disconnected;
1798
-
1799
- authLogger.info('[AuthStore] Health status update:', {
1800
- isHealthy: status.isHealthy,
1801
- message: status.message,
1802
- connectionState
1803
- });
1804
-
1805
- set({
1806
- signalrConnectionState: connectionState,
1807
- isConnectedToSignalR: status.isHealthy
1808
- });
1809
- });
1810
-
1811
- // Store the unsubscribe function for cleanup
1812
- set({
1813
- signalrConnection: {
1814
- // Minimal connection-like interface that does nothing on stop()
1815
- // This allows existing code to still call connection.stop() without errors
1816
- stop: async () => {
1817
- unsubscribe();
1818
- authLogger.info('[AuthStore] Unsubscribed from health service');
1819
- }
1820
- } as any,
1821
- signalrConnectionState: signalRActivityService.getCurrentStatus().isHealthy ?
1822
- HubConnectionState.Connected : HubConnectionState.Disconnected,
1823
- isConnectedToSignalR: signalRActivityService.getCurrentStatus().isHealthy
1824
- });
1825
-
1826
- authLogger.info('[AuthStore] SignalR connection established via health service');
1827
-
1828
- } catch (error) {
1829
- // Don't let SignalR errors propagate up and potentially break login flow
1830
- const errorMessage = error instanceof Error ? error.message : String(error);
1831
-
1832
- // Only log as error if it's not a timeout or expected connection failure
1833
- if (errorMessage.includes('timeout') || errorMessage.includes('unavailable')) {
1834
- authLogger.info('[AuthStore] SignalR connection not available (expected in some environments):', errorMessage);
1835
- } else {
1836
- authLogger.error('[AuthStore] SignalR connection failed:', error);
1837
- }
1838
-
1839
- set({
1840
- signalrConnectionState: HubConnectionState.Disconnected,
1841
- isConnectedToSignalR: false
1842
- });
1843
- }
1844
- },
1845
-
1846
- disconnectSignalR: async () => {
1847
- const state = get();
1848
-
1849
- if (state.signalrConnection) {
1850
- authLogger.info('[AuthStore] Disconnecting SignalR');
1851
-
1852
- try {
1853
- await state.signalrConnection.stop();
1854
- } catch (error) {
1855
- authLogger.error('[AuthStore] Error disconnecting SignalR:', error);
1856
- }
1857
-
1858
- set({
1859
- signalrConnection: null,
1860
- signalrConnectionState: HubConnectionState.Disconnected,
1861
- isConnectedToSignalR: false
1862
- });
1863
- }
1864
- },
1865
-
1866
- handleUserStateChanged: (data: UserStateChangeEvent) => {
1867
- // This method was designed for UserStateChanged events that don't exist in current backend
1868
- // Keeping for future reference or if we implement proper user state change events
1869
- const state = get();
1870
-
1871
- // If this event is about the current user, update their state
1872
- if (data.userId === state.user?.id && state.session) {
1873
- // PAY-1427: the event is user state; log its shape, not its contents.
1874
- authLogger.info('[AuthStore] Updating current user state from SignalR:', { keys: Object.keys(data || {}) });
1875
- // ... user state update logic would go here
1876
- }
1877
- },
1878
- }),
1879
- {
1880
- name: 'auth-store',
1881
- }
1882
- )
1883
- );
1884
-
1885
- // ===============================
1886
- // STORE INITIALIZATION HELPER
1887
- // ===============================
1888
-
1889
- /**
1890
- * Initialize the auth store with a session (typically called in layout)
1891
- */
1892
- export const initializeAuthStore = (session: AppSession | null) => {
1893
- const store = useAuthStore.getState();
1894
- if (!store.isInitialized) {
1895
- authLogger.debug('[AuthStore] Initializing with session:', { hasSession: !!session });
1896
- store.setSession(session);
1897
- }
1898
- };
1899
-
1900
- export default useAuthStore;
1
+ /**
2
+ * 🚀 CENTRALIZED AUTH STORE - THE SINGLE SOURCE OF TRUTH
3
+ *
4
+ * This Zustand store replaces ALL scattered useState patterns for auth-related state.
5
+ * No more prop drilling, no more duplicate loading states, no more auth chaos.
6
+ *
7
+ * Features:
8
+ * - Centralized session, token, and user state
9
+ * - Built-in API calling with auto token refresh
10
+ * - Loading state management for all async operations
11
+ * - Type-safe throughout
12
+ * - Integrates seamlessly with existing NextAuth
13
+ */
14
+
15
+ import { create } from 'zustand';
16
+ import { devtools } from 'zustand/middleware';
17
+ import { authClient } from '../client/better-auth-client';
18
+ import { AppSession, isValidSession, sanitizeSession } from '../lib/session';
19
+ import { authLogger } from '../config/logger';
20
+ import { ENV_CONFIG } from '../config/env';
21
+ import { HubConnectionBuilder, HubConnection, HubConnectionState } from '@microsoft/signalr';
22
+ import { signalRActivityService } from '../services/signalrActivityService';
23
+ import {
24
+ getSessionCookieName,
25
+ getSecureSessionCookieName,
26
+ getCsrfCookieName,
27
+ getSecureCsrfCookieName
28
+ } from '../lib/app-slug';
29
+ import { tokenLogId } from '../lib/token-log-id';
30
+
31
+ // ===============================
32
+ // INTERFACES & TYPES
33
+ // ===============================
34
+
35
+ export interface User {
36
+ id: string;
37
+ email: string;
38
+ roles: string[];
39
+ twoFactorSessionVerified: boolean;
40
+ requiresTwoFactor: boolean;
41
+ twoFactorMethod?: string;
42
+ authenticationMethods?: string[];
43
+ authenticationLevel?: string;
44
+
45
+ // Administrative States (from user_state_management.md)
46
+ isApproved: boolean;
47
+ isSuspended: boolean;
48
+ lockoutEnabled: boolean;
49
+ lockoutEnd?: Date | null;
50
+
51
+ // Suspension Metadata
52
+ pausedAt?: Date | null;
53
+ pausedBy?: string | null;
54
+ suspensionReason?: string | null;
55
+ }
56
+
57
+ // SignalR Event Types
58
+ export interface UserStateChangeEvent {
59
+ userId: string;
60
+ action: 'APPROVE' | 'DISAPPROVE' | 'PAUSE' | 'RESUME' | 'HALT' | 'UNLOCK';
61
+ newState: {
62
+ isApproved?: boolean;
63
+ isSuspended?: boolean;
64
+ lockoutEnabled?: boolean;
65
+ lockoutEnd?: string | null;
66
+ pausedAt?: string | null;
67
+ pausedBy?: string | null;
68
+ suspensionReason?: string | null;
69
+ };
70
+ reason?: string;
71
+ changedBy: string;
72
+ timestamp: string;
73
+ }
74
+
75
+ export interface SecurityNotificationEvent {
76
+ type: 'USER_LOCKOUT' | 'IP_THROTTLE' | 'BRUTE_FORCE' | 'DISTRIBUTED_ATTACK';
77
+ userId?: string;
78
+ ipAddress?: string;
79
+ message: string;
80
+ severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
81
+ timestamp: string;
82
+ }
83
+
84
+ export interface AuthState {
85
+ // Core Auth State
86
+ session: AppSession | null;
87
+ user: User | null;
88
+ accessToken: string | null;
89
+ refreshToken: string | null;
90
+ isAuthenticated: boolean;
91
+ isInitialized: boolean;
92
+
93
+ // Loading States (replaces useState hell)
94
+ isLoading: boolean;
95
+ isRefreshingToken: boolean;
96
+
97
+ // Data Loading States
98
+ isLoadingUserStats: boolean;
99
+ isLoadingClients: boolean;
100
+ isLoadingRoles: boolean;
101
+ isLoadingUsers: boolean;
102
+ isLoadingUserDetails: Record<string, boolean>;
103
+ isLoadingRoleCategories: boolean;
104
+ isLoadingUserAssignments: Record<string, boolean>;
105
+ isLoadingClientAuthorizations: Record<string, boolean>;
106
+
107
+ // Error States
108
+ error: string | null;
109
+ tokenError: string | null;
110
+
111
+ // Cached Data (eliminates redundant API calls)
112
+ userStats: any | null;
113
+ clients: any[] | null;
114
+ roles: any[] | null;
115
+ users: any[] | null;
116
+ userDetails: Record<string, any>;
117
+ userAssignments: Record<string, any>;
118
+ roleCategories: any[] | null;
119
+ clientAuthorizations: Record<string, any[] | undefined>;
120
+
121
+ // Cache timestamps
122
+ userStatsLastFetch: number | null;
123
+ clientsLastFetch: number | null;
124
+ rolesLastFetch: number | null;
125
+ usersLastFetch: number | null;
126
+ roleCategoriesLastFetch: number | null;
127
+
128
+ // Real-time SignalR Connection
129
+ signalrConnection: HubConnection | null;
130
+ signalrConnectionState: HubConnectionState;
131
+ isConnectedToSignalR: boolean;
132
+ }
133
+
134
+ export interface AuthActions {
135
+ // Session Management
136
+ setSession: (session: AppSession | null) => void;
137
+ clearSession: () => void;
138
+ refreshSession: () => Promise<void>;
139
+ refreshTokens: () => Promise<void>;
140
+ rehydrateSessionAfterRefresh: () => Promise<void>;
141
+
142
+ // Authentication Actions
143
+ signIn: (credentials: { email: string; password: string }) => Promise<boolean>;
144
+ signOut: () => Promise<void>;
145
+ forceLogoutAndRedirect: (reason: string) => Promise<void>;
146
+
147
+ // API Actions with Built-in Token Management
148
+ apiCall: <T = any>(url: string, options?: RequestInit, maxRetries?: number) => Promise<T>;
149
+ makeApiCall: <T = any>(url: string, options?: RequestInit, attempt?: number) => Promise<T>;
150
+
151
+ // Data Fetching Actions (replaces useEffect + useState patterns)
152
+ fetchUserStats: (force?: boolean) => Promise<void>;
153
+ fetchClients: (force?: boolean) => Promise<void>;
154
+ fetchRoles: (force?: boolean) => Promise<void>;
155
+ fetchUsers: (params?: any, force?: boolean) => Promise<void>;
156
+ fetchUserDetails: (userId: string, force?: boolean) => Promise<void>;
157
+ fetchUserClientAuthorizations: (userId: string, force?: boolean) => Promise<void>;
158
+ fetchUserRoleAssignments: (userId: string, force?: boolean) => Promise<void>;
159
+ fetchRoleCategories: (force?: boolean) => Promise<void>;
160
+
161
+ // CRUD Operations (replaces direct API calls)
162
+ createUser: (userData: any) => Promise<any>;
163
+ updateUser: (userId: string, updates: any) => Promise<any>;
164
+ deleteUser: (userId: string) => Promise<void>;
165
+ createRole: (roleData: any) => Promise<any>;
166
+ updateRole: (roleId: string, updates: any) => Promise<any>;
167
+ deleteRole: (roleId: string) => Promise<void>;
168
+ assignUserToRole: (userId: string, roleId: string) => Promise<void>;
169
+ removeUserFromRole: (userId: string, roleId: string) => Promise<void>;
170
+ assignUserToClient: (userId: string, clientId: string) => Promise<void>;
171
+ removeUserFromClient: (userId: string, clientId: string) => Promise<void>;
172
+
173
+ // Utility Actions
174
+ hasRole: (role: string) => boolean;
175
+ hasAnyRole: (roles: string[]) => boolean;
176
+ hasAllRoles: (roles: string[]) => boolean;
177
+ isFullyAuthenticated: () => boolean;
178
+
179
+ // Error Management
180
+ setError: (error: string | null) => void;
181
+ clearError: () => void;
182
+
183
+ // Admin User State Management Actions
184
+ approveUser: (userId: string, reason?: string) => Promise<void>;
185
+ disapproveUser: (userId: string, reason?: string) => Promise<void>;
186
+ pauseUser: (userId: string, reason?: string) => Promise<void>;
187
+ resumeUser: (userId: string) => Promise<void>;
188
+ haltUser: (userId: string, reason?: string) => Promise<void>;
189
+ unlockUser: (userId: string) => Promise<void>;
190
+
191
+ // User State Utilities
192
+ canUserAccess: () => boolean;
193
+ getUserStateDisplay: () => string;
194
+ isUserLocked: () => boolean;
195
+
196
+ // Real-time SignalR Management
197
+ initializeSignalR: () => Promise<void>;
198
+ disconnectSignalR: () => Promise<void>;
199
+ handleUserStateChanged: (data: UserStateChangeEvent) => void;
200
+ }
201
+
202
+ export type AuthStore = AuthState & AuthActions;
203
+
204
+ // ===============================
205
+ // CACHE CONFIGURATION
206
+ // ===============================
207
+
208
+ const CACHE_DURATION = {
209
+ USER_STATS: 5 * 60 * 1000, // 5 minutes
210
+ CLIENTS: 10 * 60 * 1000, // 10 minutes
211
+ ROLES: 15 * 60 * 1000, // 15 minutes
212
+ USERS: 5 * 60 * 1000, // 5 minutes (dynamic data)
213
+ USER_DETAILS: 2 * 60 * 1000, // 2 minutes (detailed data)
214
+ ROLE_CATEGORIES: 30 * 60 * 1000, // 30 minutes (stable data)
215
+ USER_ASSIGNMENTS: 2 * 60 * 1000, // 2 minutes (assignment data changes frequently)
216
+ };
217
+
218
+ // ===============================
219
+ // ZUSTAND STORE IMPLEMENTATION
220
+ // ===============================
221
+
222
+ export const useAuthStore = create<AuthStore>()(
223
+ devtools(
224
+ (set, get) => ({
225
+ // ===============================
226
+ // INITIAL STATE
227
+ // ===============================
228
+ session: null,
229
+ user: null,
230
+ accessToken: null,
231
+ refreshToken: null,
232
+ isAuthenticated: false,
233
+ isInitialized: false,
234
+
235
+ // Loading States
236
+ isLoading: true,
237
+ isRefreshingToken: false,
238
+ isLoadingUserStats: false,
239
+ isLoadingClients: false,
240
+ isLoadingRoles: false,
241
+ isLoadingUsers: false,
242
+ isLoadingUserDetails: {},
243
+ isLoadingRoleCategories: false,
244
+ isLoadingUserAssignments: {},
245
+ isLoadingClientAuthorizations: {},
246
+
247
+ // Error States
248
+ error: null,
249
+ tokenError: null,
250
+
251
+ // Cached Data
252
+ userStats: null,
253
+ clients: null,
254
+ roles: null,
255
+ users: null,
256
+ userDetails: {},
257
+ userAssignments: {},
258
+ roleCategories: null,
259
+ clientAuthorizations: {},
260
+
261
+ // Cache Timestamps
262
+ userStatsLastFetch: null,
263
+ clientsLastFetch: null,
264
+ rolesLastFetch: null,
265
+ usersLastFetch: null,
266
+ roleCategoriesLastFetch: null,
267
+
268
+ // SignalR Connection State
269
+ signalrConnection: null,
270
+ signalrConnectionState: HubConnectionState.Disconnected,
271
+ isConnectedToSignalR: false,
272
+
273
+ // ===============================
274
+ // SESSION MANAGEMENT ACTIONS
275
+ // ===============================
276
+
277
+ setSession: (session: AppSession | null) => {
278
+ // CRITICAL FIX: Validate and sanitize the session before setting it
279
+ // This prevents storing sessions with empty user IDs or other invalid data
280
+ const cleanSession = sanitizeSession(session);
281
+
282
+ if (!cleanSession) {
283
+ // Session is invalid (empty userId, empty email, or missing accessToken)
284
+ authLogger.warn('[AuthStore] Rejecting invalid session', {
285
+ hasSession: false,
286
+ reason: 'invalid_or_partial_session',
287
+ hasIncomingSession: !!session,
288
+ incomingUserId: session?.user?.id || '(empty)',
289
+ incomingUserEmail: session?.user?.email || '(empty)',
290
+ hasAccessToken: !!(session as any)?.accessToken
291
+ });
292
+
293
+ // Clear the session state completely
294
+ set({
295
+ session: null,
296
+ user: null,
297
+ accessToken: null,
298
+ refreshToken: null,
299
+ isAuthenticated: false,
300
+ isInitialized: true,
301
+ isLoading: false,
302
+ error: 'Invalid session data',
303
+ });
304
+
305
+ return;
306
+ }
307
+
308
+ // Session is valid - proceed with setting it
309
+ authLogger.info('[AuthStore] Setting valid session:', {
310
+ hasSession: true,
311
+ userId: cleanSession.user?.id,
312
+ userEmail: cleanSession.user?.email
313
+ });
314
+
315
+ const user: User = {
316
+ id: cleanSession.user?.id || '',
317
+ email: cleanSession.user?.email || '',
318
+ roles: Array.isArray((cleanSession.user as any)?.roles) ? (cleanSession.user as any).roles : [],
319
+ twoFactorSessionVerified: (cleanSession.user as any)?.twoFactorSessionVerified || false,
320
+ requiresTwoFactor: (cleanSession.user as any)?.requiresTwoFactor || false,
321
+ twoFactorMethod: (cleanSession.user as any)?.twoFactorMethod,
322
+ authenticationMethods: (cleanSession.user as any).authenticationMethods,
323
+ authenticationLevel: (cleanSession.user as any).authenticationLevel,
324
+
325
+ // Administrative States (with safe defaults)
326
+ isApproved: (cleanSession.user as any).isApproved ?? true,
327
+ isSuspended: (cleanSession.user as any).isSuspended ?? false,
328
+ lockoutEnabled: (cleanSession.user as any).lockoutEnabled ?? false,
329
+ lockoutEnd: (cleanSession.user as any).lockoutEnd ? new Date((cleanSession.user as any).lockoutEnd) : null,
330
+
331
+ // Suspension Metadata
332
+ pausedAt: (cleanSession.user as any).pausedAt ? new Date((cleanSession.user as any).pausedAt) : null,
333
+ pausedBy: (cleanSession.user as any).pausedBy || null,
334
+ suspensionReason: (cleanSession.user as any).suspensionReason || null,
335
+ };
336
+
337
+ set({
338
+ session: cleanSession,
339
+ user,
340
+ accessToken: (cleanSession as any).accessToken || null,
341
+ refreshToken: (cleanSession as any).refreshToken || null,
342
+ // FIXED: Use strict validation - both accessToken AND user.id must be non-empty
343
+ isAuthenticated: true, // Already validated by sanitizeSession
344
+ isInitialized: true,
345
+ isLoading: false,
346
+ error: (cleanSession as any).error || null,
347
+ });
348
+
349
+ // Auto-initialize SignalR for authenticated users
350
+ // Use a longer delay and add error handling to prevent login blocking
351
+ setTimeout(async () => {
352
+ try {
353
+ await get().initializeSignalR();
354
+ } catch (error) {
355
+ // Don't let SignalR initialization errors block the login process
356
+ authLogger.warn('[AuthStore] SignalR initialization failed (non-blocking):', error);
357
+ }
358
+ }, 500); // Longer delay to ensure login flow completes first
359
+ },
360
+
361
+ clearSession: () => {
362
+ authLogger.debug('[AuthStore] Clearing session');
363
+
364
+ // Disconnect SignalR before clearing session
365
+ get().disconnectSignalR();
366
+
367
+ set({
368
+ session: null,
369
+ user: null,
370
+ accessToken: null,
371
+ refreshToken: null,
372
+ isAuthenticated: false,
373
+ error: null,
374
+ tokenError: null,
375
+ // Keep cache data but clear sensitive auth data
376
+ });
377
+ },
378
+
379
+ refreshSession: async () => {
380
+ const state = get();
381
+ if (!state.session?.sessionToken || !state.user?.id || state.isRefreshingToken) {
382
+ return;
383
+ }
384
+
385
+ set({ isRefreshingToken: true, tokenError: null });
386
+
387
+ try {
388
+ // Call refresh endpoint directly (no middleware chaos)
389
+ const stateBefore = get();
390
+ const refreshResponse = await fetch('/api/auth/refresh', {
391
+ method: 'POST',
392
+ headers: {
393
+ 'Content-Type': 'application/json',
394
+ 'X-Session-Token': stateBefore.session?.sessionToken || '',
395
+ 'X-Request-Source': 'authStore.refreshSession'
396
+ },
397
+ credentials: 'include'
398
+ });
399
+
400
+ if (!refreshResponse.ok) {
401
+ const refreshError = await refreshResponse.json().catch(() => ({ message: 'Token refresh failed' }));
402
+ throw new Error(refreshError.message || 'Token refresh failed');
403
+ }
404
+
405
+ // TODO: Get updated session from Redis after successful refresh (temp disabled for client build)
406
+ // For now, assume refresh was successful
407
+
408
+ // TODO: Update session with new tokens from Redis (temp disabled)
409
+ // For now, assume session tokens are already updated by the refresh endpoint
410
+
411
+ authLogger.info('[AuthStore] Token refresh successful via direct /api/session/refresh');
412
+ } catch (error) {
413
+ const errorMessage = error instanceof Error ? error.message : 'Token refresh failed';
414
+ authLogger.error('[AuthStore] Token refresh failed:', error);
415
+
416
+ set({
417
+ tokenError: errorMessage,
418
+ isAuthenticated: false,
419
+ });
420
+
421
+ // If refresh fails, sign out
422
+ await get().signOut();
423
+ } finally {
424
+ set({ isRefreshingToken: false });
425
+ }
426
+ },
427
+
428
+ // ===============================
429
+ // AUTHENTICATION ACTIONS
430
+ // ===============================
431
+
432
+ signIn: async (credentials: { email: string; password: string }) => {
433
+ set({ isLoading: true, error: null });
434
+
435
+ try {
436
+ // Use Better Auth signIn
437
+ const result = await authClient.signIn.email({
438
+ email: credentials.email,
439
+ password: credentials.password,
440
+ });
441
+
442
+ if (result?.data) {
443
+ authLogger.info('[AuthStore] Sign in successful');
444
+ return true;
445
+ } else {
446
+ const errorMessage = (result as any)?.error
447
+ ? (typeof (result as any).error === 'object' ? ((result as any).error as any).message : String((result as any).error))
448
+ : 'Sign in failed';
449
+ set({ error: errorMessage, isLoading: false });
450
+ return false;
451
+ }
452
+ } catch (error) {
453
+ const errorMessage = error instanceof Error ? error.message : 'Sign in failed';
454
+ authLogger.error('[AuthStore] Sign in error:', error);
455
+ set({ error: errorMessage, isLoading: false });
456
+ return false;
457
+ }
458
+ },
459
+
460
+ signOut: async () => {
461
+ authLogger.info('[AuthStore] Starting sign out process');
462
+
463
+ // Clear local state immediately
464
+ get().clearSession();
465
+
466
+ // Clear cached data
467
+ set({
468
+ userStats: null,
469
+ clients: null,
470
+ roles: null,
471
+ userStatsLastFetch: null,
472
+ clientsLastFetch: null,
473
+ rolesLastFetch: null,
474
+ });
475
+
476
+ try {
477
+ // Use Better Auth signOut
478
+ await authClient.signOut();
479
+ authLogger.info('[AuthStore] Sign out completed');
480
+ } catch (error) {
481
+ authLogger.error('[AuthStore] Sign out error:', error);
482
+ // Even if signOut fails, we've cleared local state
483
+ }
484
+ },
485
+
486
+ forceLogoutAndRedirect: async (reason: string) => {
487
+ const state = get();
488
+ // Same truncation convention as the server-side session logs (session-store.ts,
489
+ // refresh.ts): a session token is a bearer credential, and this line goes to the
490
+ // browser console (and whatever aggregates authLogger client-side) - full value
491
+ // was leftover debug logging (DotNetPert-Scout, mail 58678).
492
+ // PAY-1505: the ternary that used to stand here fell back to the RAW token on its false arm.
493
+ // Safe only by the unenforced property that the arm is reached when the token is falsy -
494
+ // exactly the shape the source guard exists to refuse. tokenLogId already answers 'none'
495
+ // for an absent token, so the fallback is not needed.
496
+ const sessionTokenPrefix = tokenLogId(state.session?.sessionToken);
497
+
498
+ // AGGRESSIVE LOGGING TO TRACK EXECUTION
499
+ console.error('🚨 FORCE LOGOUT INITIATED 🚨', {
500
+ reason,
501
+ userId: state.user?.id,
502
+ sessionToken: sessionTokenPrefix,
503
+ timestamp: new Date().toISOString()
504
+ });
505
+
506
+ authLogger.error('[AuthStore] Force logout initiated', {
507
+ reason,
508
+ userId: state.user?.id,
509
+ sessionToken: sessionTokenPrefix
510
+ });
511
+
512
+ try {
513
+ // TODO: Step 1: Mark session as force-invalidated in Redis (temp disabled for client build)
514
+
515
+ // Step 2: Clear local state immediately
516
+ get().clearSession();
517
+
518
+ // Step 3: Clear all cached data
519
+ set({
520
+ userStats: null,
521
+ clients: null,
522
+ roles: null,
523
+ userStatsLastFetch: null,
524
+ clientsLastFetch: null,
525
+ rolesLastFetch: null,
526
+ });
527
+
528
+ // Step 4: Clear session cache (temp disabled for client build)
529
+ // TODO: Re-enable when session-cache module is available
530
+ // const { SessionCache } = await import('@/lib/session-cache');
531
+ // SessionCache.clearCache();
532
+ // SessionCache.clearServerSessionData();
533
+
534
+ // Step 5: AGGRESSIVELY clear NextAuth cookies immediately before signOut (app-slug prefixed)
535
+ if (typeof document !== 'undefined') {
536
+ const cookiesToClear = [
537
+ getSessionCookieName(),
538
+ getSecureSessionCookieName(),
539
+ getCsrfCookieName(),
540
+ getSecureCsrfCookieName()
541
+ ];
542
+
543
+ authLogger.info('[AuthStore] Aggressively clearing NextAuth cookies immediately');
544
+
545
+ cookiesToClear.forEach(cookieName => {
546
+ try {
547
+ const expiredDate = 'Thu, 01 Jan 1970 00:00:00 GMT';
548
+ const clearPatterns = [
549
+ `${cookieName}=; expires=${expiredDate}; path=/`,
550
+ `${cookieName}=; expires=${expiredDate}; path=/; domain=${window.location.hostname}`,
551
+ `${cookieName}=; expires=${expiredDate}; path=/; domain=.${window.location.hostname}`,
552
+ ];
553
+
554
+ if (window.location.protocol === 'https:') {
555
+ clearPatterns.push(
556
+ `${cookieName}=; expires=${expiredDate}; path=/; secure`,
557
+ `${cookieName}=; expires=${expiredDate}; path=/; domain=${window.location.hostname}; secure`,
558
+ `${cookieName}=; expires=${expiredDate}; path=/; domain=.${window.location.hostname}; secure`
559
+ );
560
+ }
561
+
562
+ clearPatterns.forEach(pattern => {
563
+ document.cookie = pattern;
564
+ });
565
+ } catch (cookieError) {
566
+ authLogger.warn(`Failed to clear cookie ${cookieName}:`, cookieError);
567
+ }
568
+ });
569
+ }
570
+
571
+ // Step 6: Force Better Auth signOut (this should clear the session cookie)
572
+ await authClient.signOut();
573
+
574
+ authLogger.info('[AuthStore] Force logout completed, redirecting to login');
575
+
576
+ // Step 7: Longer delay to ensure everything is processed
577
+ await new Promise(resolve => setTimeout(resolve, 1000));
578
+
579
+ // Step 8: Force redirect with cache busting - use window.location.href for immediate effect
580
+ const loginUrl = `/account-auth/login?error=SessionExpired&reason=${reason}&t=${Date.now()}`;
581
+ authLogger.info('[AuthStore] Redirecting to login:', loginUrl);
582
+
583
+ // Double redirect approach to ensure it works
584
+ window.location.replace(loginUrl);
585
+ window.location.href = loginUrl;
586
+
587
+ } catch (error) {
588
+ authLogger.error('[AuthStore] Error during force logout:', error);
589
+
590
+ // Fallback: still redirect even if cleanup fails
591
+ const loginUrl = `/account-auth/login?error=ForceLogoutError&reason=${reason}&t=${Date.now()}`;
592
+ window.location.replace(loginUrl);
593
+ }
594
+ },
595
+
596
+ // ===============================
597
+ // API CALLING WITH AUTO TOKEN REFRESH
598
+ // ===============================
599
+
600
+ // Single centralized refresh method with coordinated server-side coordination
601
+ refreshTokens: async (): Promise<void> => {
602
+ const state = get();
603
+
604
+ // If already refreshing, wait for it to complete
605
+ if (state.isRefreshingToken) {
606
+ authLogger.info('[AuthStore] Client-side token refresh already in progress, waiting for completion');
607
+
608
+ // Wait for the refresh to complete by polling the flag
609
+ const maxWaitMs = 15000; // 15 seconds max wait
610
+ const startTime = Date.now();
611
+
612
+ while (get().isRefreshingToken && (Date.now() - startTime) < maxWaitMs) {
613
+ await new Promise(resolve => setTimeout(resolve, 100));
614
+ }
615
+
616
+ // Check if refresh was successful by verifying we still have a valid session
617
+ const updatedState = get();
618
+ if (updatedState.isRefreshingToken) {
619
+ authLogger.warn('[AuthStore] Client-side token refresh timed out');
620
+ throw new Error('Token refresh timed out - another refresh operation is stuck');
621
+ }
622
+
623
+ if (!updatedState.isAuthenticated || !updatedState.session) {
624
+ authLogger.error('[AuthStore] Token refresh failed - session invalid after refresh');
625
+ throw new Error('Token refresh failed - session invalid after refresh');
626
+ }
627
+
628
+ authLogger.info('[AuthStore] Token refresh completed by another caller');
629
+ return;
630
+ }
631
+
632
+ // Set refreshing flag for client-side coordination
633
+ set({ isRefreshingToken: true });
634
+ authLogger.info('[AuthStore] Starting coordinated token refresh');
635
+
636
+ try {
637
+ // COORDINATED REFRESH: The server now handles distributed locking
638
+ // We just need to make the refresh call and let the server coordinate
639
+
640
+ // PAY-2217: the session token is NOT read from Better Auth's getSession() any more. The HTTP body no
641
+ // longer carries it (wrapBetterAuthHandler), and this read was already dead: getSession() returns
642
+ // { session: { token } }, never a top-level `sessionToken`, so the 3 x 150 ms retry loop that used to sit
643
+ // here always ran to exhaustion. The server resolves the session from the cookie and REJECTS an
644
+ // x-session-token header when it cannot (refresh.ts, 246007), so the header is advisory at best.
645
+ const resolveSessionTokenForHeader = async (): Promise<string | undefined> =>
646
+ get().session?.sessionToken as string | undefined;
647
+
648
+ const sessionTokenHeader = await resolveSessionTokenForHeader();
649
+
650
+ const refreshResponse = await fetch('/api/auth/refresh', {
651
+ method: 'POST',
652
+ headers: {
653
+ 'Content-Type': 'application/json',
654
+ 'X-Client-Refresh': 'true',
655
+ 'X-Request-Source': 'authStore',
656
+ ...(sessionTokenHeader ? { 'X-Session-Token': sessionTokenHeader } : {})
657
+ },
658
+ credentials: 'include'
659
+ });
660
+
661
+ if (!refreshResponse.ok) {
662
+ if (refreshResponse.status === 429 || refreshResponse.status === 409) {
663
+ const retryAfter = refreshResponse.headers.get('Retry-After');
664
+ const waitMs = retryAfter ? parseInt(retryAfter) * 1000 : 2000;
665
+ authLogger.info('[AuthStore] Server coordinated refresh in progress, waiting and retrying', {
666
+ status: refreshResponse.status,
667
+ retryAfterMs: waitMs
668
+ });
669
+ await new Promise(resolve => setTimeout(resolve, Math.min(waitMs, 5000)));
670
+ await get().rehydrateSessionAfterRefresh();
671
+ authLogger.info('[AuthStore] Successfully coordinated with server-side refresh');
672
+ return;
673
+ }
674
+
675
+ const refreshError = await refreshResponse.json().catch(() => ({ message: 'Token refresh failed' }));
676
+ authLogger.error('[AuthStore] Token refresh failed:', refreshError);
677
+
678
+ set({
679
+ session: null,
680
+ accessToken: null,
681
+ refreshToken: null,
682
+ isAuthenticated: false,
683
+ user: null
684
+ });
685
+
686
+ await get().forceLogoutAndRedirect('TokenRefreshFailed');
687
+ throw new Error(`Token refresh failed: ${refreshError.message}`);
688
+ }
689
+
690
+ authLogger.info('[AuthStore] Coordinated token refresh successful on backend, rehydrating session');
691
+ await get().rehydrateSessionAfterRefresh();
692
+
693
+ } catch (error) {
694
+ authLogger.error('[AuthStore] Coordinated token refresh exception:', error);
695
+
696
+ const errorMessage = error instanceof Error ? error.message : String(error);
697
+ const isCoordinationError = errorMessage.includes('coordination') ||
698
+ errorMessage.includes('timeout') ||
699
+ errorMessage.includes('another refresh');
700
+
701
+ if (!isCoordinationError) {
702
+ set({
703
+ session: null,
704
+ accessToken: null,
705
+ refreshToken: null,
706
+ isAuthenticated: false,
707
+ user: null
708
+ });
709
+ await get().forceLogoutAndRedirect('TokenRefreshException');
710
+ }
711
+
712
+ throw error;
713
+ } finally {
714
+ set({ isRefreshingToken: false });
715
+ }
716
+ },
717
+
718
+ // Atomic session rehydration after token refresh
719
+ // This ensures the authStore gets the fresh tokens from the session store
720
+ rehydrateSessionAfterRefresh: async (): Promise<void> => {
721
+ const maxRetries = 3;
722
+ const retryDelay = 500; // 500ms between retries
723
+
724
+ authLogger.info('[AuthStore] Starting session rehydration after token refresh');
725
+
726
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
727
+ try {
728
+ // Force Better Auth to reload the session
729
+ authLogger.debug(`[AuthStore] Rehydration attempt ${attempt}/${maxRetries}`);
730
+
731
+ // Force session refresh via Better Auth
732
+ const { data: freshSession } = await authClient.getSession();
733
+
734
+ if (!freshSession) {
735
+ throw new Error('No session returned from Better Auth after refresh');
736
+ }
737
+
738
+ if (!(freshSession as any).accessToken) {
739
+ throw new Error('Fresh session missing access token');
740
+ }
741
+
742
+ // Verify the token is actually fresh (not expired)
743
+ try {
744
+ const { jwtDecode } = await import('@/lib/jwt-decode');
745
+ const decoded = jwtDecode((freshSession as any).accessToken);
746
+
747
+ if (!decoded?.exp) {
748
+ throw new Error('Fresh token missing expiration claim');
749
+ }
750
+
751
+ const tokenExpiry = decoded.exp * 1000;
752
+ const now = Date.now();
753
+ const timeUntilExpiry = tokenExpiry - now;
754
+
755
+ // Token should be fresh (not expiring in next 5 minutes)
756
+ if (timeUntilExpiry < (5 * 60 * 1000)) {
757
+ throw new Error(`Fresh token still expires soon: ${timeUntilExpiry}ms`);
758
+ }
759
+
760
+ authLogger.info('[AuthStore] Session rehydration successful', {
761
+ attempt,
762
+ tokenExpiresAt: new Date(tokenExpiry).toISOString(),
763
+ timeUntilExpiry: `${Math.round(timeUntilExpiry / 1000)}s`,
764
+ subject: decoded.sub
765
+ });
766
+
767
+ // Update the authStore with the fresh session atomically
768
+ get().setSession(freshSession);
769
+
770
+ authLogger.info('[AuthStore] AuthStore updated with fresh tokens');
771
+ return; // Success!
772
+
773
+ } catch (tokenError) {
774
+ throw new Error(`Token validation failed: ${tokenError instanceof Error ? tokenError.message : String(tokenError)}`);
775
+ }
776
+
777
+ } catch (error) {
778
+ const errorMessage = error instanceof Error ? error.message : String(error);
779
+ authLogger.warn(`[AuthStore] Rehydration attempt ${attempt} failed:`, errorMessage);
780
+
781
+ // If this is the last attempt, throw the error
782
+ if (attempt === maxRetries) {
783
+ authLogger.error('[AuthStore] Session rehydration failed after all retries');
784
+ throw new Error(`Session rehydration failed after ${maxRetries} attempts: ${errorMessage}`);
785
+ }
786
+
787
+ // Wait before retrying
788
+ authLogger.debug(`[AuthStore] Waiting ${retryDelay}ms before retry ${attempt + 1}`);
789
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
790
+ }
791
+ }
792
+ },
793
+
794
+ apiCall: async <T = any>(url: string, options: RequestInit = {}, maxRetries = 3): Promise<T> => {
795
+ // UNIVERSAL RETRY WRAPPER: Handle 503 retries at the store level
796
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
797
+ try {
798
+ return await get().makeApiCall<T>(url, options, attempt);
799
+ } catch (error: any) {
800
+ const isRetryableError = error.isRetryable || error.message?.includes('Token refresh in progress');
801
+
802
+ if (isRetryableError && attempt < maxRetries) {
803
+ const retryAfter = error.retryAfter || 1;
804
+ authLogger.info(`[AuthStore] API call attempt ${attempt} failed with retryable error, retrying in ${retryAfter}s`, {
805
+ url,
806
+ error: error.message,
807
+ attempt,
808
+ maxRetries
809
+ });
810
+ await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
811
+ continue;
812
+ }
813
+
814
+ // Not retryable or max attempts reached
815
+ throw error;
816
+ }
817
+ }
818
+
819
+ // This should never be reached but TypeScript needs it
820
+ throw new Error('API call failed after all retry attempts');
821
+ },
822
+
823
+ makeApiCall: async <T = any>(url: string, options: RequestInit = {}, attempt = 1): Promise<T> => {
824
+ const state = get();
825
+
826
+ // COORDINATED AUTH: Check authentication and refresh coordination
827
+ if (!state.isAuthenticated || !state.session?.sessionToken || !state.user?.id) {
828
+ throw new Error('Not authenticated');
829
+ }
830
+
831
+ // COORDINATED REFRESH: Check if token refresh is in progress
832
+ if (state.isRefreshingToken) {
833
+ authLogger.info('[AuthStore] API call detected refresh in progress, waiting for completion', { url, attempt });
834
+
835
+ // Wait for refresh to complete before making API call
836
+ const maxWaitMs = 10000; // 10 seconds max wait
837
+ const startTime = Date.now();
838
+
839
+ while (get().isRefreshingToken && (Date.now() - startTime) < maxWaitMs) {
840
+ await new Promise(resolve => setTimeout(resolve, 100));
841
+ }
842
+
843
+ const updatedState = get();
844
+ if (updatedState.isRefreshingToken) {
845
+ authLogger.warn('[AuthStore] API call timed out waiting for refresh', { url, attempt });
846
+ throw new Error('Request failed - token refresh in progress');
847
+ }
848
+
849
+ if (!updatedState.isAuthenticated || !updatedState.session) {
850
+ authLogger.error('[AuthStore] Session lost during refresh wait', { url, attempt });
851
+ throw new Error('Authentication lost during token refresh');
852
+ }
853
+
854
+ authLogger.info('[AuthStore] API call proceeding after refresh completion', { url, attempt });
855
+ }
856
+
857
+ // COORDINATED API CALL: Make API call using NextAuth session cookies
858
+ // Server-side handlers will coordinate any needed token refresh
859
+ const response = await fetch(url, {
860
+ ...options,
861
+ credentials: 'include', // Ensure session cookies are sent
862
+ headers: {
863
+ 'Content-Type': 'application/json',
864
+ 'X-Client-Call': 'true', // Indicate this is a client-initiated call
865
+ 'X-Request-Source': 'authStore',
866
+ ...options.headers,
867
+ },
868
+ });
869
+
870
+ if (!response.ok) {
871
+ // COORDINATED ERROR HANDLING: Handle various coordination scenarios
872
+ if (response.status === 401) {
873
+ authLogger.info('[AuthStore] Received 401, attempting coordinated refresh and retry', { url, attempt });
874
+
875
+ // If not currently refreshing and we haven't retried yet, attempt refresh then retry once
876
+ if (!get().isRefreshingToken && (attempt ?? 1) < 2) {
877
+ try {
878
+ await get().refreshTokens();
879
+ authLogger.info('[AuthStore] Refresh complete, retrying API call once', { url, nextAttempt: (attempt ?? 1) + 1 });
880
+ return await get().makeApiCall<T>(url, options, (attempt ?? 1) + 1);
881
+ } catch (refreshError) {
882
+ authLogger.error('[AuthStore] Refresh attempt failed after 401', refreshError);
883
+ // fall through to force logout below
884
+ }
885
+ } else if (get().isRefreshingToken) {
886
+ authLogger.info('[AuthStore] 401 received during in-progress refresh - treating as coordination issue');
887
+ }
888
+
889
+ // If we reach here, refresh was not attempted or failed; force logout
890
+ await get().forceLogoutAndRedirect('ApiCall401');
891
+ throw new Error('Authentication failed');
892
+ }
893
+
894
+ // Handle server coordination responses
895
+ if (response.status === 503) {
896
+ const retryAfter = response.headers.get('Retry-After');
897
+ authLogger.info('[AuthStore] Received 503 (service unavailable), likely token refresh in progress', {
898
+ url,
899
+ retryAfter
900
+ });
901
+
902
+ // This is a retryable condition - let the calling code handle retries
903
+ const error = new Error('Token refresh in progress');
904
+ (error as any).retryAfter = parseInt(retryAfter || '1', 10);
905
+ (error as any).isRetryable = true;
906
+ throw error;
907
+ }
908
+
909
+ if (response.status === 429) {
910
+ const retryAfter = response.headers.get('Retry-After');
911
+ authLogger.info('[AuthStore] Received 429 (rate limit/coordination), server busy', {
912
+ url,
913
+ retryAfter
914
+ });
915
+ throw new Error(`Server busy - retry after ${retryAfter || '1'} second(s)`);
916
+ }
917
+
918
+ if (response.status === 409) {
919
+ // Distinguish business conflict (e.g., duplicate email/username) from refresh coordination
920
+ let body: any = null;
921
+ try {
922
+ body = await response.clone().json();
923
+ } catch {}
924
+
925
+ const code = body?.error?.code || body?.code;
926
+ const message: string | undefined = body?.error?.message || body?.message;
927
+ const msgLower = typeof message === 'string' ? message.toLowerCase() : '';
928
+
929
+ // PayEz-standard conflicts we want to surface to the UI
930
+ if (
931
+ code === 'RESOURCE_CONFLICT' ||
932
+ code === 'USERNAME_ALREADY_EXISTS' ||
933
+ code === 'EMAIL_ALREADY_EXISTS' ||
934
+ (msgLower.includes('duplicate') || msgLower.includes('already taken'))
935
+ ) {
936
+ const err = new Error(message || 'Resource conflict');
937
+ (err as any).status = 409;
938
+ (err as any).data = body;
939
+ throw err;
940
+ }
941
+
942
+ // Otherwise treat as coordination conflict (e.g., refresh lock)
943
+ authLogger.info('[AuthStore] Received 409 (conflict) without recognizable business code; treating as refresh coordination issue', { url });
944
+ const err = new Error('Request conflict - token refresh in progress on server');
945
+ (err as any).status = 409;
946
+ (err as any).data = body;
947
+ throw err;
948
+ }
949
+
950
+ // Handle other HTTP errors: throw an error with details
951
+ const errorData = await response.json().catch(() => ({}));
952
+ const errorMessage = errorData.message || errorData.error || response.statusText || 'Request failed';
953
+ const error = new Error(errorMessage);
954
+ (error as any).status = response.status;
955
+ (error as any).data = errorData;
956
+ throw error;
957
+ }
958
+
959
+ return await response.json();
960
+ },
961
+
962
+ // ===============================
963
+ // DATA FETCHING ACTIONS
964
+ // ===============================
965
+
966
+ fetchUserStats: async (force = false) => {
967
+ const state = get();
968
+ const now = Date.now();
969
+
970
+ // Check cache unless forced
971
+ if (!force && state.userStats && state.userStatsLastFetch) {
972
+ const age = now - state.userStatsLastFetch;
973
+ if (age < CACHE_DURATION.USER_STATS) {
974
+ authLogger.debug('[AuthStore] Using cached user stats');
975
+ return;
976
+ }
977
+ }
978
+
979
+ set({ isLoadingUserStats: true });
980
+
981
+ try {
982
+ const data = await get().apiCall('/api/activity/user-stats');
983
+ set({
984
+ userStats: data.data || data,
985
+ userStatsLastFetch: now,
986
+ isLoadingUserStats: false,
987
+ });
988
+ authLogger.debug('[AuthStore] User stats fetched successfully');
989
+ } catch (error) {
990
+ const errorMessage = error instanceof Error ? error.message : String(error);
991
+
992
+ // Don't log errors if we're already signed out (forced logout scenario)
993
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
994
+ authLogger.error('[AuthStore] Failed to fetch user stats:', error);
995
+ }
996
+
997
+ set({ isLoadingUserStats: false });
998
+
999
+ // Don't throw if we're in a forced logout scenario
1000
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1001
+ throw error;
1002
+ }
1003
+ }
1004
+ },
1005
+
1006
+ fetchClients: async (force = false) => {
1007
+ const state = get();
1008
+ const now = Date.now();
1009
+
1010
+ // Check cache unless forced
1011
+ if (!force && state.clients && state.clientsLastFetch) {
1012
+ const age = now - state.clientsLastFetch;
1013
+ if (age < CACHE_DURATION.CLIENTS) {
1014
+ authLogger.debug('[AuthStore] Using cached clients');
1015
+ return;
1016
+ }
1017
+ }
1018
+
1019
+ set({ isLoadingClients: true });
1020
+
1021
+ try {
1022
+ const data = await get().apiCall('/api/admin/clients');
1023
+ // API returns pure array - no envelope
1024
+ set({
1025
+ clients: Array.isArray(data) ? data : [],
1026
+ clientsLastFetch: now,
1027
+ isLoadingClients: false,
1028
+ });
1029
+ authLogger.debug('[AuthStore] Clients fetched successfully');
1030
+ } catch (error) {
1031
+ const errorMessage = error instanceof Error ? error.message : String(error);
1032
+
1033
+ // Don't log errors if we're already signed out (forced logout scenario)
1034
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1035
+ authLogger.error('[AuthStore] Failed to fetch clients:', error);
1036
+ }
1037
+
1038
+ set({ isLoadingClients: false });
1039
+
1040
+ // Don't throw if we're in a forced logout scenario
1041
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1042
+ throw error;
1043
+ }
1044
+ }
1045
+ },
1046
+
1047
+ fetchRoles: async (force = false) => {
1048
+ const state = get();
1049
+ const now = Date.now();
1050
+
1051
+ // Check cache unless forced
1052
+ if (!force && state.roles && state.rolesLastFetch) {
1053
+ const age = now - state.rolesLastFetch;
1054
+ if (age < CACHE_DURATION.ROLES) {
1055
+ authLogger.debug('[AuthStore] Using cached roles');
1056
+ return;
1057
+ }
1058
+ }
1059
+
1060
+ set({ isLoadingRoles: true });
1061
+
1062
+ try {
1063
+ const data = await get().apiCall('/api/admin/roles');
1064
+ // API returns pure array - no envelope
1065
+ set({
1066
+ roles: Array.isArray(data) ? data : [],
1067
+ rolesLastFetch: now,
1068
+ isLoadingRoles: false,
1069
+ });
1070
+ authLogger.debug('[AuthStore] Roles fetched successfully');
1071
+ } catch (error) {
1072
+ const errorMessage = error instanceof Error ? error.message : String(error);
1073
+
1074
+ // Don't log errors if we're already signed out (forced logout scenario)
1075
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1076
+ authLogger.error('[AuthStore] Failed to fetch roles:', error);
1077
+ }
1078
+
1079
+ set({ isLoadingRoles: false });
1080
+
1081
+ // Don't throw if we're in a forced logout scenario
1082
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1083
+ throw error;
1084
+ }
1085
+ }
1086
+ },
1087
+
1088
+ fetchUsers: async (params: any = {}, force = false) => {
1089
+ const state = get();
1090
+ const now = Date.now();
1091
+
1092
+ // Check cache unless forced (users data changes frequently, shorter cache)
1093
+ if (!force && state.users && state.usersLastFetch) {
1094
+ const age = now - state.usersLastFetch;
1095
+ if (age < CACHE_DURATION.USERS) {
1096
+ authLogger.debug('[AuthStore] Using cached users');
1097
+ return;
1098
+ }
1099
+ }
1100
+
1101
+ set({ isLoadingUsers: true });
1102
+
1103
+ try {
1104
+ // Always use POST for /api/admin/users with appropriate payload
1105
+ const url = `/api/admin/users`;
1106
+ // Build proper UserGridRequest with all required fields
1107
+ const gridParams = {
1108
+ page_number: params.page || 1,
1109
+ page_size: params.pageSize || 25,
1110
+ search: "",
1111
+ search_field: "",
1112
+ sort_field: "",
1113
+ sort_order: "",
1114
+ status: null,
1115
+ merchant_id: "",
1116
+ client_assignment_status: null
1117
+ };
1118
+ const options = { method: 'POST', body: JSON.stringify(gridParams) };
1119
+
1120
+ const data = await get().apiCall(url, options);
1121
+
1122
+ // API returns pure array - no envelope
1123
+ const users = Array.isArray(data) ? data : [];
1124
+
1125
+ set({
1126
+ users: Array.isArray(users) ? users : [],
1127
+ usersLastFetch: now,
1128
+ isLoadingUsers: false,
1129
+ });
1130
+ authLogger.debug('[AuthStore] Users fetched successfully');
1131
+ } catch (error) {
1132
+ const errorMessage = error instanceof Error ? error.message : String(error);
1133
+ authLogger.error('[AuthStore] Failed to fetch users:', error);
1134
+ set({ isLoadingUsers: false });
1135
+
1136
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1137
+ throw error;
1138
+ }
1139
+ }
1140
+ },
1141
+
1142
+ fetchUserDetails: async (userId: string, force = false) => {
1143
+ const state = get();
1144
+ const now = Date.now();
1145
+
1146
+ // Check cache unless forced
1147
+ const cacheKey = `user_${userId}`;
1148
+ const lastFetch = state.userDetails[`${cacheKey}_lastFetch`];
1149
+ if (!force && state.userDetails[userId] && lastFetch) {
1150
+ const age = now - lastFetch;
1151
+ if (age < CACHE_DURATION.USER_DETAILS) {
1152
+ authLogger.debug(`[AuthStore] Using cached user details for ${userId}`);
1153
+ return;
1154
+ }
1155
+ }
1156
+
1157
+ set({
1158
+ isLoadingUserDetails: { ...state.isLoadingUserDetails, [userId]: true }
1159
+ });
1160
+
1161
+ try {
1162
+ const data = await get().apiCall(`/api/admin/users/${userId}`);
1163
+
1164
+ set({
1165
+ userDetails: {
1166
+ ...state.userDetails,
1167
+ [userId]: data.data || data,
1168
+ [`${cacheKey}_lastFetch`]: now
1169
+ },
1170
+ isLoadingUserDetails: { ...state.isLoadingUserDetails, [userId]: false }
1171
+ });
1172
+ authLogger.debug(`[AuthStore] User details fetched for ${userId}`);
1173
+ } catch (error) {
1174
+ const errorMessage = error instanceof Error ? error.message : String(error);
1175
+ authLogger.error(`[AuthStore] Failed to fetch user details for ${userId}:`, error);
1176
+
1177
+ set({
1178
+ isLoadingUserDetails: { ...state.isLoadingUserDetails, [userId]: false }
1179
+ });
1180
+
1181
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1182
+ throw error;
1183
+ }
1184
+ }
1185
+ },
1186
+
1187
+ fetchUserClientAuthorizations: async (userId: string, force = false) => {
1188
+ const state = get();
1189
+ const now = Date.now();
1190
+ const cacheKey = `auth_${userId}`;
1191
+
1192
+ // Check if already loading
1193
+ if (state.isLoadingClientAuthorizations[userId]) {
1194
+ return;
1195
+ }
1196
+
1197
+ // Check cache unless forced
1198
+ if (!force && state.clientAuthorizations[userId]) {
1199
+ authLogger.debug(`[AuthStore] Using cached client authorizations for ${userId}`);
1200
+ return;
1201
+ }
1202
+
1203
+ set({
1204
+ isLoadingClientAuthorizations: { ...state.isLoadingClientAuthorizations, [userId]: true }
1205
+ });
1206
+
1207
+ try {
1208
+ const data = await get().apiCall(`/api/admin/users/client-authorizations?user_id=${userId}`);
1209
+
1210
+ // Handle various response formats
1211
+ let authorizations: any[] = [];
1212
+ if (data.data) {
1213
+ authorizations = data.data.authorizations || data.data || [];
1214
+ } else {
1215
+ authorizations = data.authorizations || data || [];
1216
+ }
1217
+
1218
+ set({
1219
+ clientAuthorizations: {
1220
+ ...state.clientAuthorizations,
1221
+ [userId]: Array.isArray(authorizations) ? authorizations : []
1222
+ },
1223
+ isLoadingClientAuthorizations: { ...state.isLoadingClientAuthorizations, [userId]: false }
1224
+ });
1225
+ authLogger.debug(`[AuthStore] Client authorizations fetched for ${userId}`);
1226
+ } catch (error) {
1227
+ const errorMessage = error instanceof Error ? error.message : String(error);
1228
+ authLogger.error(`[AuthStore] Failed to fetch client authorizations for ${userId}:`, error);
1229
+
1230
+ set({
1231
+ clientAuthorizations: {
1232
+ ...state.clientAuthorizations,
1233
+ [userId]: []
1234
+ },
1235
+ isLoadingClientAuthorizations: { ...state.isLoadingClientAuthorizations, [userId]: false }
1236
+ });
1237
+
1238
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1239
+ throw error;
1240
+ }
1241
+ }
1242
+ },
1243
+
1244
+ fetchUserRoleAssignments: async (userId: string, force = false) => {
1245
+ const state = get();
1246
+ const cacheKey = `assignments_${userId}`;
1247
+
1248
+ if (state.isLoadingUserAssignments[userId]) {
1249
+ return;
1250
+ }
1251
+
1252
+ if (!force && state.userAssignments[userId]) {
1253
+ authLogger.debug(`[AuthStore] Using cached role assignments for ${userId}`);
1254
+ return;
1255
+ }
1256
+
1257
+ set({
1258
+ isLoadingUserAssignments: { ...state.isLoadingUserAssignments, [userId]: true }
1259
+ });
1260
+
1261
+ try {
1262
+ const data = await get().apiCall(`/api/admin/users/role-assignments?user_id=${userId}`);
1263
+
1264
+ let assignments: any[] = [];
1265
+ if (data.data) {
1266
+ assignments = data.data.assignments || data.data || [];
1267
+ } else {
1268
+ assignments = data.assignments || data || [];
1269
+ }
1270
+
1271
+ set({
1272
+ userAssignments: {
1273
+ ...state.userAssignments,
1274
+ [userId]: Array.isArray(assignments) ? assignments : []
1275
+ },
1276
+ isLoadingUserAssignments: { ...state.isLoadingUserAssignments, [userId]: false }
1277
+ });
1278
+ authLogger.debug(`[AuthStore] Role assignments fetched for ${userId}`);
1279
+ } catch (error) {
1280
+ const errorMessage = error instanceof Error ? error.message : String(error);
1281
+ authLogger.error(`[AuthStore] Failed to fetch role assignments for ${userId}:`, error);
1282
+
1283
+ set({
1284
+ userAssignments: {
1285
+ ...state.userAssignments,
1286
+ [userId]: []
1287
+ },
1288
+ isLoadingUserAssignments: { ...state.isLoadingUserAssignments, [userId]: false }
1289
+ });
1290
+
1291
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1292
+ throw error;
1293
+ }
1294
+ }
1295
+ },
1296
+
1297
+ fetchRoleCategories: async (force = false) => {
1298
+ const state = get();
1299
+ const now = Date.now();
1300
+
1301
+ // Check cache unless forced (role categories are fairly stable)
1302
+ if (!force && state.roleCategories && state.roleCategoriesLastFetch) {
1303
+ const age = now - state.roleCategoriesLastFetch;
1304
+ if (age < CACHE_DURATION.ROLE_CATEGORIES) {
1305
+ authLogger.debug('[AuthStore] Using cached role categories');
1306
+ return;
1307
+ }
1308
+ }
1309
+
1310
+ set({ isLoadingRoleCategories: true });
1311
+
1312
+ try {
1313
+ const data = await get().apiCall('/api/admin/roles/categories');
1314
+
1315
+ set({
1316
+ roleCategories: Array.isArray(data) ? data : (data.data || []),
1317
+ roleCategoriesLastFetch: now,
1318
+ isLoadingRoleCategories: false,
1319
+ });
1320
+ authLogger.debug('[AuthStore] Role categories fetched successfully');
1321
+ } catch (error) {
1322
+ const errorMessage = error instanceof Error ? error.message : String(error);
1323
+ authLogger.error('[AuthStore] Failed to fetch role categories:', error);
1324
+
1325
+ set({ isLoadingRoleCategories: false });
1326
+
1327
+ if (!errorMessage.includes('Session expired') && !errorMessage.includes('Authentication failed')) {
1328
+ throw error;
1329
+ }
1330
+ }
1331
+ },
1332
+
1333
+ // ===============================
1334
+ // CRUD OPERATIONS
1335
+ // ===============================
1336
+
1337
+ createUser: async (userData: any) => {
1338
+ try {
1339
+ const result = await get().apiCall('/api/admin/users', {
1340
+ method: 'POST',
1341
+ body: JSON.stringify(userData)
1342
+ });
1343
+
1344
+ // Invalidate users cache to force refresh
1345
+ set({ usersLastFetch: null });
1346
+ authLogger.info('[AuthStore] User created successfully');
1347
+ return result;
1348
+ } catch (error) {
1349
+ authLogger.error('[AuthStore] Failed to create user:', error);
1350
+ throw error;
1351
+ }
1352
+ },
1353
+
1354
+ updateUser: async (userId: string, updates: any) => {
1355
+ try {
1356
+ const result = await get().apiCall(`/api/admin/users/${userId}`, {
1357
+ method: 'PUT',
1358
+ body: JSON.stringify(updates)
1359
+ });
1360
+
1361
+ // Invalidate relevant caches
1362
+ const state = get();
1363
+ set({
1364
+ usersLastFetch: null,
1365
+ userDetails: {
1366
+ ...state.userDetails,
1367
+ [userId]: undefined // Force refresh of this user's details
1368
+ }
1369
+ });
1370
+
1371
+ authLogger.info(`[AuthStore] User ${userId} updated successfully`);
1372
+ return result;
1373
+ } catch (error) {
1374
+ authLogger.error(`[AuthStore] Failed to update user ${userId}:`, error);
1375
+ throw error;
1376
+ }
1377
+ },
1378
+
1379
+ deleteUser: async (userId: string) => {
1380
+ try {
1381
+ await get().apiCall(`/api/admin/users/${userId}`, {
1382
+ method: 'DELETE'
1383
+ });
1384
+
1385
+ // Clean up all related data for this user
1386
+ const state = get();
1387
+ const newUserDetails = { ...state.userDetails };
1388
+ const newUserAssignments = { ...state.userAssignments };
1389
+ const newClientAuthorizations = { ...state.clientAuthorizations };
1390
+
1391
+ delete newUserDetails[userId];
1392
+ delete newUserAssignments[userId];
1393
+ delete newClientAuthorizations[userId];
1394
+
1395
+ set({
1396
+ usersLastFetch: null, // Force refresh
1397
+ userDetails: newUserDetails,
1398
+ userAssignments: newUserAssignments,
1399
+ clientAuthorizations: newClientAuthorizations
1400
+ });
1401
+
1402
+ authLogger.info(`[AuthStore] User ${userId} deleted successfully`);
1403
+ } catch (error) {
1404
+ authLogger.error(`[AuthStore] Failed to delete user ${userId}:`, error);
1405
+ throw error;
1406
+ }
1407
+ },
1408
+
1409
+ createRole: async (roleData: any) => {
1410
+ try {
1411
+ const result = await get().apiCall('/api/admin/roles', {
1412
+ method: 'POST',
1413
+ body: JSON.stringify(roleData)
1414
+ });
1415
+
1416
+ // Invalidate roles cache
1417
+ set({ rolesLastFetch: null });
1418
+ authLogger.info('[AuthStore] Role created successfully');
1419
+ return result;
1420
+ } catch (error) {
1421
+ authLogger.error('[AuthStore] Failed to create role:', error);
1422
+ throw error;
1423
+ }
1424
+ },
1425
+
1426
+ updateRole: async (roleId: string, updates: any) => {
1427
+ try {
1428
+ const result = await get().apiCall(`/api/admin/roles/${roleId}`, {
1429
+ method: 'PUT',
1430
+ body: JSON.stringify(updates)
1431
+ });
1432
+
1433
+ set({ rolesLastFetch: null });
1434
+ authLogger.info(`[AuthStore] Role ${roleId} updated successfully`);
1435
+ return result;
1436
+ } catch (error) {
1437
+ authLogger.error(`[AuthStore] Failed to update role ${roleId}:`, error);
1438
+ throw error;
1439
+ }
1440
+ },
1441
+
1442
+ deleteRole: async (roleId: string) => {
1443
+ try {
1444
+ await get().apiCall(`/api/admin/roles/${roleId}`, {
1445
+ method: 'DELETE'
1446
+ });
1447
+
1448
+ set({ rolesLastFetch: null });
1449
+ authLogger.info(`[AuthStore] Role ${roleId} deleted successfully`);
1450
+ } catch (error) {
1451
+ authLogger.error(`[AuthStore] Failed to delete role ${roleId}:`, error);
1452
+ throw error;
1453
+ }
1454
+ },
1455
+
1456
+ assignUserToRole: async (userId: string, roleId: string) => {
1457
+ try {
1458
+ await get().apiCall('/api/admin/users/assign-role', {
1459
+ method: 'POST',
1460
+ body: JSON.stringify({ userId, roleId })
1461
+ });
1462
+
1463
+ // Invalidate user assignments cache
1464
+ const state = get();
1465
+ set({
1466
+ userAssignments: {
1467
+ ...state.userAssignments,
1468
+ [userId]: undefined // Force refresh
1469
+ },
1470
+ usersLastFetch: null // Also refresh users list
1471
+ });
1472
+
1473
+ authLogger.info(`[AuthStore] User ${userId} assigned to role ${roleId}`);
1474
+ } catch (error) {
1475
+ authLogger.error(`[AuthStore] Failed to assign user ${userId} to role ${roleId}:`, error);
1476
+ throw error;
1477
+ }
1478
+ },
1479
+
1480
+ removeUserFromRole: async (userId: string, roleId: string) => {
1481
+ try {
1482
+ await get().apiCall('/api/admin/users/remove-role', {
1483
+ method: 'POST',
1484
+ body: JSON.stringify({ userId, roleId })
1485
+ });
1486
+
1487
+ // Invalidate user assignments cache
1488
+ const state = get();
1489
+ set({
1490
+ userAssignments: {
1491
+ ...state.userAssignments,
1492
+ [userId]: undefined // Force refresh
1493
+ },
1494
+ usersLastFetch: null // Also refresh users list
1495
+ });
1496
+
1497
+ authLogger.info(`[AuthStore] User ${userId} removed from role ${roleId}`);
1498
+ } catch (error) {
1499
+ authLogger.error(`[AuthStore] Failed to remove user ${userId} from role ${roleId}:`, error);
1500
+ throw error;
1501
+ }
1502
+ },
1503
+
1504
+ assignUserToClient: async (userId: string, clientId: string) => {
1505
+ try {
1506
+ await get().apiCall('/api/admin/users/assign-client', {
1507
+ method: 'POST',
1508
+ body: JSON.stringify({ userId, clientId })
1509
+ });
1510
+
1511
+ // Invalidate user client authorizations cache
1512
+ const state = get();
1513
+ set({
1514
+ clientAuthorizations: {
1515
+ ...state.clientAuthorizations,
1516
+ [userId]: undefined // Force refresh
1517
+ },
1518
+ usersLastFetch: null
1519
+ });
1520
+
1521
+ authLogger.info(`[AuthStore] User ${userId} assigned to client ${clientId}`);
1522
+ } catch (error) {
1523
+ authLogger.error(`[AuthStore] Failed to assign user ${userId} to client ${clientId}:`, error);
1524
+ throw error;
1525
+ }
1526
+ },
1527
+
1528
+ removeUserFromClient: async (userId: string, clientId: string) => {
1529
+ try {
1530
+ await get().apiCall('/api/admin/users/remove-client', {
1531
+ method: 'POST',
1532
+ body: JSON.stringify({ userId, clientId })
1533
+ });
1534
+
1535
+ // Invalidate user client authorizations cache
1536
+ const state = get();
1537
+ set({
1538
+ clientAuthorizations: {
1539
+ ...state.clientAuthorizations,
1540
+ [userId]: undefined // Force refresh
1541
+ },
1542
+ usersLastFetch: null
1543
+ });
1544
+
1545
+ authLogger.info(`[AuthStore] User ${userId} removed from client ${clientId}`);
1546
+ } catch (error) {
1547
+ authLogger.error(`[AuthStore] Failed to remove user ${userId} from client ${clientId}:`, error);
1548
+ throw error;
1549
+ }
1550
+ },
1551
+
1552
+ // ===============================
1553
+ // UTILITY ACTIONS
1554
+ // ===============================
1555
+
1556
+ hasRole: (role: string) => {
1557
+ const state = get();
1558
+ return state.user?.roles?.includes(role) || false;
1559
+ },
1560
+
1561
+ hasAnyRole: (roles: string[]) => {
1562
+ const state = get();
1563
+ if (!state.user?.roles) return false;
1564
+ return roles.some(role => state.user!.roles.includes(role));
1565
+ },
1566
+
1567
+ hasAllRoles: (roles: string[]) => {
1568
+ const state = get();
1569
+ if (!state.user?.roles) return false;
1570
+ return roles.every(role => state.user!.roles.includes(role));
1571
+ },
1572
+
1573
+ isFullyAuthenticated: () => {
1574
+ const state = get();
1575
+ return state.isAuthenticated &&
1576
+ (!state.user?.requiresTwoFactor || state.user.twoFactorSessionVerified);
1577
+ },
1578
+
1579
+ // ===============================
1580
+ // ERROR MANAGEMENT
1581
+ // ===============================
1582
+
1583
+ setError: (error: string | null) => {
1584
+ set({ error });
1585
+ },
1586
+
1587
+ clearError: () => {
1588
+ set({ error: null, tokenError: null });
1589
+ },
1590
+
1591
+ // ===============================
1592
+ // ADMIN USER STATE MANAGEMENT ACTIONS
1593
+ // ===============================
1594
+
1595
+ approveUser: async (userId: string, reason?: string) => {
1596
+ try {
1597
+ await get().apiCall('/api/Admin/users/toggle-approval', {
1598
+ method: 'POST',
1599
+ body: JSON.stringify({
1600
+ user_id: parseInt(userId),
1601
+ is_approved: true,
1602
+ reason: reason || 'Administrative approval'
1603
+ })
1604
+ });
1605
+
1606
+ authLogger.info('[AuthStore] User approved:', { userId, reason });
1607
+
1608
+ // The backend should broadcast the SignalR event, but we could also
1609
+ // optimistically update local state here if needed
1610
+ } catch (error) {
1611
+ authLogger.error('[AuthStore] Failed to approve user:', error);
1612
+ throw error;
1613
+ }
1614
+ },
1615
+
1616
+ disapproveUser: async (userId: string, reason?: string) => {
1617
+ try {
1618
+ await get().apiCall('/api/Admin/users/toggle-approval', {
1619
+ method: 'POST',
1620
+ body: JSON.stringify({
1621
+ user_id: parseInt(userId),
1622
+ is_approved: false,
1623
+ reason: reason || 'Administrative disapproval'
1624
+ })
1625
+ });
1626
+ authLogger.info('[AuthStore] User disapproved:', { userId, reason });
1627
+ } catch (error) {
1628
+ authLogger.error('[AuthStore] Failed to disapprove user:', error);
1629
+ throw error;
1630
+ }
1631
+ },
1632
+
1633
+ pauseUser: async (userId: string, reason?: string) => {
1634
+ try {
1635
+ await get().apiCall('/api/Admin/users/pause', {
1636
+ method: 'POST',
1637
+ body: JSON.stringify({
1638
+ user_id: parseInt(userId),
1639
+ reason: reason || 'Administrative suspension'
1640
+ })
1641
+ });
1642
+ authLogger.info('[AuthStore] User paused:', { userId, reason });
1643
+ } catch (error) {
1644
+ authLogger.error('[AuthStore] Failed to pause user:', error);
1645
+ throw error;
1646
+ }
1647
+ },
1648
+
1649
+ resumeUser: async (userId: string) => {
1650
+ try {
1651
+ await get().apiCall('/api/Admin/users/resume', {
1652
+ method: 'POST',
1653
+ body: JSON.stringify({
1654
+ user_id: parseInt(userId)
1655
+ })
1656
+ });
1657
+ authLogger.info('[AuthStore] User resumed:', { userId });
1658
+ } catch (error) {
1659
+ authLogger.error('[AuthStore] Failed to resume user:', error);
1660
+ throw error;
1661
+ }
1662
+ },
1663
+
1664
+ haltUser: async (userId: string, reason?: string) => {
1665
+ try {
1666
+ await get().apiCall('/api/Admin/users/halt', {
1667
+ method: 'POST',
1668
+ body: JSON.stringify({
1669
+ user_id: parseInt(userId),
1670
+ reason: reason || 'Security lockout'
1671
+ })
1672
+ });
1673
+ authLogger.info('[AuthStore] User halted:', { userId, reason });
1674
+ } catch (error) {
1675
+ authLogger.error('[AuthStore] Failed to halt user:', error);
1676
+ throw error;
1677
+ }
1678
+ },
1679
+
1680
+ unlockUser: async (userId: string) => {
1681
+ try {
1682
+ await get().apiCall('/api/Admin/users/unlock', {
1683
+ method: 'POST',
1684
+ body: JSON.stringify({
1685
+ user_id: parseInt(userId)
1686
+ })
1687
+ });
1688
+ authLogger.info('[AuthStore] User unlocked:', { userId });
1689
+ } catch (error) {
1690
+ authLogger.error('[AuthStore] Failed to unlock user:', error);
1691
+ throw error;
1692
+ }
1693
+ },
1694
+
1695
+ // ===============================
1696
+ // USER STATE UTILITY FUNCTIONS
1697
+ // ===============================
1698
+
1699
+ canUserAccess: () => {
1700
+ const state = get();
1701
+ if (!state.user) return false;
1702
+
1703
+ // State priority: LOCKOUT > SUSPENSION > APPROVAL
1704
+ if (state.user.lockoutEnabled) {
1705
+ // Check if lockout has expired
1706
+ if (state.user.lockoutEnd && new Date() > state.user.lockoutEnd) {
1707
+ return true; // Lockout expired
1708
+ }
1709
+ return false; // Still locked
1710
+ }
1711
+
1712
+ if (state.user.isSuspended) {
1713
+ return false; // Suspended
1714
+ }
1715
+
1716
+ return state.user.isApproved; // Must be approved
1717
+ },
1718
+
1719
+ getUserStateDisplay: () => {
1720
+ const state = get();
1721
+ if (!state.user) return 'Unknown';
1722
+
1723
+ // State priority: LOCKOUT > SUSPENSION > APPROVAL
1724
+ if (state.user.lockoutEnabled) {
1725
+ if (state.user.lockoutEnd && new Date() > state.user.lockoutEnd) {
1726
+ return 'Lockout Expired';
1727
+ }
1728
+ return 'Locked Out';
1729
+ }
1730
+
1731
+ if (state.user.isSuspended) {
1732
+ return 'Suspended';
1733
+ }
1734
+
1735
+ return state.user.isApproved ? 'Approved' : 'Unapproved';
1736
+ },
1737
+
1738
+ isUserLocked: () => {
1739
+ const state = get();
1740
+ if (!state.user) return false;
1741
+
1742
+ if (!state.user.lockoutEnabled) return false;
1743
+
1744
+ // Check if lockout has expired
1745
+ if (state.user.lockoutEnd && new Date() > state.user.lockoutEnd) {
1746
+ return false; // Lockout expired
1747
+ }
1748
+
1749
+ return true; // Still locked
1750
+ },
1751
+
1752
+ // ===============================
1753
+ // REAL-TIME SIGNALR MANAGEMENT
1754
+ // ===============================
1755
+
1756
+ initializeSignalR: async () => {
1757
+ const state = get();
1758
+
1759
+ // Don't initialize if already connected or not authenticated
1760
+ if (state.signalrConnection || !state.isAuthenticated || !state.accessToken) {
1761
+ authLogger.debug('[AuthStore] Skipping SignalR - already connected or not authenticated');
1762
+ return;
1763
+ }
1764
+
1765
+ // Check if user has admin role for ActivityHub access
1766
+ const hasAdminRole = state.user?.roles?.includes('payez_admin') || false;
1767
+ if (!hasAdminRole) {
1768
+ authLogger.info('[AuthStore] Skipping SignalR - user lacks admin role for ActivityHub');
1769
+ return;
1770
+ }
1771
+
1772
+ authLogger.info('[AuthStore] Initializing SignalR connection');
1773
+
1774
+ try {
1775
+ // Use the existing signalRActivityService instead of creating a duplicate connection
1776
+ // to the same health hub - this avoids connection errors flooding the console
1777
+
1778
+ // Start the health service if not already running (with timeout)
1779
+ const idpBaseUrl = process.env.IDP_URL;
1780
+ if (!idpBaseUrl) {
1781
+ throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
1782
+ }
1783
+ const startPromise = signalRActivityService.start(idpBaseUrl);
1784
+ const timeoutPromise = new Promise((_, reject) =>
1785
+ setTimeout(() => reject(new Error('SignalR start timeout')), 5000)
1786
+ );
1787
+
1788
+ await Promise.race([startPromise, timeoutPromise]);
1789
+
1790
+ // Subscribe to health status changes from the dedicated health service
1791
+ const unsubscribe = signalRActivityService.subscribe((status) => {
1792
+ // Map health service status to our connection state
1793
+ const connectionState = status.isHealthy ?
1794
+ HubConnectionState.Connected :
1795
+ status.message.includes('reconnecting') ?
1796
+ HubConnectionState.Reconnecting :
1797
+ HubConnectionState.Disconnected;
1798
+
1799
+ authLogger.info('[AuthStore] Health status update:', {
1800
+ isHealthy: status.isHealthy,
1801
+ message: status.message,
1802
+ connectionState
1803
+ });
1804
+
1805
+ set({
1806
+ signalrConnectionState: connectionState,
1807
+ isConnectedToSignalR: status.isHealthy
1808
+ });
1809
+ });
1810
+
1811
+ // Store the unsubscribe function for cleanup
1812
+ set({
1813
+ signalrConnection: {
1814
+ // Minimal connection-like interface that does nothing on stop()
1815
+ // This allows existing code to still call connection.stop() without errors
1816
+ stop: async () => {
1817
+ unsubscribe();
1818
+ authLogger.info('[AuthStore] Unsubscribed from health service');
1819
+ }
1820
+ } as any,
1821
+ signalrConnectionState: signalRActivityService.getCurrentStatus().isHealthy ?
1822
+ HubConnectionState.Connected : HubConnectionState.Disconnected,
1823
+ isConnectedToSignalR: signalRActivityService.getCurrentStatus().isHealthy
1824
+ });
1825
+
1826
+ authLogger.info('[AuthStore] SignalR connection established via health service');
1827
+
1828
+ } catch (error) {
1829
+ // Don't let SignalR errors propagate up and potentially break login flow
1830
+ const errorMessage = error instanceof Error ? error.message : String(error);
1831
+
1832
+ // Only log as error if it's not a timeout or expected connection failure
1833
+ if (errorMessage.includes('timeout') || errorMessage.includes('unavailable')) {
1834
+ authLogger.info('[AuthStore] SignalR connection not available (expected in some environments):', errorMessage);
1835
+ } else {
1836
+ authLogger.error('[AuthStore] SignalR connection failed:', error);
1837
+ }
1838
+
1839
+ set({
1840
+ signalrConnectionState: HubConnectionState.Disconnected,
1841
+ isConnectedToSignalR: false
1842
+ });
1843
+ }
1844
+ },
1845
+
1846
+ disconnectSignalR: async () => {
1847
+ const state = get();
1848
+
1849
+ if (state.signalrConnection) {
1850
+ authLogger.info('[AuthStore] Disconnecting SignalR');
1851
+
1852
+ try {
1853
+ await state.signalrConnection.stop();
1854
+ } catch (error) {
1855
+ authLogger.error('[AuthStore] Error disconnecting SignalR:', error);
1856
+ }
1857
+
1858
+ set({
1859
+ signalrConnection: null,
1860
+ signalrConnectionState: HubConnectionState.Disconnected,
1861
+ isConnectedToSignalR: false
1862
+ });
1863
+ }
1864
+ },
1865
+
1866
+ handleUserStateChanged: (data: UserStateChangeEvent) => {
1867
+ // This method was designed for UserStateChanged events that don't exist in current backend
1868
+ // Keeping for future reference or if we implement proper user state change events
1869
+ const state = get();
1870
+
1871
+ // If this event is about the current user, update their state
1872
+ if (data.userId === state.user?.id && state.session) {
1873
+ // PAY-1427: the event is user state; log its shape, not its contents.
1874
+ authLogger.info('[AuthStore] Updating current user state from SignalR:', { keys: Object.keys(data || {}) });
1875
+ // ... user state update logic would go here
1876
+ }
1877
+ },
1878
+ }),
1879
+ {
1880
+ name: 'auth-store',
1881
+ }
1882
+ )
1883
+ );
1884
+
1885
+ // ===============================
1886
+ // STORE INITIALIZATION HELPER
1887
+ // ===============================
1888
+
1889
+ /**
1890
+ * Initialize the auth store with a session (typically called in layout)
1891
+ */
1892
+ export const initializeAuthStore = (session: AppSession | null) => {
1893
+ const store = useAuthStore.getState();
1894
+ if (!store.isInitialized) {
1895
+ authLogger.debug('[AuthStore] Initializing with session:', { hasSession: !!session });
1896
+ store.setSession(session);
1897
+ }
1898
+ };
1899
+
1900
+ export default useAuthStore;