@djangocfg/api 2.1.619 → 2.1.622
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 +441 -106
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +80 -2
- package/dist/auth.d.ts +80 -2
- package/dist/auth.mjs +396 -63
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +43 -6
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.mjs +43 -6
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +43 -6
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +43 -6
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +264 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +264 -40
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_api/generated/helpers/auth.ts +33 -6
- package/src/auth/__tests__/authState.test.ts +46 -0
- package/src/auth/__tests__/formatAuthError.test.ts +7 -0
- package/src/auth/__tests__/sharedContext.test.ts +16 -0
- package/src/auth/__tests__/trace.test.ts +38 -0
- package/src/auth/context/AccountsContext.tsx +6 -2
- package/src/auth/context/AuthContext.tsx +63 -10
- package/src/auth/context/sharedContext.ts +16 -0
- package/src/auth/context/types.ts +2 -0
- package/src/auth/hooks/useCfgRouter.ts +31 -0
- package/src/auth/hooks/useOAuthLogin.ts +3 -0
- package/src/auth/hooks/useTwoFactorVerify.ts +2 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/trace.ts +253 -0
- package/src/auth/utils/authState.ts +39 -0
- package/src/auth/utils/errors.ts +5 -2
- package/src/auth/utils/index.ts +1 -0
- package/src/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.622",
|
|
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.
|
|
94
|
+
"@djangocfg/typescript-config": "^2.1.622",
|
|
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/);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('sharedContext', () => {
|
|
4
|
+
it('returns one context object for a key, even from a second module copy', async () => {
|
|
5
|
+
const a = await import('../context/sharedContext');
|
|
6
|
+
vi.resetModules();
|
|
7
|
+
const b = await import('../context/sharedContext');
|
|
8
|
+
expect(a.sharedContext).not.toBe(b.sharedContext);
|
|
9
|
+
expect(a.sharedContext('k', 1)).toBe(b.sharedContext('k', 2));
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('keeps different keys apart', async () => {
|
|
13
|
+
const { sharedContext } = await import('../context/sharedContext');
|
|
14
|
+
expect(sharedContext('x', 1)).not.toBe(sharedContext('y', 1));
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { authTrace, clearAuthTrace, dumpAuthTrace, readAuthTrace } from '../trace';
|
|
5
|
+
|
|
6
|
+
describe('auth trace', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
localStorage.clear();
|
|
9
|
+
clearAuthTrace();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('drops sensitive keys and cuts long strings', () => {
|
|
13
|
+
authTrace('x', { token: 'abc', email: 'a@b.c', otp: '123456', path: '/auth', long: 'y'.repeat(300), ok: true });
|
|
14
|
+
const f = readAuthTrace()[0]!.f!;
|
|
15
|
+
expect(Object.keys(f).sort()).toEqual(['long', 'ok', 'path']);
|
|
16
|
+
expect((f.long as string).length).toBeLessThan(130);
|
|
17
|
+
expect(localStorage.getItem('cfg.auth.trace')).not.toContain('123456');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('collapses an identical repeat so a loop cannot flush the start of the chain', () => {
|
|
21
|
+
authTrace('start');
|
|
22
|
+
for (let i = 0; i < 500; i++) authTrace('guard.redirect', { from: '/', to: '/auth' });
|
|
23
|
+
const t = readAuthTrace();
|
|
24
|
+
expect(t.map((e) => e.ev)).toEqual(['start', 'guard.redirect']);
|
|
25
|
+
expect(t[1]!.n).toBe(500);
|
|
26
|
+
expect(dumpAuthTrace()).toContain('x500');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('keeps at most 200 distinct events', () => {
|
|
30
|
+
for (let i = 0; i < 300; i++) authTrace('e', { i });
|
|
31
|
+
expect(readAuthTrace()).toHaveLength(200);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('survives in storage for the next page', () => {
|
|
35
|
+
authTrace('a');
|
|
36
|
+
expect(JSON.parse(localStorage.getItem('cfg.auth.trace')!)).toHaveLength(1);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"use client";
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState
|
|
18
18
|
} from 'react';
|
|
19
19
|
|
|
20
20
|
import { auth } from '../../_api/generated/helpers/auth';
|
|
@@ -60,6 +60,8 @@ import type {
|
|
|
60
60
|
PasswordLoginRequest,
|
|
61
61
|
PasswordLoginResponse,
|
|
62
62
|
} from '../../_api/generated/_cfg_accounts/types.gen';
|
|
63
|
+
import { authTrace } from '../trace';
|
|
64
|
+
import { sharedContext } from './sharedContext';
|
|
63
65
|
|
|
64
66
|
export type { PatchedCfgUserUpdateRequest, User, CfgUserUpdateRequest as UserProfileUpdateRequest };
|
|
65
67
|
|
|
@@ -83,7 +85,7 @@ export interface AccountsContextValue {
|
|
|
83
85
|
logout: () => void;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
const AccountsContext =
|
|
88
|
+
const AccountsContext = sharedContext<AccountsContextValue | undefined>('accounts', undefined);
|
|
87
89
|
|
|
88
90
|
interface AccountsProviderProps {
|
|
89
91
|
children: ReactNode;
|
|
@@ -216,6 +218,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
216
218
|
// Storage is always localStorage: session length is the token lifetime
|
|
217
219
|
// the server chose. A sessionStorage switch would strand the tokens — the
|
|
218
220
|
// module reinitializes to localStorage on the next page load.
|
|
221
|
+
authTrace('accounts.otp.session');
|
|
219
222
|
auth.setSession({ access: result.access, refresh: result.refresh });
|
|
220
223
|
try {
|
|
221
224
|
await refreshProfile({ callerId, force: true });
|
|
@@ -251,6 +254,7 @@ export function AccountsProvider({ children }: AccountsProviderProps) {
|
|
|
251
254
|
authLogger.warn('Refresh-token blacklist failed (logged out locally anyway)');
|
|
252
255
|
});
|
|
253
256
|
}
|
|
257
|
+
authTrace('accounts.logout');
|
|
254
258
|
auth.clearSession();
|
|
255
259
|
setProfile(undefined);
|
|
256
260
|
setProfileError(null);
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
|
|
19
19
|
import { usePathname } from 'next/navigation';
|
|
20
20
|
import React, {
|
|
21
|
-
createContext,
|
|
22
21
|
memo,
|
|
23
22
|
useCallback,
|
|
24
23
|
useContext,
|
|
@@ -38,9 +37,12 @@ import { purgeLegacyTokenStorage } from '../hooks/legacyStorage';
|
|
|
38
37
|
import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
|
|
39
38
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
40
39
|
import { formatAuthError, logAuthFailure } from '../utils/errors';
|
|
40
|
+
import { resolveAuthStatus } from '../utils/authState';
|
|
41
|
+
import { authTrace } from '../trace';
|
|
41
42
|
import { isDev, isBrowser } from '../utils/env';
|
|
42
43
|
import { authLogger } from '../utils/logger';
|
|
43
44
|
import { AccountsProvider, useAccountsContext } from './AccountsContext';
|
|
45
|
+
import { sharedContext } from './sharedContext';
|
|
44
46
|
|
|
45
47
|
import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
|
|
46
48
|
import type {
|
|
@@ -72,7 +74,7 @@ export const resolveAuthRoutes = (
|
|
|
72
74
|
|
|
73
75
|
const EMAIL_STORAGE_KEY = CFG_KEYS.email.key;
|
|
74
76
|
|
|
75
|
-
const AuthContext =
|
|
77
|
+
const AuthContext = sharedContext<AuthContextType | undefined>('auth', undefined);
|
|
76
78
|
|
|
77
79
|
const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
|
|
78
80
|
const accounts = useAccountsContext();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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(),
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext, type Context } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A React context that stays ONE object even when the bundle holds several copies of this package.
|
|
5
|
+
*
|
|
6
|
+
* pnpm builds a separate physical copy of a package per distinct peer set, and a dual ESM/CJS load can do the same.
|
|
7
|
+
* Each copy would call `createContext` itself: the provider mounted from copy A and a `useAuth()` called from copy B
|
|
8
|
+
* would then never meet, B would read the empty default and the page guard would redirect a signed-in user forever
|
|
9
|
+
* (hub.reforms.ai, 5 Nov 2026). Registering the context on `globalThis` makes every copy share it. React itself must
|
|
10
|
+
* still be a single copy, which every Next.js app already requires.
|
|
11
|
+
*/
|
|
12
|
+
export function sharedContext<T>(key: string, initial: T): Context<T> {
|
|
13
|
+
const g = globalThis as Record<string, unknown>;
|
|
14
|
+
const registry = (g.__cfgAuthContexts ??= {}) as Record<string, Context<unknown>>;
|
|
15
|
+
return (registry[key] ??= createContext<unknown>(initial)) as Context<T>;
|
|
16
|
+
}
|
|
@@ -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