@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.
- package/dist/auth.cjs +1611 -2148
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +37 -82
- package/dist/auth.d.ts +37 -82
- package/dist/auth.mjs +1581 -2118
- 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 +292 -47
- 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 +292 -47
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +200 -32
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +200 -32
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +292 -47
- 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 +292 -47
- 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 +288 -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 +263 -607
- package/src/auth/context/types.ts +13 -0
- package/src/auth/hooks/index.ts +8 -5
- package/src/auth/hooks/useAuthRedirect.ts +17 -0
- package/src/auth/hooks/useGithubAuth.ts +3 -3
- package/src/auth/hooks/useSession.ts +22 -0
- package/src/auth/hooks/useTwoFactor.ts +8 -6
- 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,247 @@
|
|
|
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';
|
|
15
32
|
import { APIError } from '../../_api/generated/helpers';
|
|
16
|
-
import {
|
|
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 {
|
|
42
|
+
import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
27
43
|
import type { OTPRequestResult } from '../types';
|
|
28
44
|
|
|
29
|
-
//
|
|
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: '/
|
|
51
|
+
defaultCallback: '/',
|
|
33
52
|
defaultAuthCallback: '/auth',
|
|
34
53
|
};
|
|
35
54
|
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
279
|
-
// can register a newer one before this cleanup runs).
|
|
280
|
-
apiAccounts.onUnauthorized(null);
|
|
169
|
+
cancelled = true;
|
|
281
170
|
};
|
|
282
|
-
|
|
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
|
-
|
|
305
|
-
const loadCurrentProfile = useCallback(async (callerId?: string): Promise<void> => {
|
|
306
|
-
const finalCallerId = callerId || 'AuthContext.loadCurrentProfile';
|
|
173
|
+
const isLoading = !profileSettled;
|
|
307
174
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
509
|
-
}
|
|
510
|
-
|
|
226
|
+
router.push(routesRef.current.defaultCallback);
|
|
227
|
+
} else {
|
|
228
|
+
router.push(routesRef.current.defaultAuthCallback);
|
|
511
229
|
}
|
|
512
|
-
}, [loadCurrentProfile,
|
|
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
|
-
//
|
|
518
|
-
|
|
236
|
+
// A login attempt invalidates whatever session was lying around.
|
|
237
|
+
auth.clearSession();
|
|
519
238
|
|
|
520
239
|
try {
|
|
521
|
-
const result = await
|
|
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 ||
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
[
|
|
273
|
+
[],
|
|
559
274
|
);
|
|
560
275
|
|
|
561
276
|
const verifyOTP = useCallback(
|
|
562
|
-
async (
|
|
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
|
-
//
|
|
572
|
-
|
|
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:
|
|
621
|
-
//
|
|
622
|
-
|
|
623
|
-
const
|
|
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
|
-
|
|
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,
|
|
359
|
+
[setStoredEmail, redirectManager, router],
|
|
654
360
|
);
|
|
655
361
|
|
|
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);
|
|
362
|
+
// ── Session actions ────────────────────────────────────────────────────────
|
|
674
363
|
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
707
|
-
setInitialized(true);
|
|
708
|
-
setIsLoading(false);
|
|
390
|
+
// ── Derived state ──────────────────────────────────────────────────────────
|
|
709
391
|
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
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: {
|
|
731
|
-
|
|
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
|
-
[
|
|
410
|
+
[],
|
|
735
411
|
);
|
|
736
412
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
//
|
|
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(),
|
|
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: () =>
|
|
762
|
-
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
|
-
|
|
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
|
-
*
|
|
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:
|
|
520
|
+
logout: () => {
|
|
861
521
|
authLogger.warn('useAuth: logout called outside AuthProvider');
|
|
862
522
|
},
|
|
863
523
|
saveRedirectUrl: () => {},
|
|
864
|
-
getRedirectUrl: () =>
|
|
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:
|
|
881
|
-
* (e.g
|
|
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;
|