@djangocfg/api 2.1.618 → 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.618",
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.618",
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 {}
@@ -849,15 +875,22 @@ export function installAuthOnClient(client: HeyClient): void {
849
875
  client.interceptors.response.use(async (response, request) => {
850
876
  if (response.status !== 401) return response;
851
877
 
878
+ // A refusal from the sign-in endpoints themselves (wrong OTP / password, bad OAuth code) is a credential error, not
879
+ // a dead session: expiring here reloads the sign-in page and wipes the error the user is reading.
880
+ if (/\/accounts\/(otp|oauth|login|register|token)\b/.test(request.url)) {
881
+ trace('401.passthrough', { path: pathOf(request.url) });
882
+ return response;
883
+ }
884
+
852
885
  // Already retried once — give up to avoid loops.
853
886
  if (request.headers.get(RETRY_MARKER)) {
854
- expireSession(response);
887
+ expireSession(response, 'retry-marker');
855
888
  return response;
856
889
  }
857
890
 
858
891
  const newToken = await tryRefresh();
859
892
  if (!newToken) {
860
- expireSession(response);
893
+ expireSession(response, 'refresh-failed');
861
894
  return response;
862
895
  }
863
896
 
@@ -875,7 +908,7 @@ export function installAuthOnClient(client: HeyClient): void {
875
908
  }
876
909
  try {
877
910
  const retried = await fetch(retry);
878
- if (retried.status === 401) expireSession(retried);
911
+ if (retried.status === 401) expireSession(retried, 'retry-401');
879
912
  return retried;
880
913
  } catch {
881
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/);
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A 401 from a sign-in endpoint (wrong OTP / password, bad OAuth code) is not a dead session:
3
+ * it must not fire `onSessionExpired`, which hard-reloads the sign-in page and wipes the error the user is reading.
4
+ */
5
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
6
+
7
+ function installBrowserStorage() {
8
+ const map = new Map<string, string>();
9
+ const storage = {
10
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
11
+ setItem: (k: string, v: string) => void map.set(k, v),
12
+ removeItem: (k: string) => void map.delete(k),
13
+ clear: () => map.clear(),
14
+ };
15
+ vi.stubGlobal('window', { localStorage: storage, location: { protocol: 'http:' } });
16
+ vi.stubGlobal('localStorage', storage);
17
+ }
18
+
19
+ async function setup() {
20
+ installBrowserStorage();
21
+ const { auth, installAuthOnClient } = await import('../../_api/generated/helpers/auth');
22
+ const handlers: Array<(res: Response, req: Request) => Promise<Response>> = [];
23
+ const noop = { use: () => 0 };
24
+ installAuthOnClient({
25
+ setConfig: () => undefined,
26
+ interceptors: { request: noop, error: noop, response: { use: (fn: never) => handlers.push(fn) } },
27
+ } as never);
28
+ const expired = vi.fn();
29
+ auth.onSessionExpired(expired);
30
+ return { handler: handlers[handlers.length - 1]!, expired };
31
+ }
32
+
33
+ describe('401 that is not a session expiry', () => {
34
+ beforeEach(() => {
35
+ vi.unstubAllGlobals();
36
+ vi.resetModules();
37
+ });
38
+
39
+ it('wrong OTP: no session-expired event', async () => {
40
+ const { handler, expired } = await setup();
41
+ const req = new Request('https://api.example.com/cfg/accounts/otp/verify/', { method: 'POST', headers: { Authorization: 'Bearer stale' } });
42
+ await handler(new Response('{}', { status: 401 }), req);
43
+ expect(expired).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it('signed-in request that cannot be refreshed still expires the session', async () => {
47
+ const { handler, expired } = await setup();
48
+ const req = new Request('https://api.example.com/apix/crm/me/');
49
+ await handler(new Response('{}', { status: 401 }), req);
50
+ expect(expired).toHaveBeenCalledTimes(1);
51
+ });
52
+ });
@@ -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,11 +145,13 @@ 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();
@@ -160,6 +164,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
160
164
  return;
161
165
  }
162
166
  profileAttemptedRef.current = true;
167
+ authTrace('bootstrap.profile.start');
163
168
  let cancelled = false;
164
169
  accountsRef.current
165
170
  .refreshProfile({ callerId: 'AuthProvider:bootstrap' })
@@ -168,6 +173,9 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
168
173
  // onSessionExpired funnel below. Anything else (network, 5xx) keeps
169
174
  // the session — the user can retry.
170
175
  authLogger.warn('Profile load failed (session kept):', error);
176
+ authTrace('bootstrap.profile.failed', {
177
+ status: error instanceof APIError ? error.statusCode : 0,
178
+ });
171
179
  })
172
180
  .finally(() => {
173
181
  if (!cancelled) setProfileSettled(true);
@@ -179,15 +187,56 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
179
187
 
180
188
  const isLoading = !profileSettled;
181
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
+
182
232
  // ── The ONE exit: terminal 401 → login ─────────────────────────────────────
183
233
  // The store has already cleared the session before this fires (subscribers
184
234
  // flipped to 'anonymous'), so the handler only routes. Registration returns
185
235
  // its own unsubscribe — StrictMode double-mounts compose instead of racing.
186
- const expiredHandledRef = useRef(false);
236
+ // One hard navigation per page is enforced in useCfgRouter, so no per-handler dedupe flag is needed.
187
237
  useEffect(() => {
188
238
  const unsubscribe = auth.onSessionExpired(() => {
189
- if (expiredHandledRef.current) return;
190
- expiredHandledRef.current = true;
239
+ authTrace('expired.handler', { path: pathname ?? '' });
191
240
  authLogger.warn('Session expired (terminal 401) — redirecting to login');
192
241
  clearProfileCache();
193
242
  Analytics.event(AnalyticsEvent.AUTH_SESSION_EXPIRED, {
@@ -196,7 +245,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
196
245
  router.hardReplace(routesRef.current.defaultAuthCallback);
197
246
  });
198
247
  return unsubscribe;
199
- }, [router]);
248
+ }, [router, pathname]);
200
249
 
201
250
  // ── Redirect authenticated users away from the auth page ──────────────────
202
251
  // Stands down while a login flow on this page owns navigation (success
@@ -208,6 +257,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
208
257
  if (pathname === routes.auth && !queryParams.get('flow')) {
209
258
  const saved = redirectManager.hasRedirect() ? redirectManager.getRedirect() : null;
210
259
  if (saved) redirectManager.clearRedirect();
260
+ authTrace('leave-auth', { to: saved || routes.defaultCallback, saved: Boolean(saved) });
211
261
  router.push(saved || routes.defaultCallback);
212
262
  }
213
263
  }, [isAuthenticated, pathname, queryParams, routes, router, redirectManager]);
@@ -241,6 +291,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
241
291
  const requestOTP = useCallback(
242
292
  async (identifier: string, sourceUrl?: string, consent?: OTPRequestConsent): Promise<OTPRequestResult> => {
243
293
  // A login attempt invalidates whatever session was lying around.
294
+ authTrace('otp.request');
244
295
  auth.clearSession();
245
296
 
246
297
  try {
@@ -307,6 +358,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
307
358
  // The caller opting out of redirect commits to handling ALL navigation
308
359
  // for this login — including the 2FA continuation and success screen.
309
360
  pendingManualNavRef.current = Boolean(skipRedirect);
361
+ authTrace('otp.verify', { skipRedirect: Boolean(skipRedirect) });
310
362
 
311
363
  const result = await accountsRef.current.verifyOTP({
312
364
  identifier,
@@ -390,6 +442,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
390
442
  // The caller opting out of redirect commits to handling ALL navigation
391
443
  // for this login — including the 2FA continuation and success screen.
392
444
  pendingManualNavRef.current = Boolean(skipRedirect);
445
+ authTrace('password.login', { skipRedirect: Boolean(skipRedirect) });
393
446
 
394
447
  const result = await accountsRef.current.loginWithPassword({
395
448
  email,
@@ -483,6 +536,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
483
536
  }, []);
484
537
 
485
538
  const logout = useCallback((): void => {
539
+ authTrace('logout');
486
540
  Analytics.event(AnalyticsEvent.AUTH_LOGOUT, {
487
541
  category: AnalyticsCategory.AUTH,
488
542
  });
@@ -524,6 +578,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
524
578
  () => ({
525
579
  user,
526
580
  isLoading,
581
+ status,
527
582
  // Reactive: flips via the store snapshot (token writes, refreshes,
528
583
  // expiry timer, cross-tab/cross-store sync) — no ticks, no polling.
529
584
  isAuthenticated,
@@ -551,6 +606,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
551
606
  [
552
607
  user,
553
608
  isLoading,
609
+ status,
554
610
  isAuthenticated,
555
611
  isAdminUser,
556
612
  routes,
@@ -596,6 +652,7 @@ export const AuthProvider = memo(AuthProviderRaw);
596
652
  const defaultAuthState: AuthContextType = {
597
653
  user: null,
598
654
  isLoading: false,
655
+ status: 'anonymous',
599
656
  isAuthenticated: false,
600
657
  isAdminUser: false,
601
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