@djangocfg/api 2.1.455 → 2.1.457

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 (89) hide show
  1. package/dist/auth.cjs +1611 -2148
  2. package/dist/auth.cjs.map +1 -1
  3. package/dist/auth.d.cts +37 -82
  4. package/dist/auth.d.ts +37 -82
  5. package/dist/auth.mjs +1581 -2118
  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 +292 -47
  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 +292 -47
  28. package/dist/clients.mjs.map +1 -1
  29. package/dist/hooks.cjs +200 -32
  30. package/dist/hooks.cjs.map +1 -1
  31. package/dist/hooks.mjs +200 -32
  32. package/dist/hooks.mjs.map +1 -1
  33. package/dist/index.cjs +292 -47
  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 +292 -47
  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 +288 -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 +263 -607
  71. package/src/auth/context/types.ts +13 -0
  72. package/src/auth/hooks/index.ts +8 -5
  73. package/src/auth/hooks/useAuthRedirect.ts +17 -0
  74. package/src/auth/hooks/useGithubAuth.ts +3 -3
  75. package/src/auth/hooks/useSession.ts +22 -0
  76. package/src/auth/hooks/useTwoFactor.ts +8 -6
  77. package/src/auth/middlewares/index.ts +5 -6
  78. package/src/auth/utils/guard.ts +0 -22
  79. package/src/auth/utils/index.ts +0 -2
  80. package/src/_api/generated/_cfg_accounts/events.ts +0 -198
  81. package/src/_api/generated/_cfg_centrifugo/events.ts +0 -198
  82. package/src/_api/generated/_cfg_totp/events.ts +0 -198
  83. package/src/auth/__tests__/jwt.test.ts +0 -119
  84. package/src/auth/__tests__/sessionBootstrap.test.ts +0 -111
  85. package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +0 -167
  86. package/src/auth/hooks/useAuthGuard.ts +0 -35
  87. package/src/auth/hooks/useTokenRefresh.ts +0 -131
  88. package/src/auth/refreshHandler.ts +0 -79
  89. package/src/auth/utils/jwt.ts +0 -66
@@ -1,529 +1,247 @@
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';
15
32
  import { APIError } from '../../_api/generated/helpers';
16
- import { clearProfileCache, getCachedProfile } from '../hooks/useProfileCache';
33
+ import { applyRoleLogPolicy } from '../../log-control';
34
+ import { useCfgRouter, useLocalStorage, useQueryParams, useSession } from '../hooks';
35
+ import { clearProfileCache } from '../hooks/useProfileCache';
17
36
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
18
- import { useTokenRefresh } from '../hooks/useTokenRefresh';
19
- import { ensureRefreshHandler } from '../refreshHandler';
20
37
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
38
+ import { isDev, isBrowser } from '../utils/env';
21
39
  import { authLogger } from '../utils/logger';
22
- import { isTokenExpired, getTokenExpiry } from '../utils/jwt';
23
- import { nextAuthEvaluationDelay } from '../utils/guard';
24
40
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
25
41
 
26
- import type { AuthConfig, AuthContextType, AuthProviderProps, UserProfile } from './types';
42
+ import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
27
43
  import type { OTPRequestResult } from '../types';
28
44
 
29
- // Default routes
45
+ // The ONLY place route conventions are declared. '/auth' is the sign-in page
46
+ // convention; the framework has no opinion about where an app's "home" is, so
47
+ // the callback default is the site root. Apps override via AuthConfig.routes;
48
+ // everything downstream reads the RESOLVED values from `useAuth().routes`.
30
49
  const defaultRoutes = {
31
50
  auth: '/auth',
32
- defaultCallback: '/dashboard',
51
+ defaultCallback: '/',
33
52
  defaultAuthCallback: '/auth',
34
53
  };
35
54
 
36
- const AuthContext = createContext<AuthContextType | undefined>(undefined);
55
+ /** Resolve AuthConfig.routes against the conventions — applied exactly once. */
56
+ export const resolveAuthRoutes = (
57
+ routes?: { auth?: string; defaultCallback?: string; defaultAuthCallback?: string },
58
+ ) => ({
59
+ auth: routes?.auth || defaultRoutes.auth,
60
+ defaultCallback: routes?.defaultCallback || defaultRoutes.defaultCallback,
61
+ defaultAuthCallback: routes?.defaultAuthCallback || routes?.auth || defaultRoutes.defaultAuthCallback,
62
+ });
37
63
 
