@djangocfg/api 2.1.456 → 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.
@@ -775,22 +775,34 @@ export function installAuthOnClient(client: HeyClient): void {
775
775
 
776
776
  // ── Default refresh handler (auto-registered) ──────────────────────────────
777
777
  //
778
- // Wires the SimpleJWT refresh endpoint so a 401 transparently refreshes and
779
- // retries. Registered at import time; an app can override it afterwards with
780
- // `auth.setRefreshHandler(...)` (last write wins). Returns the ROTATED refresh
781
- // token so the store persists it — `ROTATE_REFRESH_TOKENS` blacklists the old
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
- // The SDK is imported LAZILY inside the handler: a static import would close
785
- // the cycle auth.ts sdk.gen client.gen auth.ts and TDZ-crash whichever
786
- // module evaluates first. The dynamic import resolves once, then caches.
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 { CfgAccountsAuth } = await import('../sdk.gen');
790
- const { data } = await CfgAccountsAuth.cfgAccountsTokenRefreshCreate({
791
- body: { refresh },
792
- throwOnError: true,
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;
@@ -12,8 +12,8 @@ export const AUTH_CONSTANTS = {
12
12
  BACKUP_CODE_MAX_LENGTH: 12,
13
13
  } as const;
14
14
 
15
- /** Default route for the sign-in page. Override per app via AuthConfig.routes. */
16
- export const DEFAULT_AUTH_PATH = '/auth';
17
-
18
- /** Default post-login destination. Override per app via AuthConfig.routes. */
19
- export const DEFAULT_CALLBACK_PATH = '/dashboard';
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: DEFAULT_AUTH_PATH,
48
- defaultCallback: DEFAULT_CALLBACK_PATH,
49
- defaultAuthCallback: DEFAULT_AUTH_PATH,
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: config?.routes?.defaultCallback || defaultRoutes.defaultCallback,
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
- if (auth.getToken() || auth.getRefreshToken()) {
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
- const authCallbackUrl =
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
- // Skipped while a `?flow` param is present (OTP / 2FA flows in progress).
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
- const authRoute = config?.routes?.auth || defaultRoutes.auth;
166
- if (pathname === authRoute && !queryParams.get('flow')) {
167
- router.push(config?.routes?.defaultCallback || defaultRoutes.defaultCallback);
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, config?.routes, router]);
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(routes?.defaultCallback || defaultRoutes.defaultCallback);
226
+ router.push(routesRef.current.defaultCallback);
191
227
  } else {
192
- router.push(routes?.defaultAuthCallback || defaultRoutes.defaultAuthCallback);
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: explicit redirectUrl > saved redirect > config default.
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 savedRedirect = redirectManager.useAndClearRedirect();
294
- const finalRedirectUrl =
295
- redirectUrl ||
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
- const authCallbackUrl =
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
 
@@ -35,7 +35,11 @@ export {
35
35
  } from './useTwoFactorStatus';
36
36
 
37
37
  // Auth guards and redirects
38
- export { useAuthRedirectManager } from './useAuthRedirect';
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 || '/dashboard';
123
+ const finalRedirectUrl = consumeSavedRedirect() || redirectUrl || '/';
122
124
  authLogger.info('2FA successful, redirecting to:', finalRedirectUrl);
123
125
  router.hardPush(finalRedirectUrl);
124
126
  }