@djangocfg/api 2.1.455 → 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.
- package/dist/auth.cjs +2654 -2920
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +18 -82
- package/dist/auth.d.ts +18 -82
- package/dist/auth.mjs +385 -2861
- package/dist/auth.mjs.map +1 -1
- package/dist/chunk-2G67QRNU.mjs +2096 -0
- package/dist/chunk-2G67QRNU.mjs.map +1 -0
- package/dist/chunk-32SRQGAC.mjs +2109 -0
- package/dist/chunk-32SRQGAC.mjs.map +1 -0
- package/dist/chunk-4BPRCONN.mjs +2098 -0
- package/dist/chunk-4BPRCONN.mjs.map +1 -0
- package/dist/chunk-5UZ2Z323.mjs +2021 -0
- package/dist/chunk-5UZ2Z323.mjs.map +1 -0
- package/dist/chunk-7KFTJXNM.mjs +2097 -0
- package/dist/chunk-7KFTJXNM.mjs.map +1 -0
- package/dist/chunk-BK4K5CVT.mjs +2019 -0
- package/dist/chunk-BK4K5CVT.mjs.map +1 -0
- package/dist/chunk-JLPJZ6WB.mjs +2020 -0
- package/dist/chunk-JLPJZ6WB.mjs.map +1 -0
- package/dist/chunk-TVU6PYJH.mjs +2032 -0
- package/dist/chunk-TVU6PYJH.mjs.map +1 -0
- package/dist/clients.cjs +2210 -1847
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +23 -0
- package/dist/clients.d.ts +23 -0
- package/dist/clients.mjs +14 -1845
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +1776 -962
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +4 -1375
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +2245 -1877
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +81 -4
- package/dist/index.d.ts +81 -4
- package/dist/index.mjs +20 -1860
- package/dist/index.mjs.map +1 -1
- package/dist/sdk.gen-2SQOPTWJ.mjs +26 -0
- package/dist/sdk.gen-2SQOPTWJ.mjs.map +1 -0
- package/dist/sdk.gen-HSGK4C5S.mjs +26 -0
- package/dist/sdk.gen-HSGK4C5S.mjs.map +1 -0
- package/dist/sdk.gen-NUK2VGHO.mjs +25 -0
- package/dist/sdk.gen-NUK2VGHO.mjs.map +1 -0
- package/dist/sdk.gen-O4KAQUZB.mjs +26 -0
- package/dist/sdk.gen-O4KAQUZB.mjs.map +1 -0
- package/dist/sdk.gen-PPAVSBNT.mjs +26 -0
- package/dist/sdk.gen-PPAVSBNT.mjs.map +1 -0
- package/dist/sdk.gen-XLHLOCJ2.mjs +25 -0
- package/dist/sdk.gen-XLHLOCJ2.mjs.map +1 -0
- package/dist/sdk.gen-ZOE6NQAL.mjs +26 -0
- package/dist/sdk.gen-ZOE6NQAL.mjs.map +1 -0
- package/dist/sdk.gen-ZT7LGJVO.mjs +26 -0
- package/dist/sdk.gen-ZT7LGJVO.mjs.map +1 -0
- package/package.json +2 -2
- package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
- package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenBlacklistCreate.ts +28 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +99 -0
- package/src/_api/generated/_cfg_accounts/schemas/TokenBlacklistRequest.ts +11 -0
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +1 -0
- package/src/_api/generated/_cfg_centrifugo/openapi.json +9 -0
- package/src/_api/generated/_cfg_totp/openapi.json +33 -0
- package/src/_api/generated/helpers/auth.ts +276 -31
- package/src/_api/generated/openapi.json +129 -0
- package/src/_api/generated/sdk.gen.ts +94 -16
- package/src/_api/generated/types.gen.ts +18 -0
- package/src/auth/__tests__/guard.test.ts +0 -31
- package/src/auth/constants.ts +6 -0
- package/src/auth/context/AccountsContext.tsx +14 -24
- package/src/auth/context/AuthContext.tsx +226 -609
- package/src/auth/hooks/index.ts +3 -4
- package/src/auth/hooks/useGithubAuth.ts +3 -3
- package/src/auth/hooks/useSession.ts +22 -0
- package/src/auth/hooks/useTwoFactor.ts +4 -4
- package/src/auth/middlewares/index.ts +5 -6
- package/src/auth/utils/guard.ts +0 -22
- package/src/auth/utils/index.ts +0 -2
- package/src/_api/generated/_cfg_accounts/events.ts +0 -198
- package/src/_api/generated/_cfg_centrifugo/events.ts +0 -198
- package/src/_api/generated/_cfg_totp/events.ts +0 -198
- package/src/auth/__tests__/jwt.test.ts +0 -119
- package/src/auth/__tests__/sessionBootstrap.test.ts +0 -111
- package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +0 -167
- package/src/auth/hooks/useAuthGuard.ts +0 -35
- package/src/auth/hooks/useTokenRefresh.ts +0 -131
- package/src/auth/refreshHandler.ts +0 -79
- package/src/auth/utils/jwt.ts +0 -66
|
@@ -1,529 +1,211 @@
|
|
|
1
|
-
// @ts-nocheck
|
|
2
1
|
'use client';
|
|
3
2
|
|
|
4
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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:
|
|
32
|
-
defaultCallback:
|
|
33
|
-
defaultAuthCallback:
|
|
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
|
|
42
|
-
if (typeof window === 'undefined') return false;
|
|
43
|
-
return apiAccounts.isAuthenticated();
|
|
44
|
-
};
|
|
54
|
+
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
45
55
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
309
|
-
if (
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
457
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
458
|
-
}, [initialized]);
|
|
138
|
+
const isLoading = !profileSettled;
|
|
459
139
|
|
|
460
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
}, [
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
const checkAuthAndRedirect = useCallback(async () => {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
509
|
-
}
|
|
510
|
-
|
|
190
|
+
router.push(routes?.defaultCallback || defaultRoutes.defaultCallback);
|
|
191
|
+
} else {
|
|
192
|
+
router.push(routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback);
|
|
511
193
|
}
|
|
512
|
-
}, [loadCurrentProfile,
|
|
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
|
-
//
|
|
518
|
-
|
|
200
|
+
// A login attempt invalidates whatever session was lying around.
|
|
201
|
+
auth.clearSession();
|
|
519
202
|
|
|
520
203
|
try {
|
|
521
|
-
const result = await
|
|
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 ||
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
[
|
|
237
|
+
[],
|
|
559
238
|
);
|
|
560
239
|
|
|
561
240
|
const verifyOTP = useCallback(
|
|
562
|
-
async (
|
|
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
|
-
|
|
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:
|
|
621
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
321
|
+
[setStoredEmail, redirectManager, router],
|
|
654
322
|
);
|
|
655
323
|
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
}, [
|
|
352
|
+
}, [router]);
|
|
353
|
+
|
|
354
|
+
// ── Derived state ──────────────────────────────────────────────────────────
|
|
715
355
|
|
|
716
|
-
|
|
717
|
-
const isAdminUser =
|
|
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
|
|
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: {
|
|
731
|
-
|
|
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
|
-
[
|
|
374
|
+
[],
|
|
735
375
|
);
|
|
736
376
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
//
|
|
752
|
-
//
|
|
753
|
-
|
|
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: () =>
|
|
762
|
-
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
481
|
+
logout: () => {
|
|
861
482
|
authLogger.warn('useAuth: logout called outside AuthProvider');
|
|
862
483
|
},
|
|
863
484
|
saveRedirectUrl: () => {},
|
|
864
|
-
getRedirectUrl: () =>
|
|
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:
|
|
881
|
-
* (e.g
|
|
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;
|