38
- // Constants
39
64
  const EMAIL_STORAGE_KEY = 'auth_email';
40
65
 
41
- const hasValidTokens = (): boolean => {
42
- if (typeof window === 'undefined') return false;
43
- return apiAccounts.isAuthenticated();
44
- };
45
-
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
71
- };
66
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
72
67
 
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
- );
68
+ /** Extract a user-facing message from an APIError response body. */
69
+ const apiErrorMessage = (error: APIError): string => {
70
+ const body = error.response as
71
+ | { error?: string; detail?: string; message?: string }
72
+ | undefined;
73
+ return body?.error || body?.detail || body?.message || error.errorMessage;
96
74
  };
97
75
 
98
- // Internal provider that uses AccountsContext
99
76
  const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
100
77
  const accounts = useAccountsContext();
101
-
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();
107
-
108
- // Redirect URL manager for saving URL before auth redirect
109
- const redirectManager = useAuthRedirectManager({
110
- fallbackUrl: config?.routes?.defaultCallback || defaultRoutes.defaultCallback,
111
- clearOnUse: true,
112
- });
113
-
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);
78
+ const session = useSession();
79
+ const isAuthenticated = session.status === 'authenticated';
162
80
 
163
81
  const router = useCfgRouter();
164
82
  const pathname = usePathname();
165
83
  const queryParams = useQueryParams();
84
+ const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(
85
+ EMAIL_STORAGE_KEY,
86
+ null,
87
+ );
166
88
 
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.
89
+ // Resolved once; every consumer below (and, via context, every component)
90
+ // reads these never the raw config and never a module constant.
91
+ const routes = useMemo(() => resolveAuthRoutes(config?.routes), [config?.routes]);
92
+ const routesRef = useRef(routes);
93
+ useEffect(() => {
94
+ routesRef.current = routes;
95
+ }, [routes]);
174
96
 
175
- // Map AccountsContext profile to UserProfile
176
- const user = accounts.profile as UserProfile | null;
97
+ const redirectManager = useAuthRedirectManager({
98
+ fallbackUrl: routes.defaultCallback,
99
+ clearOnUse: true,
100
+ });
177
101
 
178
- // Use refs to avoid dependency issues
179
- const userRef = useRef(user);
180
102
  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
103
  useEffect(() => {
189
104
  configRef.current = config;
190
105
  }, [config]);
191
106
 
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
- });
107
+ const accountsRef = useRef(accounts);
108
+ useEffect(() => {
109
+ accountsRef.current = accounts;
110
+ }, [accounts]);
111
+
112
+ // Set when a login flow opts out of automatic navigation (verifyOTP with
113
+ // skipRedirect the AuthLayout success screen owns the final hardPush,
114
+ // including through a 2FA continuation). While set, the "redirect
115
+ // authenticated users away from /auth" effect must stand down, otherwise it
116
+ // races the success screen and eats the saved back-url.
117
+ const pendingManualNavRef = useRef(false);
118
+
119
+ // ── Bootstrap: settle the profile for the current session ─────────────────
120
+ // The session itself needs no async bootstrap — the store answers
121
+ // synchronously from token `exp`. The only async part is the profile fetch,
122
+ // and it re-runs automatically when a session appears later (e.g. iframe
123
+ // token delivery via postMessage setSession snapshot flips).
124
+ const [profileSettled, setProfileSettled] = useState(false);
125
+ const hasProfile = Boolean(accounts.profile);
126
+ const profileAttemptedRef = useRef(false);
232
127
 
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;
128
+ useEffect(() => {
129
+ if (!isAuthenticated) {
130
+ profileAttemptedRef.current = false;
131
+ // Dead tokens may still sit in storage (access AND refresh both
132
+ // expired). Purge so the login form shows instantly instead of the app
133
+ // firing doomed API calls the automated "clear localStorage and the
134
+ // form appears".
135
+ //
136
+ // CRITICAL: ask the STORE synchronously, never trust the React snapshot
137
+ // here. During hydration useSyncExternalStore serves the SERVER snapshot
138
+ // ('anonymous') for one commit even when perfectly live tokens sit in
139
+ // storage purging on that stale value nuked fresh logins (user typed
140
+ // OTP → tokens saved → hardPush → hydration commit saw 'anonymous' →
141
+ // tokens cleared → bounced back to /auth).
142
+ const hasTokens = Boolean(auth.getToken() || auth.getRefreshToken());
143
+ if (hasTokens && !auth.isAuthenticated()) {
144
+ authLogger.warn('Dead session in storage — clearing');
145
+ auth.clearSession();
146
+ clearProfileCache();
147
+ }
148
+ setProfileSettled(true);
149
+ return;
247
150
  }
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);
151
+ if (hasProfile || profileAttemptedRef.current) {
152
+ setProfileSettled(true);
153
+ return;
252
154
  }
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.
267
- 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);
155
+ profileAttemptedRef.current = true;
156
+ let cancelled = false;
157
+ accountsRef.current
158
+ .refreshProfile({ callerId: 'AuthProvider:bootstrap' })
159
+ .catch((error: unknown) => {
160
+ // A terminal 401 collapses the session via the store's
161
+ // onSessionExpired funnel below. Anything else (network, 5xx) keeps
162
+ // the session the user can retry.
163
+ authLogger.warn('Profile load failed (session kept):', error);
164
+ })
165
+ .finally(() => {
166
+ if (!cancelled) setProfileSettled(true);
167
+ });
277
168
  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);
