@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/dist/auth.cjs +311 -39
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +75 -2
- package/dist/auth.d.ts +75 -2
- package/dist/auth.mjs +311 -39
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +46 -6
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.mjs +46 -6
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +46 -6
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +46 -6
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +201 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +201 -26
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_api/generated/helpers/auth.ts +39 -6
- package/src/auth/__tests__/authState.test.ts +46 -0
- package/src/auth/__tests__/formatAuthError.test.ts +7 -0
- package/src/auth/__tests__/signInRefusal.test.ts +52 -0
- package/src/auth/context/AccountsContext.tsx +3 -0
- package/src/auth/context/AuthContext.tsx +61 -4
- 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 +183 -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.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.
|
|
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
|
-
|
|
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
|
-
|
|
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