@djangocfg/api 2.1.456 → 2.1.459
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/README.md +1 -1
- package/dist/auth.cjs +2109 -2380
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +24 -5
- package/dist/auth.d.ts +24 -5
- package/dist/auth.mjs +2229 -290
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +1906 -2024
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.mjs +2090 -14
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +1026 -1672
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +1543 -4
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +1892 -2015
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2105 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_api/generated/helpers/auth.ts +24 -12
- package/src/auth/constants.ts +5 -5
- package/src/auth/context/AuthContext.tsx +67 -28
- package/src/auth/context/types.ts +13 -0
- package/src/auth/hooks/index.ts +5 -1
- package/src/auth/hooks/useAuthRedirect.ts +17 -0
- package/src/auth/hooks/useTwoFactor.ts +4 -2
|
@@ -775,22 +775,34 @@ export function installAuthOnClient(client: HeyClient): void {
|
|
|
775
775
|
|
|
776
776
|
// ── Default refresh handler (auto-registered) ──────────────────────────────
|
|
777
777
|
//
|
|
778
|
-
//
|
|
779
|
-
// retries. Registered at import time; an app can override it
|
|
780
|
-
// `auth.setRefreshHandler(...)` (last write wins). Returns the
|
|
781
|
-
// token so the store persists it — `ROTATE_REFRESH_TOKENS`
|
|
782
|
-
// one, so reusing it would 401 on the next refresh.
|
|
778
|
+
// POSTs straight to the SimpleJWT refresh endpoint so a 401 transparently
|
|
779
|
+
// refreshes and retries. Registered at import time; an app can override it
|
|
780
|
+
// afterwards with `auth.setRefreshHandler(...)` (last write wins). Returns the
|
|
781
|
+
// ROTATED refresh token so the store persists it — `ROTATE_REFRESH_TOKENS`
|
|
782
|
+
// blacklists the old one, so reusing it would 401 on the next refresh.
|
|
783
783
|
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
784
|
+
// Raw fetch, NOT the SDK: the endpoint exists on the server even when this
|
|
785
|
+
// target's sliced SDK doesn't include the accounts group, and importing
|
|
786
|
+
// sdk.gen here would close the cycle auth.ts → sdk.gen → client.gen → auth.ts.
|
|
787
|
+
const REFRESH_ENDPOINT = '/cfg/accounts/token/refresh/';
|
|
788
|
+
|
|
787
789
|
auth.setRefreshHandler(async (refresh) => {
|
|
788
790
|
try {
|
|
789
|
-
const
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
791
|
+
const url = `${auth.getBaseUrl()}${REFRESH_ENDPOINT}`;
|
|
792
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
793
|
+
// Bound tokens (`cnf.jkt`) require a DPoP proof on the refresh call too.
|
|
794
|
+
if (dpopEnabled() && typeof window !== 'undefined') {
|
|
795
|
+
const proof = await _makeDpopProof('POST', url);
|
|
796
|
+
if (proof) headers['DPoP'] = proof;
|
|
797
|
+
}
|
|
798
|
+
const res = await fetch(url, {
|
|
799
|
+
method: 'POST',
|
|
800
|
+
headers,
|
|
801
|
+
credentials: auth.getWithCredentials() ? 'include' : 'same-origin',
|
|
802
|
+
body: JSON.stringify({ refresh }),
|
|
793
803
|
});
|
|
804
|
+
if (!res.ok) return null;
|
|
805
|
+
const data = (await res.json()) as { access?: string; refresh?: string } | null;
|
|
794
806
|
return data?.access
|
|
795
807
|
? { access: data.access, refresh: data.refresh ?? refresh }
|
|
796
808
|
: null;
|
package/src/auth/constants.ts
CHANGED
|
@@ -12,8 +12,8 @@ export const AUTH_CONSTANTS = {
|
|
|
12
12
|
BACKUP_CODE_MAX_LENGTH: 12,
|
|
13
13
|
} as const;
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
// Route conventions are NOT exported here on purpose. The '/auth' and '/'
|
|
16
|
+
// defaults are applied in exactly one place — AuthProvider's route resolution
|
|
17
|
+
// — and every consumer reads the resolved values from `useAuth().routes`.
|
|
18
|
+
// Exporting route constants invites components to default to them directly,
|
|
19
|
+
// which silently diverges from an app's AuthConfig.routes override.
|
|
@@ -29,7 +29,6 @@ import React, {
|
|
|
29
29
|
} from 'react';
|
|
30
30
|
|
|
31
31
|
import { auth } from '../../_api/generated/helpers/auth';
|
|
32
|
-
import { DEFAULT_AUTH_PATH, DEFAULT_CALLBACK_PATH } from '../constants';
|
|
33
32
|
import { APIError } from '../../_api/generated/helpers';
|
|
34
33
|
import { applyRoleLogPolicy } from '../../log-control';
|
|
35
34
|
import { useCfgRouter, useLocalStorage, useQueryParams, useSession } from '../hooks';
|
|
@@ -43,12 +42,25 @@ import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
|
43
42
|
import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
44
43
|
import type { OTPRequestResult } from '../types';
|
|
45
44
|
|
|
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`.
|
|
46
49
|
const defaultRoutes = {
|
|
47
|
-
auth:
|
|
48
|
-
defaultCallback:
|
|
49
|
-
defaultAuthCallback:
|
|
50
|
+
auth: '/auth',
|
|
51
|
+
defaultCallback: '/',
|
|
52
|
+
defaultAuthCallback: '/auth',
|
|
50
53
|
};
|
|
51
54
|
|
|
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
|
+
});
|
|
63
|
+
|
|
52
64
|
const EMAIL_STORAGE_KEY = 'auth_email';
|
|
53
65
|
|
|
54
66
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|
@@ -74,8 +86,16 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
74
86
|
null,
|
|
75
87
|
);
|
|
76
88
|
|
|
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]);
|
|
96
|
+
|
|
77
97
|
const redirectManager = useAuthRedirectManager({
|
|
78
|
-
fallbackUrl:
|
|
98
|
+
fallbackUrl: routes.defaultCallback,
|
|
79
99
|
clearOnUse: true,
|
|
80
100
|
});
|
|
81
101
|
|
|
@@ -89,6 +109,13 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
89
109
|
accountsRef.current = accounts;
|
|
90
110
|
}, [accounts]);
|
|
91
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
|
+
|
|
92
119
|
// ── Bootstrap: settle the profile for the current session ─────────────────
|
|
93
120
|
// The session itself needs no async bootstrap — the store answers
|
|
94
121
|
// synchronously from token `exp`. The only async part is the profile fetch,
|
|
@@ -105,7 +132,15 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
105
132
|
// expired). Purge so the login form shows instantly instead of the app
|
|
106
133
|
// firing doomed API calls — the automated "clear localStorage and the
|
|
107
134
|
// form appears".
|
|
108
|
-
|
|
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()) {
|
|
109
144
|
authLogger.warn('Dead session in storage — clearing');
|
|
110
145
|
auth.clearSession();
|
|
111
146
|
clearProfileCache();
|
|
@@ -151,22 +186,24 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
151
186
|
Analytics.event(AnalyticsEvent.AUTH_SESSION_EXPIRED, {
|
|
152
187
|
category: AnalyticsCategory.AUTH,
|
|
153
188
|
});
|
|
154
|
-
|
|
155
|
-
configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
|
|
156
|
-
router.hardReplace(authCallbackUrl);
|
|
189
|
+
router.hardReplace(routesRef.current.defaultAuthCallback);
|
|
157
190
|
});
|
|
158
191
|
return unsubscribe;
|
|
159
192
|
}, [router]);
|
|
160
193
|
|
|
161
194
|
// ── Redirect authenticated users away from the auth page ──────────────────
|
|
162
|
-
//
|
|
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.
|
|
163
199
|
useEffect(() => {
|
|
164
|
-
if (!isAuthenticated) return;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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);
|
|
168
205
|
}
|
|
169
|
-
}, [isAuthenticated, pathname, queryParams,
|
|
206
|
+
}, [isAuthenticated, pathname, queryParams, routes, router, redirectManager]);
|
|
170
207
|
|
|
171
208
|
// ── Profile actions (delegated to AccountsContext) ─────────────────────────
|
|
172
209
|
|
|
@@ -180,16 +217,15 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
180
217
|
}, []);
|
|
181
218
|
|
|
182
219
|
const checkAuthAndRedirect = useCallback(async (): Promise<void> => {
|
|
183
|
-
const routes = configRef.current?.routes;
|
|
184
220
|
if (auth.isAuthenticated()) {
|
|
185
221
|
try {
|
|
186
222
|
await loadCurrentProfile('checkAuthAndRedirect');
|
|
187
223
|
} catch (error) {
|
|
188
224
|
authLogger.warn('checkAuthAndRedirect: profile load failed', error);
|
|
189
225
|
}
|
|
190
|
-
router.push(
|
|
226
|
+
router.push(routesRef.current.defaultCallback);
|
|
191
227
|
} else {
|
|
192
|
-
router.push(
|
|
228
|
+
router.push(routesRef.current.defaultAuthCallback);
|
|
193
229
|
}
|
|
194
230
|
}, [loadCurrentProfile, router]);
|
|
195
231
|
|
|
@@ -253,6 +289,10 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
253
289
|
should_prompt_2fa?: boolean;
|
|
254
290
|
}> => {
|
|
255
291
|
try {
|
|
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
|
+
|
|
256
296
|
const result = await accountsRef.current.verifyOTP({
|
|
257
297
|
identifier,
|
|
258
298
|
otp: otpCode,
|
|
@@ -288,14 +328,12 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
288
328
|
}
|
|
289
329
|
|
|
290
330
|
if (!skipRedirect) {
|
|
291
|
-
// Priority:
|
|
331
|
+
// Priority: saved back-url (per-visit intent from the guard) >
|
|
332
|
+
// explicit redirectUrl (static app default) > config default.
|
|
292
333
|
// hardPush = full page load so every context reinitializes cleanly.
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
savedRedirect ||
|
|
297
|
-
configRef.current?.routes?.defaultCallback ||
|
|
298
|
-
defaultRoutes.defaultCallback;
|
|
334
|
+
const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
|
|
335
|
+
if (saved) redirectManager.clearRedirect();
|
|
336
|
+
const finalRedirectUrl = saved || redirectUrl || routesRef.current.defaultCallback;
|
|
299
337
|
authLogger.info('Redirecting after auth to:', finalRedirectUrl);
|
|
300
338
|
router.hardPush(finalRedirectUrl);
|
|
301
339
|
}
|
|
@@ -346,9 +384,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
346
384
|
accountsRef.current.logout(); // clears session + profile + cache
|
|
347
385
|
// hardReplace = full reload + history replace, so contexts reinitialize
|
|
348
386
|
// and Back doesn't return to a protected page.
|
|
349
|
-
|
|
350
|
-
configRef.current?.routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback;
|
|
351
|
-
router.hardReplace(authCallbackUrl);
|
|
387
|
+
router.hardReplace(routesRef.current.defaultAuthCallback);
|
|
352
388
|
}, [router]);
|
|
353
389
|
|
|
354
390
|
// ── Derived state ──────────────────────────────────────────────────────────
|
|
@@ -387,6 +423,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
387
423
|
// expiry timer, cross-tab/cross-store sync) — no ticks, no polling.
|
|
388
424
|
isAuthenticated,
|
|
389
425
|
isAdminUser,
|
|
426
|
+
routes,
|
|
390
427
|
loadCurrentProfile,
|
|
391
428
|
checkAuthAndRedirect,
|
|
392
429
|
getToken: () => auth.getToken(),
|
|
@@ -410,6 +447,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
|
|
|
410
447
|
isLoading,
|
|
411
448
|
isAuthenticated,
|
|
412
449
|
isAdminUser,
|
|
450
|
+
routes,
|
|
413
451
|
loadCurrentProfile,
|
|
414
452
|
checkAuthAndRedirect,
|
|
415
453
|
storedEmail,
|
|
@@ -453,6 +491,7 @@ const defaultAuthState: AuthContextType = {
|
|
|
453
491
|
isLoading: false,
|
|
454
492
|
isAuthenticated: false,
|
|
455
493
|
isAdminUser: false,
|
|
494
|
+
routes: resolveAuthRoutes(),
|
|
456
495
|
loadCurrentProfile: async () => {
|
|
457
496
|
authLogger.warn('useAuth: loadCurrentProfile called outside AuthProvider');
|
|
458
497
|
},
|
|
@@ -17,12 +17,25 @@ export interface AuthConfig {
|
|
|
17
17
|
onLogout?: () => void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* AuthConfig.routes resolved against the framework conventions — no field is
|
|
22
|
+
* optional here. This is what components consume (via `useAuth().routes`)
|
|
23
|
+
* instead of defaulting to route constants themselves.
|
|
24
|
+
*/
|
|
25
|
+
export interface ResolvedAuthRoutes {
|
|
26
|
+
auth: string;
|
|
27
|
+
defaultCallback: string;
|
|
28
|
+
defaultAuthCallback: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
// Auth context interface
|
|
21
32
|
export interface AuthContextType {
|
|
22
33
|
user: UserProfile | null;
|
|
23
34
|
isLoading: boolean;
|
|
24
35
|
isAuthenticated: boolean;
|
|
25
36
|
isAdminUser: boolean;
|
|
37
|
+
/** Resolved route conventions (config override or framework default). */
|
|
38
|
+
routes: ResolvedAuthRoutes;
|
|
26
39
|
loadCurrentProfile: (callerId?: string) => Promise<void>;
|
|
27
40
|
checkAuthAndRedirect: () => Promise<void>;
|
|
28
41
|
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -35,7 +35,11 @@ export {
|
|
|
35
35
|
} from './useTwoFactorStatus';
|
|
36
36
|
|
|
37
37
|
// Auth guards and redirects
|
|
38
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
consumeSavedRedirect,
|
|
40
|
+
peekSavedRedirect,
|
|
41
|
+
useAuthRedirectManager,
|
|
42
|
+
} from './useAuthRedirect';
|
|
39
43
|
|
|
40
44
|
// Storage hooks
|
|
41
45
|
export { useSessionStorage } from './useSessionStorage';
|
|
@@ -34,6 +34,23 @@ const clearRedirectFromStorage = (): void => {
|
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Read the saved back-url without clearing it. Null when nothing saved —
|
|
39
|
+
* unlike getFinalRedirectUrl, never substitutes a fallback.
|
|
40
|
+
*/
|
|
41
|
+
export const peekSavedRedirect = (): string | null => getRedirectFromStorage() || null;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Read AND clear the saved back-url (single-use intent). Null when nothing
|
|
45
|
+
* saved. This is the one primitive every post-auth navigator uses to honor
|
|
46
|
+
* "return the user to where the guard bounced them from".
|
|
47
|
+
*/
|
|
48
|
+
export const consumeSavedRedirect = (): string | null => {
|
|
49
|
+
const stored = getRedirectFromStorage();
|
|
50
|
+
if (stored) clearRedirectFromStorage();
|
|
51
|
+
return stored || null;
|
|
52
|
+
};
|
|
53
|
+
|
|
37
54
|
export const useAuthRedirectManager = (options: AuthRedirectOptions = {}) => {
|
|
38
55
|
const { fallbackUrl = '/dashboard', clearOnUse = true } = options;
|
|
39
56
|
const [redirectUrl, setRedirectUrl, removeRedirectUrl] = useSessionStorage<string>(AUTH_REDIRECT_KEY, '');
|
|
@@ -8,6 +8,7 @@ import { APIError } from '../../_api/generated/helpers';
|
|
|
8
8
|
import { CfgTotp, CfgTotpVerify } from '../../_api/generated/sdk.gen';
|
|
9
9
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
10
10
|
import { authLogger } from '../utils/logger';
|
|
11
|
+
import { consumeSavedRedirect } from './useAuthRedirect';
|
|
11
12
|
import { useCfgRouter } from './useCfgRouter';
|
|
12
13
|
|
|
13
14
|
export interface UseTwoFactorOptions {
|
|
@@ -116,9 +117,10 @@ export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorRet
|
|
|
116
117
|
// Call success callback
|
|
117
118
|
onSuccess?.(response.user);
|
|
118
119
|
|
|
119
|
-
// Redirect (unless skipRedirect is true)
|
|
120
|
+
// Redirect (unless skipRedirect is true).
|
|
121
|
+
// Priority: saved back-url (guard intent) > explicit option > default.
|
|
120
122
|
if (!skipRedirect) {
|
|
121
|
-
const finalRedirectUrl = redirectUrl || '/
|
|
123
|
+
const finalRedirectUrl = consumeSavedRedirect() || redirectUrl || '/';
|
|
122
124
|
authLogger.info('2FA successful, redirecting to:', finalRedirectUrl);
|
|
123
125
|
router.hardPush(finalRedirectUrl);
|
|
124
126
|
}
|