169
+ cancelled = true;
281
170
  };
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]);
171
+ }, [isAuthenticated, hasProfile]);
303
172
 
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';
173
+ const isLoading = !profileSettled;
307
174
 
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}`);
311
- return;
175
+ // ── The ONE exit: terminal 401 login ─────────────────────────────────────
176
+ // The store has already cleared the session before this fires (subscribers
177
+ // flipped to 'anonymous'), so the handler only routes. Registration returns
178
+ // its own unsubscribe — StrictMode double-mounts compose instead of racing.
179
+ const expiredHandledRef = useRef(false);
180
+ useEffect(() => {
181
+ const unsubscribe = auth.onSessionExpired(() => {
182
+ if (expiredHandledRef.current) return;
183
+ expiredHandledRef.current = true;
184
+ authLogger.warn('Session expired (terminal 401) — redirecting to login');
185
+ clearProfileCache();
186
+ Analytics.event(AnalyticsEvent.AUTH_SESSION_EXPIRED, {
187
+ category: AnalyticsCategory.AUTH,
188
+ });
189
+ router.hardReplace(routesRef.current.defaultAuthCallback);
190
+ });
191
+ return unsubscribe;
192
+ }, [router]);
193
+
194
+ // ── Redirect authenticated users away from the auth page ──────────────────
195
+ // Stands down while a login flow on this page owns navigation (success
196
+ // screen / 2FA continuation — see pendingManualNavRef). When it does fire
197
+ // (an already-authenticated user landing on /auth), it honors the saved
198
+ // back-url with the same priority rule as every other consumer.
199
+ useEffect(() => {
200
+ if (!isAuthenticated || pendingManualNavRef.current) return;
201
+ if (pathname === routes.auth && !queryParams.get('flow')) {
202
+ const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
203
+ if (saved) redirectManager.clearRedirect();
204
+ router.push(saved || routes.defaultCallback);
312
205
  }
206
+ }, [isAuthenticated, pathname, queryParams, routes, router, redirectManager]);
313
207
 
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 });
208
+ // ── Profile actions (delegated to AccountsContext) ─────────────────────────
338
209
 
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;
210
+ const loadCurrentProfile = useCallback(async (callerId?: string): Promise<void> => {
211
+ if (!auth.isAuthenticated()) {
212
+ throw new Error('No valid authentication token');
367
213
  }
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
- }
454
- };
455
-
456
- initializeAuth();
457
- // eslint-disable-next-line react-hooks/exhaustive-deps
458
- }, [initialized]);
214
+ await accountsRef.current.refreshProfile({
215
+ callerId: callerId || 'AuthContext.loadCurrentProfile',
216
+ });
217
+ }, []);
459
218
 
460
- // Redirect logic - only for unauthenticated users on protected pages
461
- useEffect(() => {
462
- if (!initialized) return;
463
-
464
- // Consider authenticated if we have valid tokens, even without profile
465
- const isAuthenticated = apiAccounts.isAuthenticated();
466
- 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);
475
- }
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');
219
+ const checkAuthAndRedirect = useCallback(async (): Promise<void> => {
220
+ if (auth.isAuthenticated()) {
221
+ try {
222
+ await loadCurrentProfile('checkAuthAndRedirect');
223
+ } catch (error) {
224
+ authLogger.warn('checkAuthAndRedirect: profile load failed', error);
507
225
  }
508
- pushToDefaultAuthCallbackUrl();
509
- } finally {
510
- setIsLoading(false);
226
+ router.push(routesRef.current.defaultCallback);
227
+ } else {
228
+ router.push(routesRef.current.defaultAuthCallback);
511
229
  }
512
- }, [loadCurrentProfile, clearAuthState, pushToDefaultCallbackUrl, pushToDefaultAuthCallbackUrl, handleGlobalAuthError]);
230
+ }, [loadCurrentProfile, router]);
231
+
232
+ // ── OTP login flow ─────────────────────────────────────────────────────────
513
233
 
514
- // OTP methods - email only - now uses AccountsContext
515
234
  const requestOTP = useCallback(
516
235
  async (identifier: string, sourceUrl?: string): Promise<OTPRequestResult> => {
517
- // Clear tokens before requesting OTP
518
- apiAccounts.clearToken();
236
+ // A login attempt invalidates whatever session was lying around.
237
+ auth.clearSession();
519
238
 
520
239
  try {
521
- const result = await accounts.requestOTP({
240
+ const result = await accountsRef.current.requestOTP({
522
241
  identifier,
523
242
  source_url: sourceUrl,
524
243
  });
525
244
 
526
- // Track OTP request
527
245
  Analytics.event(AnalyticsEvent.AUTH_OTP_REQUEST, {
528
246
  category: AnalyticsCategory.AUTH,
529
247
  label: 'email',
@@ -531,35 +249,38 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
531
249
 
532
250
  return {
533
251
  success: true,
534
- message: result.message || `OTP code sent to your email address`,
252
+ message: result.message || 'OTP code sent to your email address',
535
253
  webmail: result.webmail ?? null,
536
254
  };
537
255
  } catch (error) {
538
256
  authLogger.error('Request OTP error:', error);
539
257
 
540
258
  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;
259
+ const body = error.response as
260
+ | { retry_after?: number; retryAfter?: number }
261
+ | undefined;
262
+ const retryAfter = body?.retry_after ?? body?.retryAfter;
544
263
  return {
545
264
  success: false,
546
265
  statusCode: error.statusCode,
547
- message,
266
+ message: apiErrorMessage(error),
548
267
  retryAfter: typeof retryAfter === 'number' ? retryAfter : undefined,
549
268
  };
550
269
  }
551
-
552
- return {
553
- success: false,
554
- message: 'Failed to send OTP',
555
- };
270
+ return { success: false, message: 'Failed to send OTP' };
556
271
  }
557
272
  },
558
- [accounts],
273
+ [],
559
274
  );
560
275
 
561
276
  const verifyOTP = useCallback(
562
- async (identifier: string, otpCode: string, sourceUrl?: string, redirectUrl?: string, skipRedirect?: boolean): Promise<{
277
+ async (
278
+ identifier: string,
279
+ otpCode: string,
280
+ sourceUrl?: string,
281
+ redirectUrl?: string,
282
+ skipRedirect?: boolean,
283
+ ): Promise<{
563
284
  success: boolean;
564
285
  message: string;
565
286
  user?: UserProfile;
@@ -568,14 +289,16 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
568
289
  should_prompt_2fa?: boolean;
569
290
  }> => {
570
291
  try {
571
- // AccountsContext automatically saves tokens and refreshes profile
572
- const result = await accounts.verifyOTP({
292
+ // The caller opting out of redirect commits to handling ALL navigation
293
+ // for this login — including the 2FA continuation and success screen.
294
+ pendingManualNavRef.current = Boolean(skipRedirect);
295
+
296
+ const result = await accountsRef.current.verifyOTP({
573
297
  identifier,
574
298
  otp: otpCode,
575
299
  source_url: sourceUrl,
576
300
  });
577
301
 
578
- // Check if 2FA is required - return early with session info
579
302
  if (result.requires_2fa && result.session_id) {
580
303
  authLogger.info('2FA required, session:', result.session_id);
581
304
  return {
@@ -587,40 +310,30 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
587
310
  };
588
311
  }
589
312
 
590
- // Verify that we got valid tokens (only required when 2FA is not needed)
591
313
  if (!result.access || !result.refresh) {
592
314
  authLogger.error('Verify OTP returned invalid response:', result);
593
- return {
594
- success: false,
595
- message: 'Invalid OTP verification response',
596
- };
315
+ return { success: false, message: 'Invalid OTP verification response' };
597
316
  }
598
317
 
599
- // Save email identifier
600
318
  if (identifier.includes('@')) {
601
319
  setStoredEmail(identifier);
602
320
  }
603
321
 
604
- // Small delay to ensure profile state is updated
605
- await new Promise(resolve => setTimeout(resolve, 200));
606
-
607
- // Track successful login
608
322
  Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
609
323
  category: AnalyticsCategory.AUTH,
610
324
  label: 'email',
611
325
  });
612
-
613
- // Set user ID for future tracking
614
326
  if (result.user?.id) {
615
327
  Analytics.setUser(String(result.user.id));
616
328
  }
617
329
 
618
- // Handle redirect logic (unless skipRedirect is true)
619
330
  if (!skipRedirect) {
620
- // Priority: provided redirectUrl > saved redirect > config default
621
- // Use hardPush for full page reload - ensures all React contexts reinitialize
622
- const savedRedirect = redirectManager.useAndClearRedirect();
623
- const finalRedirectUrl = redirectUrl || savedRedirect || config?.routes?.defaultCallback || defaultRoutes.defaultCallback;
331
+ // Priority: saved back-url (per-visit intent from the guard) >
332
+ // explicit redirectUrl (static app default) > config default.
333
+ // hardPush = full page load so every context reinitializes cleanly.
334
+ const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
335
+ if (saved) redirectManager.clearRedirect();
336
+ const finalRedirectUrl = saved || redirectUrl || routesRef.current.defaultCallback;
624
337
  authLogger.info('Redirecting after auth to:', finalRedirectUrl);
625
338
  router.hardPush(finalRedirectUrl);
626
339
  }
@@ -633,133 +346,88 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
633
346
  };
634
347
  } catch (error) {
635
348
  authLogger.error('Verify OTP error:', error);
636
-
637
- // Track failed verification
638
349
  Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
639
350
  category: AnalyticsCategory.AUTH,
640
351
  label: 'email',
641
352
  });
642
-
643
353
  if (error instanceof APIError) {
644
- const message = error.response?.error || error.response?.detail || error.response?.message || error.errorMessage;
645
- return { success: false, message };
354
+ return { success: false, message: apiErrorMessage(error) };
646
355
  }
647
- return {
648
- success: false,
649
- message: 'Failed to verify OTP',
650
- };
356
+ return { success: false, message: 'Failed to verify OTP' };
651
357
  }
652
358
  },
653
- [setStoredEmail, config?.routes?.defaultCallback, accounts, router],
359
+ [setStoredEmail, redirectManager, router],
654
360
  );
655
361
 
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);
362
+ // ── Session actions ────────────────────────────────────────────────────────
674
363
 
675
- // Track successful refresh
364
+ const refreshToken = useCallback(async (): Promise<{ success: boolean; message: string }> => {
365
+ // Same single-flight + Web-Locks path as the 401 interceptor — rotation,
366
+ // dedup and rotated-token persistence come for free.
367
+ const access = await auth.refreshNow();
368
+ if (access) {
676
369
  Analytics.event(AnalyticsEvent.AUTH_TOKEN_REFRESH, {
677
370
  category: AnalyticsCategory.AUTH,
678
371
  });
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
- };
372
+ return { success: true, message: 'Token refreshed' };
697
373
  }
698
- }, [clearAuthState, accounts]);
374
+ Analytics.event(AnalyticsEvent.AUTH_TOKEN_REFRESH_FAIL, {
375
+ category: AnalyticsCategory.AUTH,
376
+ });
377
+ return { success: false, message: 'Error refreshing token' };
378
+ }, []);
699
379
 
700
380
  const logout = useCallback((): void => {
701
- // Track logout
702
381
  Analytics.event(AnalyticsEvent.AUTH_LOGOUT, {
703
382
  category: AnalyticsCategory.AUTH,
704
383
  });
384
+ accountsRef.current.logout(); // clears session + profile + cache
385
+ // hardReplace = full reload + history replace, so contexts reinitialize
386
+ // and Back doesn't return to a protected page.
387
+ router.hardReplace(routesRef.current.defaultAuthCallback);
388
+ }, [router]);
705
389
 
706
- accounts.logout(); // Clear tokens and profile
707
- setInitialized(true);
708
- setIsLoading(false);
390
+ // ── Derived state ──────────────────────────────────────────────────────────
709
391
 
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;
713
- router.hardReplace(authCallbackUrl);
714
- }, [accounts, config?.routes?.defaultAuthCallback, router]);
715
-
716
- // Computed: Is admin user (staff or superuser)
717
- const isAdminUser = useMemo(() => {
718
- return Boolean(user?.is_staff || user?.is_superuser);
719
- }, [user]);
392
+ const user = (accounts.profile as UserProfile | undefined) ?? null;
393
+ const isAdminUser = Boolean(user?.is_staff || user?.is_superuser);
720
394
 
721
395
  // 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).
396
+ // users in production get a clean console.
724
397
  useEffect(() => {
725
398
  applyRoleLogPolicy({ isAdmin: isAdminUser });
726
399
  }, [isAdminUser]);
727
400
 
728
- // Profile management: Update profile data
729
401
  const updateProfile = useCallback(
730
- async (data: { first_name?: string; last_name?: string; username?: string }): Promise<UserProfile> => {
731
- const result = await accounts.partialUpdateProfile(data);
402
+ async (data: {
403
+ first_name?: string;
404
+ last_name?: string;
405
+ username?: string;
406
+ }): Promise<UserProfile> => {
407
+ const result = await accountsRef.current.partialUpdateProfile(data);
732
408
  return result as UserProfile;
733
409
  },
734
- [accounts]
410
+ [],
735
411
  );
736
412
 
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
- );
413
+ const uploadAvatar = useCallback(async (avatar: File | Blob): Promise<UserProfile> => {
414
+ const result = await accountsRef.current.uploadAvatar(avatar);
415
+ return result as UserProfile;
416
+ }, []);
745
417
 
746
- // Memoized context value
747
418
  const value = useMemo<AuthContextType>(
748
419
  () => ({
749
420
  user,
750
421
  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(),
422
+ // Reactive: flips via the store snapshot (token writes, refreshes,
423
+ // expiry timer, cross-tab/cross-store sync) — no ticks, no polling.
424
+ isAuthenticated,
758
425
  isAdminUser,
426
+ routes,
759
427
  loadCurrentProfile,
760
428
  checkAuthAndRedirect,
761
- getToken: () => apiAccounts.getToken(),
762
- getRefreshToken: () => apiAccounts.getRefreshToken(),
429
+ getToken: () => auth.getToken(),
430
+ getRefreshToken: () => auth.getRefreshToken(),
763
431
  getSavedEmail: () => storedEmail,
764
432
  saveEmail: setStoredEmail,
765
433
  clearSavedEmail: clearStoredEmail,
@@ -767,20 +435,19 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
767
435
  verifyOTP,
768
436
  refreshToken,
769
437
  logout,
770
- // Redirect URL methods
771
438
  saveRedirectUrl: redirectManager.setRedirect,
772
439
  getRedirectUrl: redirectManager.getFinalRedirectUrl,
773
440
  clearRedirectUrl: redirectManager.clearRedirect,
774
441
  hasRedirectUrl: redirectManager.hasRedirect,
775
- // Profile management
776
442
  updateProfile,
777
443
  uploadAvatar,
778
444
  }),
779
445
  [
780
446
  user,
781
447
  isLoading,
782
- authTick, // recompute isAuthenticated when tokens are cleared/set
448
+ isAuthenticated,
783
449
  isAdminUser,
450
+ routes,
784
451
  loadCurrentProfile,
785
452
  checkAuthAndRedirect,
786
453
  storedEmail,
@@ -796,27 +463,19 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
796
463
  ],
797
464
  );
798
465
 
799
- return (
800
- <AuthContext.Provider value={value}>
801
- <SWRConfig value={swrConfig}>{children}</SWRConfig>
802
- </AuthContext.Provider>
803
- );
466
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
804
467
  };
805
468
 
806
- // Wrapper that provides AccountsContext
807
469
  /**
808
470
  * AuthProvider — wraps AccountsProvider + AuthProviderInternal.
809
471
  *
810
472
  * Memoised: re-renders only when `config` reference or `children` change.
811
- * Internal auth state is isolated inside AuthProviderInternal.
812
473
  */
813
474
  function AuthProviderRaw({ children, config, enabled = true }: AuthProviderProps) {
814
475
  if (!enabled) return <>{children}</>;
815
476
  return (
816
477
  <AccountsProvider>
817
- <AuthProviderInternal config={config}>
818
- {children}
819
- </AuthProviderInternal>
478
+ <AuthProviderInternal config={config}>{children}</AuthProviderInternal>
820
479
  </AccountsProvider>
821
480
  );
822
481
  }
@@ -824,14 +483,15 @@ function AuthProviderRaw({ children, config, enabled = true }: AuthProviderProps
824
483
  export const AuthProvider = memo(AuthProviderRaw);
825
484
 
826
485
  /**
827
- * Default auth state for SSR/outside provider
828
- * Returns safe defaults that indicate "not authenticated, not loading"
486
+ * Default auth state for SSR/outside provider.
487
+ * Safe defaults that read as "not authenticated, not loading".
829
488
  */
830
489
  const defaultAuthState: AuthContextType = {
831
490
  user: null,
832
491
  isLoading: false,
833
492
  isAuthenticated: false,
834
493
  isAdminUser: false,
494
+ routes: resolveAuthRoutes(),
835
495
  loadCurrentProfile: async () => {
836
496
  authLogger.warn('useAuth: loadCurrentProfile called outside AuthProvider');
837
497
  },
@@ -857,11 +517,11 @@ const defaultAuthState: AuthContextType = {
857
517
  authLogger.warn('useAuth: refreshToken called outside AuthProvider');
858
518
  return { success: false, message: 'AuthProvider not available' };
859
519
  },
860
- logout: async (_options?: { skipConfirm?: boolean }) => {
520
+ logout: () => {
861
521
  authLogger.warn('useAuth: logout called outside AuthProvider');
862
522
  },
863
523
  saveRedirectUrl: () => {},
864
- getRedirectUrl: () => null,
524
+ getRedirectUrl: () => '',
865
525
  clearRedirectUrl: () => {},
866
526
  hasRedirectUrl: () => false,
867
527
  updateProfile: async () => {
@@ -875,24 +535,20 @@ const defaultAuthState: AuthContextType = {
875
535
  };
876
536
 
877
537
  /**
878
- * Hook to access auth context
538
+ * Hook to access auth context.
879
539
  *
880
- * SSR-safe: Returns default unauthenticated state when called outside AuthProvider
881
- * (e.g., during static page generation or server-side rendering)
540
+ * SSR-safe: returns default unauthenticated state when called outside
541
+ * AuthProvider (e.g. during static page generation).
882
542
  */
883
543
  export const useAuth = (): AuthContextType => {
884
544
  const context = useContext(AuthContext);
885
-
886
545
  if (context === undefined) {
887
- // SSR or outside provider - return safe defaults
888
- // Log only in development to help debugging
889
546
  if (isBrowser && isDev) {
890
547
  authLogger.debug('useAuth called outside AuthProvider, returning default state');
891
548
  }
892
549
  return defaultAuthState;
893
550
  }
894
-
895
551
  return context;
896
552
  };
897
553
 
898
- export default AuthContext;
554
+ export default AuthContext;