@djangocfg/api 2.1.619 → 2.1.621

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.619",
3
+ "version": "2.1.621",
4
4
  "description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
5
5
  "keywords": [
6
6
  "django",
@@ -91,7 +91,7 @@
91
91
  "zod": "^4.3.6"
92
92
  },
93
93
  "devDependencies": {
94
- "@djangocfg/typescript-config": "^2.1.619",
94
+ "@djangocfg/typescript-config": "^2.1.621",
95
95
  "@testing-library/react": "^16.3.3",
96
96
  "@types/node": "^25.9.5",
97
97
  "@types/react": "19.3.0",
@@ -16,6 +16,25 @@ const storage = 'cfg' === 'cfg' ? cfgStorage : createStorage('cfg');
16
16
 
17
17
  const isBrowser = typeof window !== 'undefined';
18
18
 
19
+ /**
20
+ * Auth trace hook. The reader lives in `@djangocfg/api` (`auth/trace.ts`) and installs `globalThis.__cfgAuthTrace`;
21
+ * until it does, events queue on `globalThis` so none are lost. Never pass a token, a code or an address.
22
+ */
23
+ type TraceFields = Record<string, string | number | boolean | null>;
24
+ function trace(ev: string, fields?: TraceFields): void {
25
+ try {
26
+ const g = globalThis as Record<string, unknown>;
27
+ const sink = g.__cfgAuthTrace as ((e: string, f?: TraceFields) => void) | undefined;
28
+ if (sink) { sink(ev, fields); return; }
29
+ const queue = (g.__cfgAuthTraceQ as unknown[] | undefined) ?? [];
30
+ g.__cfgAuthTraceQ = queue;
31
+ if (queue.length < 100) queue.push([Date.now(), ev, fields]);
32
+ } catch { /* tracing never breaks auth */ }
33
+ }
34
+ function pathOf(url: string): string {
35
+ try { return new URL(url, 'http://x').pathname; } catch { return ''; }
36
+ }
37
+
19
38
  export type StorageMode = 'localStorage' | 'sessionStorage' | 'cookie';
20
39
 
21
40
  // ── Storage backends (browser-only; server-side reads return null) ─────────
@@ -281,7 +300,7 @@ function notifySessionChanged(): void {
281
300
  const changed =
282
301
  next.status !== _snapshot.status ||
283
302
  next.accessExpiresAt !== _snapshot.accessExpiresAt;
284
- if (changed) _snapshot = next;
303
+ if (changed) { _snapshot = next; trace('store.snapshot', { status: next.status }); }
285
304
  scheduleExpiryFlip();
286
305
  if (!changed) return;
287
306
  for (const listener of Array.from(_sessionListeners)) {
@@ -418,15 +437,18 @@ export const auth = {
418
437
  // ── Bearer token ──────────────────────────────────────────────────
419
438
  getToken(): string | null { return _storage.get(ACCESS_KEY); },
420
439
  setToken(token: string | null): void {
440
+ trace('store.setToken', { present: token !== null });
421
441
  _storage.set(ACCESS_KEY, token);
422
442
  notifySessionChanged();
423
443
  },
424
444
  getRefreshToken(): string | null { return _storage.get(REFRESH_KEY); },
425
445
  setRefreshToken(token: string | null): void {
446
+ trace('store.setRefreshToken', { present: token !== null });
426
447
  _storage.set(REFRESH_KEY, token);
427
448
  notifySessionChanged();
428
449
  },
429
450
  clearTokens(): void {
451
+ trace('store.clearTokens');
430
452
  // Clear every backend, not just the active one. Sign-out must leave nothing
431
453
  // behind anywhere — including tokens an older build stranded in
432
454
  // sessionStorage — or the next page load could resurrect a dead session.
@@ -452,6 +474,7 @@ export const auth = {
452
474
  * rotation); `refresh: null` explicitly drops it.
453
475
  */
454
476
  setSession(tokens: { access: string; refresh?: string | null }): void {
477
+ trace('store.setSession', { refresh: tokens.refresh === undefined ? 'kept' : tokens.refresh === null ? 'dropped' : 'set' });
455
478
  _storage.set(ACCESS_KEY, tokens.access);
456
479
  if (tokens.refresh !== undefined) _storage.set(REFRESH_KEY, tokens.refresh);
457
480
  notifySessionChanged();
@@ -603,7 +626,8 @@ async function tryRefresh(): Promise<string | null> {
603
626
  const refresh = auth.getRefreshToken();
604
627
  if (!refresh) return null;
605
628
  const result = await _refreshHandler!(refresh);
606
- if (!result?.access) return null;
629
+ if (!result?.access) { trace('refresh', { ok: false, why: 'no-access' }); return null; }
630
+ trace('refresh', { ok: true, rotated: Boolean(result.refresh) });
607
631
  auth.setToken(result.access);
608
632
  if (result.refresh) auth.setRefreshToken(result.refresh);
609
633
  return result.access;
@@ -621,6 +645,7 @@ async function tryRefresh(): Promise<string | null> {
621
645
  }
622
646
  return await runRefresh();
623
647
  } catch {
648
+ trace('refresh', { ok: false, why: 'threw' });
624
649
  return null;
625
650
  } finally {
626
651
  _refreshInflight = null;
@@ -637,7 +662,8 @@ async function tryRefresh(): Promise<string | null> {
637
662
  * `onUnauthorized` slot. Apps only route to login; they never own the
638
663
  * clear-before-redirect ordering.
639
664
  */
640
- function expireSession(response: Response): void {
665
+ function expireSession(response: Response, why: string): void {
666
+ trace('store.expire', { why, status: response.status, path: pathOf(response.url), handlers: _sessionExpiredHandlers.size });
641
667
  auth.clearTokens();
642
668
  for (const cb of Array.from(_sessionExpiredHandlers)) {
643
669
  try { cb(response); } catch {}
@@ -852,18 +878,19 @@ export function installAuthOnClient(client: HeyClient): void {
852
878
  // A refusal from the sign-in endpoints themselves (wrong OTP / password, bad OAuth code) is a credential error, not
853
879
  // a dead session: expiring here reloads the sign-in page and wipes the error the user is reading.
854
880
  if (/\/accounts\/(otp|oauth|login|register|token)\b/.test(request.url)) {
881
+ trace('401.passthrough', { path: pathOf(request.url) });
855
882
  return response;
856
883
  }
857
884
 
858
885
  // Already retried once — give up to avoid loops.
859
886
  if (request.headers.get(RETRY_MARKER)) {
860
- expireSession(response);
887
+ expireSession(response, 'retry-marker');
861
888
  return response;
862
889
  }
863
890
 
864
891
  const newToken = await tryRefresh();
865
892
  if (!newToken) {
866
- expireSession(response);
893
+ expireSession(response, 'refresh-failed');
867
894
  return response;
868
895
  }
869
896
 
@@ -881,7 +908,7 @@ export function installAuthOnClient(client: HeyClient): void {
881
908
  }
882
909
  try {
883
910
  const retried = await fetch(retry);
884
- if (retried.status === 401) expireSession(retried);
911
+ if (retried.status === 401) expireSession(retried, 'retry-401');
885
912
  return retried;
886
913
  } catch {
887
914
  // Network error on the retry. The refresh SUCCEEDED (we hold a fresh
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { resolveAuthStatus, type AuthStateInput } from '../utils/authState';
4
+
5
+ const base: AuthStateInput = {
6
+ clientReady: true,
7
+ snapshotAuthenticated: false,
8
+ storeAuthenticated: false,
9
+ profileSettled: false,
10
+ timedOut: false,
11
+ };
12
+ const at = (over: Partial<AuthStateInput>) => resolveAuthStatus({ ...base, ...over });
13
+
14
+ describe('resolveAuthStatus', () => {
15
+ it('is resolving until the client has read the session (hydration commit)', () => {
16
+ expect(at({ clientReady: false })).toBe('resolving');
17
+ expect(at({ clientReady: false, snapshotAuthenticated: true, storeAuthenticated: true, profileSettled: true })).toBe('resolving');
18
+ });
19
+
20
+ it('is anonymous when store and snapshot both say signed out', () => {
21
+ expect(at({})).toBe('anonymous');
22
+ });
23
+
24
+ it('is authenticated once the profile settled', () => {
25
+ expect(at({ snapshotAuthenticated: true, storeAuthenticated: true, profileSettled: true })).toBe('authenticated');
26
+ });
27
+
28
+ it('is resolving while a live session loads its profile', () => {
29
+ expect(at({ snapshotAuthenticated: true, storeAuthenticated: true })).toBe('resolving');
30
+ });
31
+
32
+ it('waits while the snapshot lags behind the store', () => {
33
+ expect(at({ snapshotAuthenticated: false, storeAuthenticated: true })).toBe('resolving');
34
+ });
35
+
36
+ it('never stays resolving once the timer fired', () => {
37
+ expect(at({ snapshotAuthenticated: true, storeAuthenticated: true, timedOut: true })).toBe('authenticated');
38
+ expect(at({ snapshotAuthenticated: false, storeAuthenticated: true, timedOut: true })).toBe('anonymous');
39
+ });
40
+
41
+ it('terminates for every input once clientReady and timedOut', () => {
42
+ for (const snap of [true, false]) for (const store of [true, false]) for (const prof of [true, false]) {
43
+ expect(at({ snapshotAuthenticated: snap, storeAuthenticated: store, profileSettled: prof, timedOut: true })).not.toBe('resolving');
44
+ }
45
+ });
46
+ });
@@ -67,6 +67,13 @@ describe('resolveAuthError — transport strings never reach the UI', () => {
67
67
  expect(key).toBe(AUTH_ERROR_KEYS.serverUnavailable);
68
68
  });
69
69
 
70
+ it('HTTP 0 (no response: network, CORS, API restarting) is a network error, not "HTTP 0:"', () => {
71
+ const { message, key } = resolveAuthError({ message: 'HTTP 0: ', statusCode: 0, name: 'APIError' });
72
+
73
+ expect(message).not.toMatch(/HTTP/);
74
+ expect(key).toBe(AUTH_ERROR_KEYS.networkError);
75
+ });
76
+
70
77
  it('rejects it even when statusText filled it in', () => {
71
78
  expect(formatAuthError({ message: 'HTTP 500: Internal Server Error', statusCode: 500 }))
72
79
  .not.toMatch(/HTTP/);
@@ -60,6 +60,7 @@ import type {
60
60
  PasswordLoginRequest,
61
61
  PasswordLoginResponse,
62
62
  } from '../../_api/generated/_cfg_accounts/types.gen';
63
+ import { authTrace } from '../trace';
63
64
 
64
65
  export type { PatchedCfgUserUpdateRequest, User, CfgUserUpdateRequest as UserProfileUpdateRequest };
65
66
 
@@ -216,6 +217,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
216
217
  // Storage is always localStorage: session length is the token lifetime
217
218
  // the server chose. A sessionStorage switch would strand the tokens — the
218
219
  // module reinitializes to localStorage on the next page load.
220
+ authTrace('accounts.otp.session');
219
221
  auth.setSession({ access: result.access, refresh: result.refresh });
220
222
  try {
221
223
  await refreshProfile({ callerId, force: true });
@@ -251,6 +253,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
251
253
  authLogger.warn('Refresh-token blacklist failed (logged out locally anyway)');
252
254
  });
253
255
  }
256
+ authTrace('accounts.logout');
254
257
  auth.clearSession();
255
258
  setProfile(undefined);
256
259
  setProfileError(null);
@@ -38,6 +38,8 @@ import { purgeLegacyTokenStorage } from '../hooks/legacyStorage';
38
38
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
39
39
  import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
40
40
  import { formatAuthError, logAuthFailure } from '../utils/errors';
41
+ import { resolveAuthStatus } from '../utils/authState';
42
+ import { authTrace } from '../trace';
41
43
  import { isDev, isBrowser } from '../utils/env';
42
44
  import { authLogger } from '../utils/logger';
43
45
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
@@ -143,19 +145,17 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
143
145
  const hasTokens = Boolean(auth.getToken() || auth.getRefreshToken());
144
146
  if (hasTokens && !auth.isAuthenticated()) {
145
147
  authLogger.warn('Dead session in storage — clearing');
148
+ authTrace('bootstrap.purge', { why: 'dead-tokens' });
146
149
  auth.clearSession();
147
150
  clearProfileCache();
148
151
  } else if (!hasTokens && !auth.isAuthenticated()) {
149
152
  // No session at all: a leftover profile cache and the old localStorage tokens (pre-cookie auth) must not keep
150
153
  // the private UI alive. Drop them; the page's own guard then sends the user to /auth.
154
+ authTrace('bootstrap.purge', { why: 'no-session' });
151
155
  clearProfileCache();
152
156
  purgeLegacyTokenStorage();
153
157
  if (accountsRef.current.profile) accountsRef.current.logout();
154
158
  }
155
- // The hydration commit serves the SERVER snapshot ('anonymous') even when the store holds a live session. Settling
156
- // now would let the page guard read "loaded and signed out" and bounce to /auth; the snapshot flips on the next
157
- // render and this effect runs again with the real value.
158
- if (auth.isAuthenticated()) return;
159
159
  setProfileSettled(true);
160
160
  return;
161
161
  }
@@ -164,6 +164,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
164
164
  return;
165
165
  }
166
166
  profileAttemptedRef.current = true;
167
+ authTrace('bootstrap.profile.start');
167
168
  let cancelled = false;
168
169
  accountsRef.current
169
170
  .refreshProfile({ callerId: 'AuthProvider:bootstrap' })
@@ -172,6 +173,9 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
172
173
  // onSessionExpired funnel below. Anything else (network, 5xx) keeps
173
174
  // the session — the user can retry.
174
175
  authLogger.warn('Profile load failed (session kept):', error);
176
+ authTrace('bootstrap.profile.failed', {
177
+ status: error instanceof APIError ? error.statusCode : 0,
178
+ });
175
179
  })
176
180
  .finally(() => {
177
181
  if (!cancelled) setProfileSettled(true);
@@ -183,15 +187,56 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
183
187
 
184
188
  const isLoading = !profileSettled;
185
189
 
190
+ // ── The one linear state ───────────────────────────────────────────────────
191
+ // clientReady: the hydration commit is over (the server snapshot is always 'anonymous', so before this the
192
+ // snapshot proves nothing). timedOut: ONE safety timer, so 'resolving' always ends.
193
+ const [clientReady, setClientReady] = useState(false);
194
+ const [timedOut, setTimedOut] = useState(false);
195
+ useEffect(() => {
196
+ setClientReady(true);
197
+ const timer = setTimeout(() => setTimedOut(true), 8000);
198
+ return () => clearTimeout(timer);
199
+ }, []);
200
+ const status = resolveAuthStatus({
201
+ clientReady,
202
+ snapshotAuthenticated: isAuthenticated,
203
+ storeAuthenticated: isBrowser ? auth.isAuthenticated() : false,
204
+ profileSettled,
205
+ timedOut,
206
+ });
207
+ const lastStatusRef = useRef<string>('');
208
+ useEffect(() => {
209
+ if (lastStatusRef.current === status) return;
210
+ lastStatusRef.current = status;
211
+ authTrace('status', {
212
+ status,
213
+ snapshot: isAuthenticated,
214
+ store: auth.isAuthenticated(),
215
+ profileSettled,
216
+ timedOut,
217
+ path: pathname ?? '',
218
+ });
219
+ }, [status, isAuthenticated, profileSettled, timedOut, pathname]);
220
+ useEffect(() => {
221
+ (globalThis as Record<string, unknown>).__cfgAuthNow = () => ({
222
+ status,
223
+ snapshot: isAuthenticated,
224
+ store: auth.isAuthenticated(),
225
+ hasToken: Boolean(auth.getToken()),
226
+ hasRefresh: Boolean(auth.getRefreshToken()),
227
+ profileSettled,
228
+ timedOut,
229
+ });
230
+ }, [status, isAuthenticated, profileSettled, timedOut]);
231
+
186
232
  // ── The ONE exit: terminal 401 → login ─────────────────────────────────────
187
233
  // The store has already cleared the session before this fires (subscribers
188
234
  // flipped to 'anonymous'), so the handler only routes. Registration returns
189
235
  // its own unsubscribe — StrictMode double-mounts compose instead of racing.
190
- const expiredHandledRef = useRef(false);
236
+ // One hard navigation per page is enforced in useCfgRouter, so no per-handler dedupe flag is needed.
191
237
  useEffect(() => {
192
238
  const unsubscribe = auth.onSessionExpired(() => {
193
- if (expiredHandledRef.current) return;
194
- expiredHandledRef.current = true;
239
+ authTrace('expired.handler', { path: pathname ?? '' });
195
240
  authLogger.warn('Session expired (terminal 401) — redirecting to login');
196
241
  clearProfileCache();
197
242
  Analytics.event(AnalyticsEvent.AUTH_SESSION_EXPIRED, {
@@ -200,7 +245,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
200
245
  router.hardReplace(routesRef.current.defaultAuthCallback);
201
246
  });
202
247
  return unsubscribe;
203
- }, [router]);
248
+ }, [router, pathname]);
204
249
 
205
250
  // ── Redirect authenticated users away from the auth page ──────────────────
206
251
  // Stands down while a login flow on this page owns navigation (success
@@ -212,6 +257,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
212
257
  if (pathname === routes.auth && !queryParams.get('flow')) {
213
258
  const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
214
259
  if (saved) redirectManager.clearRedirect();
260
+ authTrace('leave-auth', { to: saved || routes.defaultCallback, saved: Boolean(saved) });
215
261
  router.push(saved || routes.defaultCallback);
216
262
  }
217
263
  }, [isAuthenticated, pathname, queryParams, routes, router, redirectManager]);
@@ -245,6 +291,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
245
291
  const requestOTP = useCallback(
246
292
  async (identifier: string, sourceUrl?: string, consent?: OTPRequestConsent): Promise<OTPRequestResult> => {
247
293
  // A login attempt invalidates whatever session was lying around.
294
+ authTrace('otp.request');
248
295
  auth.clearSession();
249
296
 
250
297
  try {
@@ -311,6 +358,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
311
358
  // The caller opting out of redirect commits to handling ALL navigation
312
359
  // for this login — including the 2FA continuation and success screen.
313
360
  pendingManualNavRef.current = Boolean(skipRedirect);
361
+ authTrace('otp.verify', { skipRedirect: Boolean(skipRedirect) });
314
362
 
315
363
  const result = await accountsRef.current.verifyOTP({
316
364
  identifier,
@@ -394,6 +442,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
394
442
  // The caller opting out of redirect commits to handling ALL navigation
395
443
  // for this login — including the 2FA continuation and success screen.
396
444
  pendingManualNavRef.current = Boolean(skipRedirect);
445
+ authTrace('password.login', { skipRedirect: Boolean(skipRedirect) });
397
446
 
398
447
  const result = await accountsRef.current.loginWithPassword({
399
448
  email,
@@ -487,6 +536,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
487
536
  }, []);
488
537
 
489
538
  const logout = useCallback((): void => {
539
+ authTrace('logout');
490
540
  Analytics.event(AnalyticsEvent.AUTH_LOGOUT, {
491
541
  category: AnalyticsCategory.AUTH,
492
542
  });
@@ -528,6 +578,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
528
578
  () => ({
529
579
  user,
530
580
  isLoading,
581
+ status,
531
582
  // Reactive: flips via the store snapshot (token writes, refreshes,
532
583
  // expiry timer, cross-tab/cross-store sync) — no ticks, no polling.
533
584
  isAuthenticated,
@@ -555,6 +606,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
555
606
  [
556
607
  user,
557
608
  isLoading,
609
+ status,
558
610
  isAuthenticated,
559
611
  isAdminUser,
560
612
  routes,
@@ -600,6 +652,7 @@ export const AuthProvider = memo(AuthProviderRaw);
600
652
  const defaultAuthState: AuthContextType = {
601
653
  user: null,
602
654
  isLoading: false,
655
+ status: 'anonymous',
603
656
  isAuthenticated: false,
604
657
  isAdminUser: false,
605
658
  routes: resolveAuthRoutes(),
@@ -37,6 +37,8 @@ export interface ResolvedAuthRoutes {
37
37
  export interface AuthContextType {
38
38
  user: UserProfile | null;
39
39
  isLoading: boolean;
40
+ /** The one linear auth state: 'resolving' until the client has read the session and its profile, then 'authenticated' or 'anonymous'. */
41
+ status: 'resolving' | 'authenticated' | 'anonymous';
40
42
  isAuthenticated: boolean;
41
43
  isAdminUser: boolean;
42
44
  /** Resolved route conventions (config override or framework default). */
@@ -18,6 +18,23 @@
18
18
  import { useRouter as useNextRouter } from 'next/navigation';
19
19
  import { useCallback, useMemo } from 'react';
20
20
 
21
+ import { authTrace } from '../trace';
22
+
23
+ /**
24
+ * A hard navigation unloads the page. Once one is in flight, a second one (two 401s, the guard and an effect, a double
25
+ * click) is dropped: the first decides. Reset when the page is restored from the back/forward cache.
26
+ */
27
+ let hardNavigating = false;
28
+ if (typeof window !== 'undefined') {
29
+ window.addEventListener('pageshow', (e) => {
30
+ if (e.persisted) hardNavigating = false;
31
+ });
32
+ }
33
+
34
+ function fromPath(): string {
35
+ return typeof window === 'undefined' ? '' : window.location.pathname;
36
+ }
37
+
21
38
  /**
22
39
  * Get base path from environment variable
23
40
  */
@@ -67,6 +84,7 @@ export function useCfgRouter() {
67
84
  const basePath = useMemo(() => getBasePath(), []);
68
85
 
69
86
  const push = useCallback((href: string, options?: { scroll?: boolean }) => {
87
+ authTrace('nav.push', { from: fromPath(), to: href });
70
88
  if (basePath) {
71
89
  // App Router doesn't handle basePath automatically, use window.location
72
90
  window.location.href = withBasePath(href, basePath);
@@ -77,6 +95,7 @@ export function useCfgRouter() {
77
95
  }, [router, basePath]);
78
96
 
79
97
  const replace = useCallback((href: string, options?: { scroll?: boolean }) => {
98
+ authTrace('nav.replace', { from: fromPath(), to: href });
80
99
  if (basePath) {
81
100
  // App Router doesn't handle basePath automatically, use window.location
82
101
  window.location.replace(withBasePath(href, basePath));
@@ -100,6 +119,12 @@ export function useCfgRouter() {
100
119
  * ```
101
120
  */
102
121
  const hardPush = useCallback((href: string) => {
122
+ if (hardNavigating) {
123
+ authTrace('nav.hardPush.ignored', { from: fromPath(), to: href });
124
+ return;
125
+ }
126
+ hardNavigating = true;
127
+ authTrace('nav.hardPush', { from: fromPath(), to: href });
103
128
  window.location.href = withBasePath(href, basePath);
104
129
  }, [basePath]);
105
130
 
@@ -116,6 +141,12 @@ export function useCfgRouter() {
116
141
  * ```
117
142
  */
118
143
  const hardReplace = useCallback((href: string) => {
144
+ if (hardNavigating) {
145
+ authTrace('nav.hardReplace.ignored', { from: fromPath(), to: href });
146
+ return;
147
+ }
148
+ hardNavigating = true;
149
+ authTrace('nav.hardReplace', { from: fromPath(), to: href });
119
150
  window.location.replace(withBasePath(href, basePath));
120
151
  }, [basePath]);
121
152
 
@@ -16,6 +16,7 @@ import { authLogger } from '../utils/logger';
16
16
  import { type OAuthFailureCode, type OAuthProviderId, readOAuthFailure } from '../utils/oauthErrors';
17
17
  import { consumeSavedRedirect } from './useAuthRedirect';
18
18
  import { useCfgRouter } from './useCfgRouter';
19
+ import { authTrace } from '../trace';
19
20
 
20
21
  /**
21
22
  * The user payload as the OAuth endpoint declares it.
@@ -138,6 +139,7 @@ export const useOAuthLogin = (options: UseOAuthLoginOptions = {}): UseOAuthLogin
138
139
  setErrorCode(null);
139
140
  // Match the OTP flow: a new login must not leave an older persistent
140
141
  // session available after this browser is intentionally made session-only.
142
+ authTrace('oauth.start');
141
143
  auth.clearSession();
142
144
 
143
145
  try {
@@ -200,6 +202,7 @@ export const useOAuthLogin = (options: UseOAuthLoginOptions = {}): UseOAuthLogin
200
202
  }
201
203
 
202
204
  // The ONE write path — atomic pair + session notification.
205
+ authTrace('oauth.session');
203
206
  auth.setSession({ access: response.access, refresh: response.refresh });
204
207
 
205
208
  Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
@@ -11,6 +11,7 @@ import { formatAuthError, logAuthFailure } from '../utils/errors';
11
11
  import { consumeSavedRedirect } from './useAuthRedirect';
12
12
  import { useCfgRouter } from './useCfgRouter';
13
13
  import { extractAttemptsRemaining, isValidTotpCode } from './twoFactor.shared';
14
+ import { authTrace } from '../trace';
14
15
 
15
16
  export interface UseTwoFactorVerifyOptions {
16
17
  /** Callback on successful 2FA verification */
@@ -71,6 +72,7 @@ export const useTwoFactorVerify = (
71
72
  // Save tokens — the ONE write path (atomic pair + session notification).
72
73
  // Storage is always localStorage; session length is the server's call.
73
74
  // See AccountsContext.verifyOTP.
75
+ authTrace('2fa.session');
74
76
  auth.setSession({ access: response.access_token, refresh: response.refresh_token });
75
77
 
76
78
  if (response.warning) {
package/src/auth/index.ts CHANGED
@@ -27,6 +27,9 @@
27
27
  // Constants
28
28
  export * from './constants';
29
29
 
30
+ // Persisted auth trace: `authTrace(ev, fields)` and the readers behind `window.__cfgAuth`.
31
+ export * from './trace';
32
+
30
33
  // Contexts
31
34
  export * from './context';
32
35