@djangocfg/api 2.1.454 → 2.1.456

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 (87) hide show
  1. package/dist/auth.cjs +2654 -2920
  2. package/dist/auth.cjs.map +1 -1
  3. package/dist/auth.d.cts +18 -82
  4. package/dist/auth.d.ts +18 -82
  5. package/dist/auth.mjs +385 -2861
  6. package/dist/auth.mjs.map +1 -1
  7. package/dist/chunk-2G67QRNU.mjs +2096 -0
  8. package/dist/chunk-2G67QRNU.mjs.map +1 -0
  9. package/dist/chunk-32SRQGAC.mjs +2109 -0
  10. package/dist/chunk-32SRQGAC.mjs.map +1 -0
  11. package/dist/chunk-4BPRCONN.mjs +2098 -0
  12. package/dist/chunk-4BPRCONN.mjs.map +1 -0
  13. package/dist/chunk-5UZ2Z323.mjs +2021 -0
  14. package/dist/chunk-5UZ2Z323.mjs.map +1 -0
  15. package/dist/chunk-7KFTJXNM.mjs +2097 -0
  16. package/dist/chunk-7KFTJXNM.mjs.map +1 -0
  17. package/dist/chunk-BK4K5CVT.mjs +2019 -0
  18. package/dist/chunk-BK4K5CVT.mjs.map +1 -0
  19. package/dist/chunk-JLPJZ6WB.mjs +2020 -0
  20. package/dist/chunk-JLPJZ6WB.mjs.map +1 -0
  21. package/dist/chunk-TVU6PYJH.mjs +2032 -0
  22. package/dist/chunk-TVU6PYJH.mjs.map +1 -0
  23. package/dist/clients.cjs +2210 -1847
  24. package/dist/clients.cjs.map +1 -1
  25. package/dist/clients.d.cts +23 -0
  26. package/dist/clients.d.ts +23 -0
  27. package/dist/clients.mjs +14 -1845
  28. package/dist/clients.mjs.map +1 -1
  29. package/dist/hooks.cjs +1776 -962
  30. package/dist/hooks.cjs.map +1 -1
  31. package/dist/hooks.mjs +4 -1375
  32. package/dist/hooks.mjs.map +1 -1
  33. package/dist/index.cjs +2245 -1877
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +81 -4
  36. package/dist/index.d.ts +81 -4
  37. package/dist/index.mjs +20 -1860
  38. package/dist/index.mjs.map +1 -1
  39. package/dist/sdk.gen-2SQOPTWJ.mjs +26 -0
  40. package/dist/sdk.gen-2SQOPTWJ.mjs.map +1 -0
  41. package/dist/sdk.gen-HSGK4C5S.mjs +26 -0
  42. package/dist/sdk.gen-HSGK4C5S.mjs.map +1 -0
  43. package/dist/sdk.gen-NUK2VGHO.mjs +25 -0
  44. package/dist/sdk.gen-NUK2VGHO.mjs.map +1 -0
  45. package/dist/sdk.gen-O4KAQUZB.mjs +26 -0
  46. package/dist/sdk.gen-O4KAQUZB.mjs.map +1 -0
  47. package/dist/sdk.gen-PPAVSBNT.mjs +26 -0
  48. package/dist/sdk.gen-PPAVSBNT.mjs.map +1 -0
  49. package/dist/sdk.gen-XLHLOCJ2.mjs +25 -0
  50. package/dist/sdk.gen-XLHLOCJ2.mjs.map +1 -0
  51. package/dist/sdk.gen-ZOE6NQAL.mjs +26 -0
  52. package/dist/sdk.gen-ZOE6NQAL.mjs.map +1 -0
  53. package/dist/sdk.gen-ZT7LGJVO.mjs +26 -0
  54. package/dist/sdk.gen-ZT7LGJVO.mjs.map +1 -0
  55. package/package.json +2 -2
  56. package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
  57. package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenBlacklistCreate.ts +28 -0
  58. package/src/_api/generated/_cfg_accounts/openapi.json +99 -0
  59. package/src/_api/generated/_cfg_accounts/schemas/TokenBlacklistRequest.ts +11 -0
  60. package/src/_api/generated/_cfg_accounts/schemas/index.ts +1 -0
  61. package/src/_api/generated/_cfg_centrifugo/openapi.json +9 -0
  62. package/src/_api/generated/_cfg_totp/openapi.json +33 -0
  63. package/src/_api/generated/helpers/auth.ts +276 -31
  64. package/src/_api/generated/openapi.json +129 -0
  65. package/src/_api/generated/sdk.gen.ts +94 -16
  66. package/src/_api/generated/types.gen.ts +18 -0
  67. package/src/auth/__tests__/guard.test.ts +0 -31
  68. package/src/auth/constants.ts +6 -0
  69. package/src/auth/context/AccountsContext.tsx +14 -24
  70. package/src/auth/context/AuthContext.tsx +226 -609
  71. package/src/auth/hooks/index.ts +3 -4
  72. package/src/auth/hooks/useGithubAuth.ts +3 -3
  73. package/src/auth/hooks/useSession.ts +22 -0
  74. package/src/auth/hooks/useTwoFactor.ts +4 -4
  75. package/src/auth/middlewares/index.ts +5 -6
  76. package/src/auth/utils/guard.ts +0 -22
  77. package/src/auth/utils/index.ts +0 -2
  78. package/src/_api/generated/_cfg_accounts/events.ts +0 -198
  79. package/src/_api/generated/_cfg_centrifugo/events.ts +0 -198
  80. package/src/_api/generated/_cfg_totp/events.ts +0 -198
  81. package/src/auth/__tests__/jwt.test.ts +0 -119
  82. package/src/auth/__tests__/sessionBootstrap.test.ts +0 -111
  83. package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +0 -167
  84. package/src/auth/hooks/useAuthGuard.ts +0 -35
  85. package/src/auth/hooks/useTokenRefresh.ts +0 -131
  86. package/src/auth/refreshHandler.ts +0 -79
  87. package/src/auth/utils/jwt.ts +0 -66
@@ -1,529 +1,211 @@
1
- // @ts-nocheck
2
1
  'use client';
3
2
 
4
- import { isDev, isBrowser } from "../utils/env";
3
+ /**
4
+ * AuthProvider — thin React layer over the generated auth store.
5
+ *
6
+ * Architecture rule (auth v2): the generated store (`_api/generated/helpers/auth`)
7
+ * is the ONLY auth engine. It owns tokens, JWT-exp semantics, the reactive
8
+ * session snapshot, 401→refresh→retry recovery (cross-store Web-Locks), and
9
+ * the terminal-401 `onSessionExpired` event. React only:
10
+ * 1. subscribes to the snapshot (`useSyncExternalStore`),
11
+ * 2. loads the user profile when a session exists,
12
+ * 3. routes to login when the store says the session died,
13
+ * 4. exposes login/logout actions.
14
+ *
15
+ * Everything that used to live here — authTick, expiry timers, four parallel
16
+ * 401 handlers, presence-vs-exp token checks — is the store's job now.
17
+ */
18
+
5
19
  import { usePathname } from 'next/navigation';
6
20
  import React, {
7
- createContext, memo, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState
21
+ createContext,
22
+ memo,
23
+ useCallback,
24
+ useContext,
25
+ useEffect,
26
+ useMemo,
27
+ useRef,
28
+ useState,
8
29
  } from 'react';
9
- import { SWRConfig } from 'swr';
10
30
 
11
- import { useCfgRouter, useLocalStorage, useQueryParams } from '../hooks';
12
-
13
- import { api as apiAccounts } from '../../';
14
- import { applyRoleLogPolicy } from '../../log-control';
31
+ import { auth } from '../../_api/generated/helpers/auth';
32
+ import { DEFAULT_AUTH_PATH, DEFAULT_CALLBACK_PATH } from '../constants';
15
33
  import { APIError } from '../../_api/generated/helpers';
16
- import { clearProfileCache, getCachedProfile } from '../hooks/useProfileCache';
34
+ import { applyRoleLogPolicy } from '../../log-control';
35
+ import { useCfgRouter, useLocalStorage, useQueryParams, useSession } from '../hooks';
36
+ import { clearProfileCache } from '../hooks/useProfileCache';
17
37
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
18
- import { useTokenRefresh } from '../hooks/useTokenRefresh';
19
- import { ensureRefreshHandler } from '../refreshHandler';
20
38
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
39
+ import { isDev, isBrowser } from '../utils/env';
21
40
  import { authLogger } from '../utils/logger';
22
- import { isTokenExpired, getTokenExpiry } from '../utils/jwt';
23
- import { nextAuthEvaluationDelay } from '../utils/guard';
24
41
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
25
42
 
26
- import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
43
+ import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
27
44
  import type { OTPRequestResult } from '../types';
28
45
 
29
- // Default routes
30
46
  const defaultRoutes = {
31
- auth: '/auth',
32
- defaultCallback: '/dashboard',
33
- defaultAuthCallback: '/auth',
47
+ auth: DEFAULT_AUTH_PATH,
48
+ defaultCallback: DEFAULT_CALLBACK_PATH,
49
+ defaultAuthCallback: DEFAULT_AUTH_PATH,
34
50
  };
35
51
 
36
- const AuthContext = createContext<AuthContextType | undefined>(undefined);
37
-
38
- // Constants
39
52
  const EMAIL_STORAGE_KEY = 'auth_email';
40
53
 
41
- const hasValidTokens = (): boolean => {
42
- if (typeof window === 'undefined') return false;
43
- return apiAccounts.isAuthenticated();
44
- };
54
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
45
55
 
46
- /**
47
- * Decide, at bootstrap, whether the persisted tokens are a DEAD session that
48
- * can never be recovered — so we should clear storage and show the login form
49
- * instead of firing doomed API calls.
50
- *
51
- * Pure + injectable (tokens passed in) so it can be unit-tested without a store.
52
- *
53
- * A session is dead when the access token is missing/expired/malformed AND the
54
- * refresh token cannot save it (missing or itself expired/malformed). If either
55
- * the access token is still live, OR a usable refresh token exists, the session
56
- * is NOT dead — normal init proceeds (and refresh will run if needed).
57
- *
58
- * This is exactly the state the user used to fix by hand ("cleared localStorage
59
- * and then the form appeared"): stale/blacklisted-companion tokens left in
60
- * storage with no path forward.
61
- */
62
- export const isSessionDeadOnBootstrap = (
63
- accessToken: string | null,
64
- refreshToken: string | null,
65
- now = Date.now(),
66
- ): boolean => {
67
- const accessDead = isTokenExpired(accessToken, 0, now);
68
- if (!accessDead) return false; // access still usable → alive
69
- const refreshDead = isTokenExpired(refreshToken, 0, now);
70
- return refreshDead; // access dead + refresh dead/absent → unrecoverable
56
+ /** Extract a user-facing message from an APIError response body. */
57
+ const apiErrorMessage = (error: APIError): string => {
58
+ const body = error.response as
59
+ | { error?: string; detail?: string; message?: string }
60
+ | undefined;
61
+ return body?.error || body?.detail || body?.message || error.errorMessage;
71
62
  };
72
63
 
73
- /**
74
- * Live, network-free authentication check — the single source of truth for
75
- * `isAuthenticated`. Reads the persisted tokens and validates their `exp`
76
- * LOCALLY, so the answer never depends on a server 401 arriving (which CSP, a
77
- * hung request, or an offline network can silently swallow).
78
- *
79
- * A session is alive while it is not `isSessionDeadOnBootstrap` — i.e. the
80
- * access token is still valid, OR a usable refresh token exists to renew it.
81
- * We deliberately do NOT require a live access token: between access expiry and
82
- * the silent refresh the session is still recoverable, and flipping to
83
- * "unauthenticated" there would bounce the user to /auth on every protected
84
- * page. Only access-dead AND refresh-dead means truly logged out.
85
- *
86
- * Note: `isAuthenticated()` in the generated store only checks token PRESENCE,
87
- * not expiry — this wrapper adds the expiry gate the guard needs.
88
- */
89
- const isSessionAlive = (now = Date.now()): boolean => {
90
- if (typeof window === 'undefined') return false;
91
- return !isSessionDeadOnBootstrap(
92
- apiAccounts.getToken(),
93
- apiAccounts.getRefreshToken(),
94
- now,
95
- );
96
- };
97
-
98
- // Internal provider that uses AccountsContext
99
64
  const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
100
65
  const accounts = useAccountsContext();
66
+ const session = useSession();
67
+ const isAuthenticated = session.status === 'authenticated';
101
68
 
102
- // Register the canonical refresh strategy exactly once, synchronously on the
103
- // first render, BEFORE any request can 401. This lights up the generated
104
- // client's built-in 401 recovery (single-flight + retry + rotated-token
105
- // persistence). Idempotent safe across remounts / multiple providers.
106
- ensureRefreshHandler();
69
+ const router = useCfgRouter();
70
+ const pathname = usePathname();
71
+ const queryParams = useQueryParams();
72
+ const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(
73
+ EMAIL_STORAGE_KEY,
74
+ null,
75
+ );
107
76
 
108
- // Redirect URL manager for saving URL before auth redirect
109
77
  const redirectManager = useAuthRedirectManager({
110
78
  fallbackUrl: config?.routes?.defaultCallback || defaultRoutes.defaultCallback,
111
79
  clearOnUse: true,
112
80
  });
113
81
 
114
- // Smart initial loading state: only true if we don't have tokens yet
115
- const [isLoading, setIsLoading] = useState(() => {
116
- // If we already have tokens and profile, don't show loading
117
- if (typeof window !== 'undefined') {
118
- const hasTokens = hasValidTokens();
119
- // Only show loading on initial mount if no tokens
120
- return !hasTokens;
121
- }
122
- return true;
123
- });
124
-
125
- const [initialized, setInitialized] = useState(false);
126
-
127
- // Reactivity tick for `isAuthenticated`. Token state lives in the auth store
128
- // (localStorage), NOT React state — so `apiAccounts.isAuthenticated()` is read
129
- // once when the context value is memoised and would go stale after tokens are
130
- // cleared (e.g. a blacklisted-refresh 401). Bumping this on every auth-state
131
- // transition forces the memo to recompute, so the guard reliably flips to
132
- // "unauthenticated" and the login form is shown instead of an endless
133
- // "Authenticating…" spinner.
134
- const [authTick, setAuthTick] = useState(0);
135
- const bumpAuthTick = useCallback(() => setAuthTick((t) => t + 1), []);
136
-
137
- // Time-driven re-evaluation of `isAuthenticated`. Because the flag is derived
138
- // from token `exp` (not from a server event), a session can silently expire
139
- // while the tab sits open — with nothing to trigger a recompute. So we arm a
140
- // timer for the next moment the answer could change: whichever of the access
141
- // / refresh tokens expires next. When it fires we bump the tick; the memo
142
- // recomputes; the guard flips to unauthenticated and redirects — no 401 (and
143
- // thus no CSP/offline dependency) required. Re-armed whenever tokens change
144
- // (authTick) so a fresh refresh pushes the deadline out.
145
- useEffect(() => {
146
- if (typeof window === 'undefined') return;
147
- const delay = nextAuthEvaluationDelay(
148
- getTokenExpiry(apiAccounts.getToken()),
149
- getTokenExpiry(apiAccounts.getRefreshToken()),
150
- Date.now(),
151
- );
152
- if (delay === null) return; // nothing live to expire
153
- // setTimeout caps at ~24.8 days; clamp so a far-future refresh doesn't overflow.
154
- const timer = setTimeout(bumpAuthTick, Math.min(delay, 2_000_000_000));
155
- return () => clearTimeout(timer);
156
- }, [authTick, bumpAuthTick]);
157
-
158
- // Latches once a dead session has been handled, so the redirect-to-login only
159
- // fires once even if both the interceptor (auth.onUnauthorized) and the
160
- // proactive-refresh error path trip for the same expired session.
161
- const onUnauthorizedRef = useRef(false);
162
-
163
- const router = useCfgRouter();
164
- const pathname = usePathname();
165
- const queryParams = useQueryParams();
166
-
167
- // Use localStorage hook for email
168
- const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(EMAIL_STORAGE_KEY, null);
169
-
170
- // Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect.
171
- // NOTE: onRefreshError is wired below, after clearAuthState is defined
172
- // (see handleProactiveRefreshError), so it can collapse a dead session to the
173
- // login form. useTokenRefresh only runs when a refresh token exists.
174
-
175
- // Map AccountsContext profile to UserProfile
176
- const user = accounts.profile as UserProfile | null;
177
-
178
- // Use refs to avoid dependency issues
179
- const userRef = useRef(user);
180
82
  const configRef = useRef(config);
181
- const isLoadingProfileRef = useRef(false);
182
-
183
- // Update refs when values change
184
- useEffect(() => {
185
- userRef.current = user;
186
- }, [user]);
187
-
188
83
  useEffect(() => {
189
84
  configRef.current = config;
190
85
  }, [config]);
191
86
 
192
- // Note: API URL is configured in BaseClient, not at runtime
193
-
194
- // Common function to clear auth state
195
- const clearAuthState = useCallback((caller: string) => {
196
- authLogger.info('clearAuthState >> caller', caller);
197
- apiAccounts.clearToken();
198
- clearProfileCache(); // Clear profile cache from localStorage
199
- // Note: user is now managed by AccountsContext, will auto-update
200
- setInitialized(true);
201
- setIsLoading(false);
202
- // Tokens just went away — recompute `isAuthenticated` so the guard shows
203
- // the login form instead of hanging on the preloader.
204
- bumpAuthTick();
205
- }, [bumpAuthTick]);
206
-
207
- // Proactive-refresh failure handler. useTokenRefresh (expiry timer / focus /
208
- // reconnect) runs OUTSIDE the response interceptor, so its failures do NOT go
209
- // through auth.onUnauthorized. A failed proactive refresh means the refresh
210
- // token is dead (expired, or blacklisted after rotation) → the session is
211
- // unrecoverable → clear it and show the login form. This is what was missing:
212
- // the old handler just logged a warning and left stale tokens, so the app hung
213
- // on "Authenticating…" forever.
214
- const handleProactiveRefreshError = useCallback((error: Error) => {
215
- authLogger.warn('Proactive token refresh failed — session is dead, redirecting to login:', error.message);
216
- // Guard against the redirect firing twice if the interceptor path already tripped.
217
- if (onUnauthorizedRef.current) return;
218
- onUnauthorizedRef.current = true;
219
- clearAuthState('proactiveRefresh:failed');
220
- const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
221
- router.hardReplace(authCallbackUrl);
222
- }, [clearAuthState, router]);
223
-
224
- // Automatic token refresh — refreshes before expiry, on focus, and on reconnect.
225
- useTokenRefresh({
226
- enabled: true,
227
- onRefresh: () => {
228
- authLogger.info('Token auto-refreshed successfully');
229
- },
230
- onRefreshError: handleProactiveRefreshError,
231
- });
232
-
233
- // Global error handler for auth-related errors
234
- // Only clears auth on actual authentication errors (401), not on any API error
235
- const handleGlobalAuthError = useCallback((error: any, context: string = 'API Request') => {
236
- // Only clear auth on actual authentication errors (401)
237
- // Don't logout on validation errors, server errors, network issues, etc.
238
- const isAuthError = error?.status === 401 ||
239
- error?.statusCode === 401 ||
240
- error?.code === 'token_not_valid' ||
241
- error?.code === 'authentication_failed';
242
-
243
- if (isAuthError) {
244
- authLogger.warn(`Authentication error in ${context}, clearing tokens`);
245
- clearAuthState(`globalAuthError:${context}`);
246
- return true;
247
- }
248
-
249
- // Log but don't logout for other errors
250
- if (error?.success === false) {
251
- authLogger.warn(`Non-auth error in ${context} (not clearing session):`, error?.message || error);
252
- }
253
-
254
- return false;
255
- }, [clearAuthState]);
256
-
257
- // Unrecoverable-401 handler from the generated client. Fires ONLY when a 401
258
- // could not be recovered by the refresh path — no refresh token, no handler,
259
- // the refresh call failed (e.g. "Token is blacklisted" after rotation), or
260
- // the retried request still 401'd. Transparently-recovered 401s never reach
261
- // here. This is the single place that guarantees a dead session collapses to
262
- // the login form: clear tokens + hard-redirect to /auth.
263
- //
264
- // Without this, a proactive refresh (useTokenRefresh) hitting a blacklisted
265
- // token would just log a warning and leave stale tokens in storage, so the
266
- // guard stayed on "Authenticating…" forever and never rendered the form.
87
+ const accountsRef = useRef(accounts);
267
88
  useEffect(() => {
268
- const handler = () => {
269
- if (onUnauthorizedRef.current) return; // de-dupe concurrent 401s
270
- onUnauthorizedRef.current = true;
271
- authLogger.warn('Unrecoverable 401 (refresh failed) clearing session and redirecting to login');
272
- clearAuthState('onUnauthorized:401');
273
- const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
274
- router.hardReplace(authCallbackUrl);
275
- };
276
- apiAccounts.onUnauthorized(handler);
277
- return () => {
278
- // Only clear if we're still the registered handler (StrictMode double-mount
279
- // can register a newer one before this cleanup runs).
280
- apiAccounts.onUnauthorized(null);
281
- };
282
- // clearAuthState/router are stable (useCallback); configRef is a ref.
283
- }, [clearAuthState, router]);
284
-
285
- // SWR onError: auto-logout on 401 from any SWR hook
286
- const isAutoLoggingOutRef = useRef(false);
287
- const swrOnError = useCallback((error: any) => {
288
- const isAuthError = error?.status === 401 ||
289
- error?.statusCode === 401 ||
290
- error?.code === 'token_not_valid' ||
291
- error?.code === 'authentication_failed';
292
-
293
- if (isAuthError && !isAutoLoggingOutRef.current) {
294
- isAutoLoggingOutRef.current = true;
295
- authLogger.warn('SWR 401 error detected, auto-logout');
296
- clearAuthState('swrOnError:401');
297
-
298
- const authCallbackUrl = configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
299
- router.hardReplace(authCallbackUrl);
300
- }
301
- }, [clearAuthState, router]);
302
- const swrConfig = useMemo(() => ({ onError: swrOnError }), [swrOnError]);
303
-
304
- // Simple profile loading without retry - now uses AccountsContext with memoization
305
- const loadCurrentProfile = useCallback(async (callerId?: string): Promise<void> => {
306
- const finalCallerId = callerId || 'AuthContext.loadCurrentProfile';
89
+ accountsRef.current = accounts;
90
+ }, [accounts]);
91
+
92
+ // ── Bootstrap: settle the profile for the current session ─────────────────
93
+ // The session itself needs no async bootstrap — the store answers
94
+ // synchronously from token `exp`. The only async part is the profile fetch,
95
+ // and it re-runs automatically when a session appears later (e.g. iframe
96
+ // token delivery via postMessage → setSession → snapshot flips).
97
+ const [profileSettled, setProfileSettled] = useState(false);
98
+ const hasProfile = Boolean(accounts.profile);
99
+ const profileAttemptedRef = useRef(false);
307
100
 
308
- // Check if profile loading is already in progress
309
- if (isLoadingProfileRef.current) {
310
- authLogger.debug(`Profile loading already in progress, skipping duplicate call from: ${finalCallerId}`);
101
+ useEffect(() => {
102
+ if (!isAuthenticated) {
103
+ profileAttemptedRef.current = false;
104
+ // Dead tokens may still sit in storage (access AND refresh both
105
+ // expired). Purge so the login form shows instantly instead of the app
106
+ // firing doomed API calls — the automated "clear localStorage and the
107
+ // form appears".
108
+ if (auth.getToken() || auth.getRefreshToken()) {
109
+ authLogger.warn('Dead session in storage — clearing');
110
+ auth.clearSession();
111
+ clearProfileCache();
112
+ }
113
+ setProfileSettled(true);
311
114
  return;
312
115
  }
313
-
314
- authLogger.debug(`loadCurrentProfile called by: ${finalCallerId}`);
315
-
316
- try {
317
- isLoadingProfileRef.current = true;
318
-
319
- // Ensure API clients are properly initialized with current token
320
- const isAuth = apiAccounts.isAuthenticated();
321
- const token = apiAccounts.getToken();
322
- // authLogger.debug('isAuthenticated:', isAuth, 'token:', token ? token.substring(0, 20) + '...' : 'null');
323
-
324
- if (!isAuth) {
325
- authLogger.warn('No valid authentication token, throwing error');
326
- throw new Error('No valid authentication token');
327
- }
328
-
329
- // Check if profile is already loaded in AccountsContext to prevent duplicate API calls
330
- if (accounts.profile && !accounts.isLoadingProfile) {
331
- authLogger.debug('Profile already loaded in AccountsContext, skipping API call');
332
- setInitialized(true);
333
- return;
334
- }
335
-
336
- // Refresh profile from AccountsContext
337
- const refreshedProfile = await accounts.refreshProfile({ callerId: finalCallerId });
338
-
339
- if (refreshedProfile) {
340
- authLogger.info('Profile loaded successfully:', refreshedProfile.id);
341
- } else {
342
- authLogger.warn('Profile refresh returned undefined - but keeping tokens');
343
- }
344
-
345
- // Always mark as initialized if we have valid tokens
346
- // Don't clear tokens just because profile fetch failed
347
- setInitialized(true);
348
- } catch (error: any) {
349
- authLogger.error('Failed to load profile:', error);
350
- // Only clear auth state on actual authentication errors (401)
351
- // Don't logout on network errors, server errors, etc.
352
- const isAuthError = error?.status === 401 ||
353
- error?.statusCode === 401 ||
354
- error?.code === 'token_not_valid' ||
355
- error?.code === 'authentication_failed';
356
-
357
- if (isAuthError) {
358
- authLogger.warn('Authentication error, clearing session');
359
- clearAuthState('loadCurrentProfile:authError');
360
- } else {
361
- // Keep tokens, mark as initialized - user can retry
362
- authLogger.warn('Profile load failed but keeping session (non-auth error)');
363
- setInitialized(true);
364
- }
365
- } finally {
366
- isLoadingProfileRef.current = false;
116
+ if (hasProfile || profileAttemptedRef.current) {
117
+ setProfileSettled(true);
118
+ return;
367
119
  }
368
- }, [clearAuthState, handleGlobalAuthError, accounts]);
369
-
370
- // Initialize auth state once
371
- useEffect(() => {
372
- if (initialized) return;
373
-
374
- const initializeAuth = async () => {
375
- authLogger.info('Initializing auth...');
376
-
377
- // Check if running in iframe (AdminLayout will handle auth via postMessage)
378
- const isInIframe = typeof window !== 'undefined' && window.self !== window.top;
379
- authLogger.info('Is in iframe:', isInIframe);
380
-
381
- // Debug token state
382
- const token = apiAccounts.getToken();
383
- const refreshToken = apiAccounts.getRefreshToken();
384
- authLogger.info('Token from API:', token ? `${token.substring(0, 20)}...` : 'null');
385
- authLogger.info('Refresh token from API:', refreshToken ? `${refreshToken.substring(0, 20)}...` : 'null');
386
- authLogger.info('localStorage keys:', Object.keys(localStorage).filter(k => k.includes('token') || k.includes('auth')));
387
-
388
- // Fail-fast: if storage holds a DEAD session (access expired/malformed AND
389
- // refresh unusable), clear it and show the login form immediately — never
390
- // fire doomed profile/refresh calls that just 401 and hang the UI. This is
391
- // the automated version of "clear localStorage, then the form appears".
392
- // Skipped in iframe mode: the parent frame owns token delivery there.
393
- if (!isInIframe && isSessionDeadOnBootstrap(token, refreshToken)) {
394
- authLogger.warn('Bootstrap: dead session in storage (access + refresh both unusable) — clearing and showing login');
395
- clearAuthState('initializeAuth:deadSession');
396
- return;
397
- }
398
-
399
- const hasTokens = hasValidTokens();
400
- authLogger.info('Has tokens:', hasTokens);
401
-
402
- // Check if profile is already loaded from cache (AccountsContext initialization)
403
- if (userRef.current) {
404
- authLogger.info('Profile already loaded from AccountsContext cache, skipping API request');
405
- setInitialized(true);
406
- setIsLoading(false);
407
- return;
408
- }
409
-
410
- // Check if cache exists in localStorage (before userRef updates)
411
- const cachedProfile = getCachedProfile();
412
- if (cachedProfile) {
413
- authLogger.info('Profile found in localStorage cache, skipping API request');
414
- setInitialized(true);
415
- setIsLoading(false);
416
- return;
417
- }
418
-
419
- // In iframe mode WITHOUT tokens yet - wait for AdminLayout to receive them via postMessage
420
- // Don't initialize yet - AdminLayout.handleAuthToken will call loadCurrentProfile
421
- if (isInIframe && !hasTokens) {
422
- authLogger.info('Running in iframe without tokens - waiting for parent to send via postMessage');
423
- authLogger.info('AdminLayout will handle auth initialization, skipping AuthContext init');
424
- setInitialized(true); // Mark as initialized to prevent re-initialization
425
- setIsLoading(false);
426
- return;
427
- }
428
-
429
- if (hasTokens) {
430
- setIsLoading(true);
431
- try {
432
- authLogger.info('No cached profile found, loading from API...');
433
- await loadCurrentProfile('AuthContext.initializeAuth');
434
- } catch (error: any) {
435
- authLogger.error('Failed to load profile during initialization:', error);
436
- // Only clear on 401 auth error, otherwise keep session
437
- const isAuthError = error?.status === 401 ||
438
- error?.statusCode === 401 ||
439
- error?.code === 'token_not_valid' ||
440
- error?.code === 'authentication_failed';
441
-
442
- if (isAuthError) {
443
- clearAuthState('initializeAuth:authError');
444
- } else {
445
- authLogger.warn('Init profile load failed but keeping session');
446
- setInitialized(true);
447
- }
448
- }
449
- setIsLoading(false);
450
- } else {
451
- setInitialized(true);
452
- setIsLoading(false);
453
- }
120
+ profileAttemptedRef.current = true;
121
+ let cancelled = false;
122
+ accountsRef.current
123
+ .refreshProfile({ callerId: 'AuthProvider:bootstrap' })
124
+ .catch((error: unknown) => {
125
+ // A terminal 401 collapses the session via the store's
126
+ // onSessionExpired funnel below. Anything else (network, 5xx) keeps
127
+ // the session — the user can retry.
128
+ authLogger.warn('Profile load failed (session kept):', error);
129
+ })
130
+ .finally(() => {
131
+ if (!cancelled) setProfileSettled(true);
132
+ });
133
+ return () => {
134
+ cancelled = true;
454
135
  };
136
+ }, [isAuthenticated, hasProfile]);
455
137
 
456
- initializeAuth();
457
- // eslint-disable-next-line react-hooks/exhaustive-deps
458
- }, [initialized]);
138
+ const isLoading = !profileSettled;
459
139
 
460
- // Redirect logic - only for unauthenticated users on protected pages
140
+ // ── The ONE exit: terminal 401 login ─────────────────────────────────────
141
+ // The store has already cleared the session before this fires (subscribers
142
+ // flipped to 'anonymous'), so the handler only routes. Registration returns
143
+ // its own unsubscribe — StrictMode double-mounts compose instead of racing.
144
+ const expiredHandledRef = useRef(false);
461
145
  useEffect(() => {
462
- if (!initialized) return;
146
+ const unsubscribe = auth.onSessionExpired(() => {
147
+ if (expiredHandledRef.current) return;
148
+ expiredHandledRef.current = true;
149
+ authLogger.warn('Session expired (terminal 401) — redirecting to login');
150
+ clearProfileCache();
151
+ Analytics.event(AnalyticsEvent.AUTH_SESSION_EXPIRED, {
152
+ category: AnalyticsCategory.AUTH,
153
+ });
154
+ const authCallbackUrl =
155
+ configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
156
+ router.hardReplace(authCallbackUrl);
157
+ });
158
+ return unsubscribe;
159
+ }, [router]);
463
160
 
464
- // Consider authenticated if we have valid tokens, even without profile
465
- const isAuthenticated = apiAccounts.isAuthenticated();
161
+ // ── Redirect authenticated users away from the auth page ──────────────────
162
+ // Skipped while a `?flow` param is present (OTP / 2FA flows in progress).
163
+ useEffect(() => {
164
+ if (!isAuthenticated) return;
466
165
  const authRoute = config?.routes?.auth || defaultRoutes.auth;
467
- const isAuthPage = pathname === authRoute;
468
- const flowParam = queryParams.get('flow');
469
-
470
- // Only redirect authenticated users away from auth page if they're not in a flow
471
- // This prevents interference with OTP verification flow
472
- if (isAuthenticated && isAuthPage && !flowParam) {
473
- const callbackUrl = config?.routes?.defaultCallback || defaultRoutes.defaultCallback;
474
- router.push(callbackUrl);
166
+ if (pathname === authRoute && !queryParams.get('flow')) {
167
+ router.push(config?.routes?.defaultCallback || defaultRoutes.defaultCallback);
475
168
  }
476
- }, [initialized, pathname, queryParams, config?.routes]);
477
-
478
- const pushToDefaultCallbackUrl = useCallback(() => {
479
- const callbackUrl = config?.routes?.defaultCallback || defaultRoutes.defaultCallback;
480
- router.push(callbackUrl);
481
- }, [config?.routes, router]);
482
-
483
- const pushToDefaultAuthCallbackUrl = useCallback(() => {
484
- const authCallbackUrl = config?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
485
- router.push(authCallbackUrl);
486
- }, [config?.routes, router]);
487
-
488
- // Memoized checkAuthAndRedirect function
489
- const checkAuthAndRedirect = useCallback(async () => {
490
- try {
491
- setIsLoading(true);
492
- const isAuthenticated = apiAccounts.isAuthenticated();
493
-
494
- if (isAuthenticated) {
495
- await loadCurrentProfile();
496
- if (userRef.current) {
497
- pushToDefaultCallbackUrl();
498
- }
499
- } else {
500
- pushToDefaultAuthCallbackUrl();
501
- }
502
- } catch (error) {
503
- authLogger.error('Failed to check authentication:', error);
504
- // Use global error handler first
505
- if (!handleGlobalAuthError(error, 'checkAuthAndRedirect')) {
506
- clearAuthState('checkAuthAndRedirect');
169
+ }, [isAuthenticated, pathname, queryParams, config?.routes, router]);
170
+
171
+ // ── Profile actions (delegated to AccountsContext) ─────────────────────────
172
+
173
+ const loadCurrentProfile = useCallback(async (callerId?: string): Promise<void> => {
174
+ if (!auth.isAuthenticated()) {
175
+ throw new Error('No valid authentication token');
176
+ }
177
+ await accountsRef.current.refreshProfile({
178
+ callerId: callerId || 'AuthContext.loadCurrentProfile',
179
+ });
180
+ }, []);
181
+
182
+ const checkAuthAndRedirect = useCallback(async (): Promise<void> => {
183
+ const routes = configRef.current?.routes;
184
+ if (auth.isAuthenticated()) {
185
+ try {
186
+ await loadCurrentProfile('checkAuthAndRedirect');
187
+ } catch (error) {
188
+ authLogger.warn('checkAuthAndRedirect: profile load failed', error);
507
189
  }
508
- pushToDefaultAuthCallbackUrl();
509
- } finally {
510
- setIsLoading(false);
190
+ router.push(routes?.defaultCallback || defaultRoutes.defaultCallback);
191
+ } else {
192
+ router.push(routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback);
511
193
  }
512
- }, [loadCurrentProfile, clearAuthState, pushToDefaultCallbackUrl, pushToDefaultAuthCallbackUrl, handleGlobalAuthError]);
194
+ }, [loadCurrentProfile, router]);
195
+
196
+ // ── OTP login flow ─────────────────────────────────────────────────────────
513
197
 
514
- // OTP methods - email only - now uses AccountsContext
515
198
  const requestOTP = useCallback(
516
199
  async (identifier: string, sourceUrl?: string): Promise<OTPRequestResult> => {
517
- // Clear tokens before requesting OTP
518
- apiAccounts.clearToken();
200
+ // A login attempt invalidates whatever session was lying around.
201
+ auth.clearSession();
519
202
 
520
203
  try {
521
- const result = await accounts.requestOTP({
204
+ const result = await accountsRef.current.requestOTP({
522
205
  identifier,
523
206
  source_url: sourceUrl,
524
207
  });
525
208
 
526
- // Track OTP request
527
209
  Analytics.event(AnalyticsEvent.AUTH_OTP_REQUEST, {
528
210
  category: AnalyticsCategory.AUTH,
529
211
  label: 'email',
@@ -531,35 +213,38 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
531
213
 
532
214
  return {
533
215
  success: true,
534
- message: result.message || `OTP code sent to your email address`,
216
+ message: result.message || 'OTP code sent to your email address',
535
217
  webmail: result.webmail ?? null,
536
218
  };
537
219
  } catch (error) {
538
220
  authLogger.error('Request OTP error:', error);
539
221
 
540
222
  if (error instanceof APIError) {
541
- const retryAfter = error.response?.retry_after ?? error.response?.retryAfter;
542
- // Django returns { error, error_code, retry_after } errorMessage reads 'detail'/'message' only
543
- const message = error.response?.error || error.response?.detail || error.response?.message || error.errorMessage;
223
+ const body = error.response as
224
+ | { retry_after?: number; retryAfter?: number }
225
+ | undefined;
226
+ const retryAfter = body?.retry_after ?? body?.retryAfter;
544
227
  return {
545
228
  success: false,
546
229
  statusCode: error.statusCode,
547
- message,
230
+ message: apiErrorMessage(error),
548
231
  retryAfter: typeof retryAfter === 'number' ? retryAfter : undefined,
549
232
  };
550
233
  }
551
-
552
- return {
553
- success: false,
554
- message: 'Failed to send OTP',
555
- };
234
+ return { success: false, message: 'Failed to send OTP' };
556
235
  }
557
236
  },
558
- [accounts],
237
+ [],
559
238
  );
560
239
 
561
240
  const verifyOTP = useCallback(
562
- async (identifier: string, otpCode: string, sourceUrl?: string, redirectUrl?: string, skipRedirect?: boolean): Promise<{
241
+ async (
242
+ identifier: string,
243
+ otpCode: string,
244
+ sourceUrl?: string,
245
+ redirectUrl?: string,
246
+ skipRedirect?: boolean,
247
+ ): Promise<{
563
248
  success: boolean;
564
249
  message: string;
565
250
  user?: UserProfile;
@@ -568,14 +253,12 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
568
253
  should_prompt_2fa?: boolean;
569
254
  }> => {
570
255
  try {
571
- // AccountsContext automatically saves tokens and refreshes profile
572
- const result = await accounts.verifyOTP({
256
+ const result = await accountsRef.current.verifyOTP({
573
257
  identifier,
574
258
  otp: otpCode,
575
259
  source_url: sourceUrl,
576
260
  });
577
261
 
578
- // Check if 2FA is required - return early with session info
579
262
  if (result.requires_2fa && result.session_id) {
580
263
  authLogger.info('2FA required, session:', result.session_id);
581
264
  return {
@@ -587,40 +270,32 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
587
270
  };
588
271
  }
589
272
 
590
- // Verify that we got valid tokens (only required when 2FA is not needed)
591
273
  if (!result.access || !result.refresh) {
592
274
  authLogger.error('Verify OTP returned invalid response:', result);
593
- return {
594
- success: false,
595
- message: 'Invalid OTP verification response',
596
- };
275
+ return { success: false, message: 'Invalid OTP verification response' };
597
276
  }
598
277
 
599
- // Save email identifier
600
278
  if (identifier.includes('@')) {
601
279
  setStoredEmail(identifier);
602
280
  }
603
281
 
604
- // Small delay to ensure profile state is updated
605
- await new Promise(resolve => setTimeout(resolve, 200));
606
-
607
- // Track successful login
608
282
  Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
609
283
  category: AnalyticsCategory.AUTH,
610
284
  label: 'email',
611
285
  });
612
-
613
- // Set user ID for future tracking
614
286
  if (result.user?.id) {
615
287
  Analytics.setUser(String(result.user.id));
616
288
  }
617
289
 
618
- // Handle redirect logic (unless skipRedirect is true)
619
290
  if (!skipRedirect) {
620
- // Priority: provided redirectUrl > saved redirect > config default
621
- // Use hardPush for full page reload - ensures all React contexts reinitialize
291
+ // Priority: explicit redirectUrl > saved redirect > config default.
292
+ // hardPush = full page load so every context reinitializes cleanly.
622
293
  const savedRedirect = redirectManager.useAndClearRedirect();
623
- const finalRedirectUrl = redirectUrl || savedRedirect || config?.routes?.defaultCallback || defaultRoutes.defaultCallback;
294
+ const finalRedirectUrl =
295
+ redirectUrl ||
296
+ savedRedirect ||
297
+ configRef.current?.routes?.defaultCallback ||
298
+ defaultRoutes.defaultCallback;
624
299
  authLogger.info('Redirecting after auth to:', finalRedirectUrl);
625
300
  router.hardPush(finalRedirectUrl);
626
301
  }
@@ -633,133 +308,89 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
633
308
  };
634
309
  } catch (error) {
635
310
  authLogger.error('Verify OTP error:', error);
636
-
637
- // Track failed verification
638
311
  Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
639
312
  category: AnalyticsCategory.AUTH,
640
313
  label: 'email',
641
314
  });
642
-
643
315
  if (error instanceof APIError) {
644
- const message = error.response?.error || error.response?.detail || error.response?.message || error.errorMessage;
645
- return { success: false, message };
316
+ return { success: false, message: apiErrorMessage(error) };
646
317
  }
647
- return {
648
- success: false,
649
- message: 'Failed to verify OTP',
650
- };
318
+ return { success: false, message: 'Failed to verify OTP' };
651
319
  }
652
320
  },
653
- [setStoredEmail, config?.routes?.defaultCallback, accounts, router],
321
+ [setStoredEmail, redirectManager, router],
654
322
  );
655
323
 
656
- const refreshToken = useCallback(async (): Promise<{ success: boolean; message: string }> => {
657
- try {
658
- const refreshTokenValue = apiAccounts.getRefreshToken();
659
- if (!refreshTokenValue) {
660
- clearAuthState('refreshToken:noToken');
661
-
662
- // Track session expired
663
- Analytics.event(AnalyticsEvent.AUTH_SESSION_EXPIRED, {
664
- category: AnalyticsCategory.AUTH,
665
- });
666
-
667
- return {
668
- success: false,
669
- message: 'No refresh token available',
670
- };
671
- }
672
-
673
- await accounts.refreshToken(refreshTokenValue);
324
+ // ── Session actions ────────────────────────────────────────────────────────
674
325
 
675
- // Track successful refresh
326
+ const refreshToken = useCallback(async (): Promise<{ success: boolean; message: string }> => {
327
+ // Same single-flight + Web-Locks path as the 401 interceptor — rotation,
328
+ // dedup and rotated-token persistence come for free.
329
+ const access = await auth.refreshNow();
330
+ if (access) {
676
331
  Analytics.event(AnalyticsEvent.AUTH_TOKEN_REFRESH, {
677
332
  category: AnalyticsCategory.AUTH,
678
333
  });
679
-
680
- return {
681
- success: true,
682
- message: 'Token refreshed',
683
- };
684
- } catch (error) {
685
- authLogger.error('Refresh token error:', error);
686
- clearAuthState('refreshToken:error');
687
-
688
- // Track refresh failure
689
- Analytics.event(AnalyticsEvent.AUTH_TOKEN_REFRESH_FAIL, {
690
- category: AnalyticsCategory.AUTH,
691
- });
692
-
693
- return {
694
- success: false,
695
- message: 'Error refreshing token',
696
- };
334
+ return { success: true, message: 'Token refreshed' };
697
335
  }
698
- }, [clearAuthState, accounts]);
336
+ Analytics.event(AnalyticsEvent.AUTH_TOKEN_REFRESH_FAIL, {
337
+ category: AnalyticsCategory.AUTH,
338
+ });
339
+ return { success: false, message: 'Error refreshing token' };
340
+ }, []);
699
341
 
700
342
  const logout = useCallback((): void => {
701
- // Track logout
702
343
  Analytics.event(AnalyticsEvent.AUTH_LOGOUT, {
703
344
  category: AnalyticsCategory.AUTH,
704
345
  });
705
-
706
- accounts.logout(); // Clear tokens and profile
707
- setInitialized(true);
708
- setIsLoading(false);
709
-
710
- // Use hardReplace for full page reload + replace history
711
- // This ensures contexts reinitialize AND back button won't return to protected page
712
- const authCallbackUrl = config?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
346
+ accountsRef.current.logout(); // clears session + profile + cache
347
+ // hardReplace = full reload + history replace, so contexts reinitialize
348
+ // and Back doesn't return to a protected page.
349
+ const authCallbackUrl =
350
+ configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
713
351
  router.hardReplace(authCallbackUrl);
714
- }, [accounts, config?.routes?.defaultAuthCallback, router]);
352
+ }, [router]);
353
+
354
+ // ── Derived state ──────────────────────────────────────────────────────────
715
355
 
716
- // Computed: Is admin user (staff or superuser)
717
- const isAdminUser = useMemo(() => {
718
- return Boolean(user?.is_staff || user?.is_superuser);
719
- }, [user]);
356
+ const user = (accounts.profile as UserProfile | undefined) ?? null;
357
+ const isAdminUser = Boolean(user?.is_staff || user?.is_superuser);
720
358
 
721
359
  // Role-aware console verbosity: admins (and dev) get debug logs, regular
722
- // users in production get a clean console (errors only). Re-applied whenever
723
- // the admin status changes (login / logout / profile load).
360
+ // users in production get a clean console.
724
361
  useEffect(() => {
725
362
  applyRoleLogPolicy({ isAdmin: isAdminUser });
726
363
  }, [isAdminUser]);
727
364
 
728
- // Profile management: Update profile data
729
365
  const updateProfile = useCallback(
730
- async (data: { first_name?: string; last_name?: string; username?: string }): Promise<UserProfile> => {
731
- const result = await accounts.partialUpdateProfile(data);
366
+ async (data: {
367
+ first_name?: string;
368
+ last_name?: string;
369
+ username?: string;
370
+ }): Promise<UserProfile> => {
371
+ const result = await accountsRef.current.partialUpdateProfile(data);
732
372
  return result as UserProfile;
733
373
  },
734
- [accounts]
374
+ [],
735
375
  );
736
376
 
737
- // Profile management: Upload avatar
738
- const uploadAvatar = useCallback(
739
- async (avatar: File | Blob): Promise<UserProfile> => {
740
- const result = await accounts.uploadAvatar(avatar);
741
- return result as UserProfile;
742
- },
743
- [accounts]
744
- );
377
+ const uploadAvatar = useCallback(async (avatar: File | Blob): Promise<UserProfile> => {
378
+ const result = await accountsRef.current.uploadAvatar(avatar);
379
+ return result as UserProfile;
380
+ }, []);
745
381
 
746
- // Memoized context value
747
382
  const value = useMemo<AuthContextType>(
748
383
  () => ({
749
384
  user,
750
385
  isLoading,
751
- // Authenticated iff the session is locally alive (access valid OR a usable
752
- // refresh token) — validated by `exp`, NOT by mere token presence. This is
753
- // what makes the guard self-sufficient: an expired session reads as
754
- // unauthenticated WITHOUT waiting for a server 401 (which CSP / a hung
755
- // request / offline can swallow). `authTick` (in deps) forces recompute
756
- // after tokens are cleared/set, since token state lives in storage.
757
- isAuthenticated: isSessionAlive(),
386
+ // Reactive: flips via the store snapshot (token writes, refreshes,
387
+ // expiry timer, cross-tab/cross-store sync) — no ticks, no polling.
388
+ isAuthenticated,
758
389
  isAdminUser,
759
390
  loadCurrentProfile,
760
391
  checkAuthAndRedirect,
761
- getToken: () => apiAccounts.getToken(),
762
- getRefreshToken: () => apiAccounts.getRefreshToken(),
392
+ getToken: () => auth.getToken(),
393
+ getRefreshToken: () => auth.getRefreshToken(),
763
394
  getSavedEmail: () => storedEmail,
764
395
  saveEmail: setStoredEmail,
765
396
  clearSavedEmail: clearStoredEmail,
@@ -767,19 +398,17 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
767
398
  verifyOTP,
768
399
  refreshToken,
769
400
  logout,
770
- // Redirect URL methods
771
401
  saveRedirectUrl: redirectManager.setRedirect,
772
402
  getRedirectUrl: redirectManager.getFinalRedirectUrl,
773
403
  clearRedirectUrl: redirectManager.clearRedirect,
774
404
  hasRedirectUrl: redirectManager.hasRedirect,
775
- // Profile management
776
405
  updateProfile,
777
406
  uploadAvatar,
778
407
  }),
779
408
  [
780
409
  user,
781
410
  isLoading,
782
- authTick, // recompute isAuthenticated when tokens are cleared/set
411
+ isAuthenticated,
783
412
  isAdminUser,
784
413
  loadCurrentProfile,
785
414
  checkAuthAndRedirect,
@@ -796,27 +425,19 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
796
425
  ],
797
426
  );
798
427
 
799
- return (
800
- <AuthContext.Provider value={value}>
801
- <SWRConfig value={swrConfig}>{children}</SWRConfig>
802
- </AuthContext.Provider>
803
- );
428
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
804
429
  };
805
430
 
806
- // Wrapper that provides AccountsContext
807
431
  /**
808
432
  * AuthProvider — wraps AccountsProvider + AuthProviderInternal.
809
433
  *
810
434
  * Memoised: re-renders only when `config` reference or `children` change.
811
- * Internal auth state is isolated inside AuthProviderInternal.
812
435
  */
813
436
  function AuthProviderRaw({ children, config, enabled = true }: AuthProviderProps) {
814
437
  if (!enabled) return <>{children}</>;
815
438
  return (
816
439
  <AccountsProvider>
817
- <AuthProviderInternal config={config}>
818
- {children}
819
- </AuthProviderInternal>
440
+ <AuthProviderInternal config={config}>{children}</AuthProviderInternal>
820
441
  </AccountsProvider>
821
442
  );
822
443
  }
@@ -824,8 +445,8 @@ function AuthProviderRaw({ children, config, enabled = true }: AuthProviderProps
824
445
  export const AuthProvider = memo(AuthProviderRaw);
825
446
 
826
447
  /**
827
- * Default auth state for SSR/outside provider
828
- * Returns safe defaults that indicate "not authenticated, not loading"
448
+ * Default auth state for SSR/outside provider.
449
+ * Safe defaults that read as "not authenticated, not loading".
829
450
  */
830
451
  const defaultAuthState: AuthContextType = {
831
452
  user: null,
@@ -857,11 +478,11 @@ const defaultAuthState: AuthContextType = {
857
478
  authLogger.warn('useAuth: refreshToken called outside AuthProvider');
858
479
  return { success: false, message: 'AuthProvider not available' };
859
480
  },
860
- logout: async (_options?: { skipConfirm?: boolean }) => {
481
+ logout: () => {
861
482
  authLogger.warn('useAuth: logout called outside AuthProvider');
862
483
  },
863
484
  saveRedirectUrl: () => {},
864
- getRedirectUrl: () => null,
485
+ getRedirectUrl: () => '',
865
486
  clearRedirectUrl: () => {},
866
487
  hasRedirectUrl: () => false,
867
488
  updateProfile: async () => {
@@ -875,24 +496,20 @@ const defaultAuthState: AuthContextType = {
875
496
  };
876
497
 
877
498
  /**
878
- * Hook to access auth context
499
+ * Hook to access auth context.
879
500
  *
880
- * SSR-safe: Returns default unauthenticated state when called outside AuthProvider
881
- * (e.g., during static page generation or server-side rendering)
501
+ * SSR-safe: returns default unauthenticated state when called outside
502
+ * AuthProvider (e.g. during static page generation).
882
503
  */
883
504
  export const useAuth = (): AuthContextType => {
884
505
  const context = useContext(AuthContext);
885
-
886
506
  if (context === undefined) {
887
- // SSR or outside provider - return safe defaults
888
- // Log only in development to help debugging
889
507
  if (isBrowser && isDev) {
890
508
  authLogger.debug('useAuth called outside AuthProvider, returning default state');
891
509
  }
892
510
  return defaultAuthState;
893
511
  }
894
-
895
512
  return context;
896
513
  };
897
514
 
898
- export default AuthContext;
515
+ export default AuthContext;