@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
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth trace: a persisted record of what the auth layer did, readable after any reload or redirect.
|
|
3
|
+
*
|
|
4
|
+
* Why storage and not `window`: a sign-in problem usually ends in a reload or a redirect, which throws away
|
|
5
|
+
* everything held in memory and in the console. The events live in `localStorage` (falling back to
|
|
6
|
+
* `sessionStorage`, then memory), so the next page can still print them. `window.__cfgAuth` is only the reader.
|
|
7
|
+
*
|
|
8
|
+
* Read it in the browser console, on any page of the origin, even after the redirect:
|
|
9
|
+
*
|
|
10
|
+
* __cfgAuth.dump() // copy-ready text with a header; paste it into the report
|
|
11
|
+
* __cfgAuth.trace() // the raw array
|
|
12
|
+
* __cfgAuth.clear() // empty it before reproducing
|
|
13
|
+
* __cfgAuth.copy() // dump to the clipboard; __cfgAuth.download() saves the raw JSON
|
|
14
|
+
*
|
|
15
|
+
* What is recorded is the decision, never the secret: no token, code, address, cookie value or password.
|
|
16
|
+
* Values are primitives, strings are cut, and keys that look sensitive are dropped (`SENSITIVE`).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
type Primitive = string | number | boolean | null;
|
|
20
|
+
export type TraceFields = Record<string, Primitive>;
|
|
21
|
+
|
|
22
|
+
export interface TraceEntry {
|
|
23
|
+
/** Wall clock, ms since epoch. */
|
|
24
|
+
t: number;
|
|
25
|
+
/** ms since this page started (`performance.now()`), so a reload shows as the counter restarting. */
|
|
26
|
+
p: number;
|
|
27
|
+
/** Short random id of this page load: one sequence, several loads, told apart by this. */
|
|
28
|
+
tab: string;
|
|
29
|
+
ev: string;
|
|
30
|
+
f?: TraceFields;
|
|
31
|
+
/** Times this exact event repeated back to back (absent = once). A loop must not push the start of the chain out of the buffer. */
|
|
32
|
+
n?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const KEY = 'cfg.auth.trace';
|
|
36
|
+
const MAX = 200;
|
|
37
|
+
const MAX_STRING = 120;
|
|
38
|
+
const SENSITIVE = /token|otp|code|email|password|secret|cookie|authorization|key|identifier/i;
|
|
39
|
+
|
|
40
|
+
// One id per page load, shared by every copy of this module on the page (a second copy must not look like a second tab).
|
|
41
|
+
const TAB: string = (() => {
|
|
42
|
+
const g = globalThis as Record<string, unknown>;
|
|
43
|
+
return (g.__cfgAuthTab ??= Math.random().toString(36).slice(2, 6)) as string;
|
|
44
|
+
})();
|
|
45
|
+
const hasWindow = typeof window !== 'undefined';
|
|
46
|
+
|
|
47
|
+
let memory: TraceEntry[] = [];
|
|
48
|
+
let loaded = false;
|
|
49
|
+
|
|
50
|
+
function backend(): Storage | null {
|
|
51
|
+
if (!hasWindow) return null;
|
|
52
|
+
for (const name of ['localStorage', 'sessionStorage'] as const) {
|
|
53
|
+
try {
|
|
54
|
+
const s = window[name];
|
|
55
|
+
s.setItem('__cfg_probe', '1');
|
|
56
|
+
s.removeItem('__cfg_probe');
|
|
57
|
+
return s;
|
|
58
|
+
} catch {
|
|
59
|
+
/* try the next one */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function load(): TraceEntry[] {
|
|
66
|
+
if (loaded) return memory;
|
|
67
|
+
loaded = true;
|
|
68
|
+
try {
|
|
69
|
+
const raw = backend()?.getItem(KEY);
|
|
70
|
+
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
|
71
|
+
if (Array.isArray(parsed)) memory = parsed.filter((e): e is TraceEntry => typeof e === 'object' && e !== null);
|
|
72
|
+
} catch {
|
|
73
|
+
memory = [];
|
|
74
|
+
}
|
|
75
|
+
return memory;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function persist(): void {
|
|
79
|
+
try {
|
|
80
|
+
backend()?.setItem(KEY, JSON.stringify(memory));
|
|
81
|
+
} catch {
|
|
82
|
+
/* quota or blocked storage: the in-memory copy still serves this page */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function clean(fields?: TraceFields): TraceFields | undefined {
|
|
87
|
+
if (!fields) return undefined;
|
|
88
|
+
const out: TraceFields = {};
|
|
89
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
90
|
+
if (SENSITIVE.test(k)) continue;
|
|
91
|
+
if (typeof v === 'string') out[k] = v.length > MAX_STRING ? `${v.slice(0, MAX_STRING)}…` : v;
|
|
92
|
+
else if (typeof v === 'number' || typeof v === 'boolean' || v === null) out[k] = v;
|
|
93
|
+
}
|
|
94
|
+
return Object.keys(out).length ? out : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Re-read storage before every write: another tab (or another copy) may have appended since this one last looked. */
|
|
98
|
+
function refresh(): void {
|
|
99
|
+
try {
|
|
100
|
+
const raw = backend()?.getItem(KEY);
|
|
101
|
+
const parsed: unknown = raw ? JSON.parse(raw) : null;
|
|
102
|
+
if (Array.isArray(parsed)) memory = parsed.filter((e): e is TraceEntry => typeof e === 'object' && e !== null);
|
|
103
|
+
} catch {
|
|
104
|
+
/* keep the in-memory copy */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function record(at: number, ev: string, fields?: TraceFields): void {
|
|
109
|
+
load();
|
|
110
|
+
refresh();
|
|
111
|
+
const entries = memory;
|
|
112
|
+
const p = hasWindow && typeof performance !== 'undefined' ? Math.round(performance.now()) : 0;
|
|
113
|
+
const f = clean(fields);
|
|
114
|
+
const last = entries[entries.length - 1];
|
|
115
|
+
if (last && last.tab === TAB && last.ev === ev && JSON.stringify(last.f) === JSON.stringify(f)) {
|
|
116
|
+
last.n = (last.n ?? 1) + 1;
|
|
117
|
+
last.t = at;
|
|
118
|
+
last.p = p;
|
|
119
|
+
persist();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
entries.push({ t: at, p, tab: TAB, ev, f });
|
|
123
|
+
if (entries.length > MAX) entries.splice(0, entries.length - MAX);
|
|
124
|
+
persist();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Record one auth event. Safe anywhere: it never throws and does nothing on the server. */
|
|
128
|
+
export function authTrace(ev: string, fields?: TraceFields): void {
|
|
129
|
+
if (!hasWindow) return;
|
|
130
|
+
try {
|
|
131
|
+
record(Date.now(), ev, fields);
|
|
132
|
+
} catch {
|
|
133
|
+
/* tracing never breaks auth */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The recorded events, oldest first. */
|
|
138
|
+
/** @internal for tests */
|
|
139
|
+
export function resetAuthTraceForTests(): void {
|
|
140
|
+
memory = [];
|
|
141
|
+
loaded = true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function readAuthTrace(): TraceEntry[] {
|
|
145
|
+
return load().slice();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Empty the trace. */
|
|
149
|
+
export function clearAuthTrace(): void {
|
|
150
|
+
memory = [];
|
|
151
|
+
loaded = true;
|
|
152
|
+
try {
|
|
153
|
+
backend()?.removeItem(KEY);
|
|
154
|
+
} catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function context(): string[] {
|
|
160
|
+
if (!hasWindow) return [];
|
|
161
|
+
const copies = (globalThis as Record<string, unknown>).__cfgAuthCopies ?? 1;
|
|
162
|
+
const lines = [`url: ${window.location.href.split('?')[0]}`, `copies: ${String(copies)}`, `online: ${navigator.onLine}`, `ua: ${navigator.userAgent}`];
|
|
163
|
+
try {
|
|
164
|
+
const auth = (globalThis as Record<string, unknown>).__cfgAuthNow as (() => TraceFields) | undefined;
|
|
165
|
+
const now = auth?.();
|
|
166
|
+
if (now) lines.push(`now: ${Object.entries(now).map(([k, v]) => `${k}=${String(v)}`).join(' ')}`);
|
|
167
|
+
} catch {
|
|
168
|
+
/* the live state is a courtesy */
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Copy-ready text: a short header, then one line per event. Reloads show as a new `[tab]`. */
|
|
174
|
+
export function dumpAuthTrace(): string {
|
|
175
|
+
const entries = load();
|
|
176
|
+
const first = entries[0]?.t ?? Date.now();
|
|
177
|
+
const rows = entries.map((e) => {
|
|
178
|
+
const fields = e.f ? ` ${Object.entries(e.f).map(([k, v]) => `${k}=${String(v)}`).join(' ')}` : '';
|
|
179
|
+
const at = new Date(e.t).toISOString().slice(11, 23);
|
|
180
|
+
return `${at} +${String(e.t - first).padStart(6)}ms [${e.tab} p${e.p}] ${e.ev}${fields}${e.n && e.n > 1 ? ` x${e.n}` : ''}`;
|
|
181
|
+
});
|
|
182
|
+
return [`# cfg auth trace, ${entries.length} events`, ...context(), '', ...rows].join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Install the reader. Called once from the package entry; idempotent across copies of this module.
|
|
187
|
+
* The generated auth store forwards through `globalThis.__cfgAuthTrace` and queues until this runs.
|
|
188
|
+
*/
|
|
189
|
+
export function installAuthTrace(): void {
|
|
190
|
+
if (!hasWindow) return;
|
|
191
|
+
const g = globalThis as Record<string, unknown>;
|
|
192
|
+
g.__cfgAuthTrace = authTrace;
|
|
193
|
+
g.__cfgAuth = {
|
|
194
|
+
trace: readAuthTrace,
|
|
195
|
+
dump: () => {
|
|
196
|
+
const text = dumpAuthTrace();
|
|
197
|
+
// eslint-disable-next-line no-console
|
|
198
|
+
console.log(text);
|
|
199
|
+
return text;
|
|
200
|
+
},
|
|
201
|
+
clear: clearAuthTrace,
|
|
202
|
+
/** Put the dump on the clipboard (works when the console output is blocked or too long to select). */
|
|
203
|
+
copy: () => {
|
|
204
|
+
const text = dumpAuthTrace();
|
|
205
|
+
void navigator.clipboard?.writeText(text);
|
|
206
|
+
return `${text.split('\n').length} lines copied`;
|
|
207
|
+
},
|
|
208
|
+
/** Download the raw trace as a JSON file. */
|
|
209
|
+
download: () => {
|
|
210
|
+
const blob = new Blob([JSON.stringify(readAuthTrace(), null, 1)], { type: 'application/json' });
|
|
211
|
+
const a = document.createElement('a');
|
|
212
|
+
a.href = URL.createObjectURL(blob);
|
|
213
|
+
a.download = `auth-trace-${Date.now()}.json`;
|
|
214
|
+
a.click();
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
const queued = g.__cfgAuthTraceQ;
|
|
218
|
+
if (Array.isArray(queued)) {
|
|
219
|
+
g.__cfgAuthTraceQ = [];
|
|
220
|
+
for (const item of queued) {
|
|
221
|
+
if (Array.isArray(item) && typeof item[1] === 'string') {
|
|
222
|
+
try {
|
|
223
|
+
record(Number(item[0]) || Date.now(), item[1], item[2] as TraceFields | undefined);
|
|
224
|
+
} catch {
|
|
225
|
+
/* ignore */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Count the copies of this package in the page. More than one means the bundle holds duplicates (pnpm peer-set
|
|
234
|
+
* variants, a dual ESM/CJS load): the contexts are shared (`sharedContext`), but the build should still be fixed.
|
|
235
|
+
* Visible as `dup.copy` in the trace and once in the console.
|
|
236
|
+
*/
|
|
237
|
+
function registerCopy(): void {
|
|
238
|
+
if (!hasWindow) return;
|
|
239
|
+
const g = globalThis as Record<string, unknown>;
|
|
240
|
+
const n = ((g.__cfgAuthCopies as number | undefined) ?? 0) + 1;
|
|
241
|
+
g.__cfgAuthCopies = n;
|
|
242
|
+
if (n > 1) {
|
|
243
|
+
authTrace('dup.copy', { copies: n });
|
|
244
|
+
if (n === 2) {
|
|
245
|
+
// eslint-disable-next-line no-console
|
|
246
|
+
console.warn('[@djangocfg/api] more than one copy of the auth package is loaded; see __cfgAuth.dump() (dup.copy). Align the peer set so pnpm installs one instance.');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
registerCopy();
|
|
251
|
+
|
|
252
|
+
// The reader is installed as soon as the package loads, so a trace exists from the first event.
|
|
253
|
+
installAuthTrace();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one auth state: a single value answers "may this page render?".
|
|
3
|
+
*
|
|
4
|
+
* resolving the client has not read the session yet, or a live session is still loading its profile
|
|
5
|
+
* authenticated a live session and its profile settled
|
|
6
|
+
* anonymous no session (store and React snapshot agree)
|
|
7
|
+
*
|
|
8
|
+
* Every consumer (the page guard, the "leave /auth" effect, the trace) reads this and nothing else. The
|
|
9
|
+
* inputs are plain booleans so the whole table is unit-tested.
|
|
10
|
+
*
|
|
11
|
+
* Termination: `resolving` is left in finite time. `timedOut` is set by one timer in the provider, and
|
|
12
|
+
* when it fires the React snapshot decides. There is no input combination that stays `resolving` with
|
|
13
|
+
* `timedOut` true.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type AuthStatus = 'resolving' | 'authenticated' | 'anonymous';
|
|
17
|
+
|
|
18
|
+
export interface AuthStateInput {
|
|
19
|
+
/** The first client effect has run (the hydration commit is over). */
|
|
20
|
+
clientReady: boolean;
|
|
21
|
+
/** `useSession()` says authenticated (the React snapshot; the server snapshot is always anonymous). */
|
|
22
|
+
snapshotAuthenticated: boolean;
|
|
23
|
+
/** `auth.isAuthenticated()` read straight from the store at this render. */
|
|
24
|
+
storeAuthenticated: boolean;
|
|
25
|
+
/** The profile for a live session has loaded, failed in a way that keeps the session, or was cached. */
|
|
26
|
+
profileSettled: boolean;
|
|
27
|
+
/** The provider's single safety timer fired. */
|
|
28
|
+
timedOut: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveAuthStatus(s: AuthStateInput): AuthStatus {
|
|
32
|
+
if (!s.clientReady) return 'resolving';
|
|
33
|
+
if (s.snapshotAuthenticated) {
|
|
34
|
+
return s.profileSettled || s.timedOut ? 'authenticated' : 'resolving';
|
|
35
|
+
}
|
|
36
|
+
// Snapshot says signed out. If the store disagrees the snapshot is lagging: wait for it, but never forever.
|
|
37
|
+
if (s.storeAuthenticated && !s.timedOut) return 'resolving';
|
|
38
|
+
return 'anonymous';
|
|
39
|
+
}
|
package/src/auth/utils/errors.ts
CHANGED
|
@@ -10,9 +10,9 @@ const prop = (value: unknown, key: string): unknown =>
|
|
|
10
10
|
* `APIError` builds this when the caller supplies no message of its own
|
|
11
11
|
* (`_api/generated/helpers/errors.ts`), so it is never server prose — it is the
|
|
12
12
|
* status line restated. Under HTTP/2 `statusText` is empty, which is how a bare
|
|
13
|
-
* `HTTP 404:` reached a sign-in form and told the user nothing at all.
|
|
13
|
+
* `HTTP 404:` (and `HTTP 0:` for a response that never arrived) reached a sign-in form and told the user nothing at all.
|
|
14
14
|
*/
|
|
15
|
-
const TRANSPORT_FALLBACK = /^HTTP \d{3}:\s*/;
|
|
15
|
+
const TRANSPORT_FALLBACK = /^HTTP \d{1,3}:\s*/;
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Translation keys for the copy this module can choose.
|
|
@@ -52,6 +52,9 @@ const FALLBACK_COPY: Record<AuthErrorKey, string> = {
|
|
|
52
52
|
/** Map a status code to the key whose copy explains it. */
|
|
53
53
|
const keyForStatus = (status: number | undefined): AuthErrorKey => {
|
|
54
54
|
if (status === undefined) return AUTH_ERROR_KEYS.generic;
|
|
55
|
+
// 0 is "no response at all": the network dropped, CORS refused (a proxy answering 404 while the API restarts carries
|
|
56
|
+
// no CORS headers, so the browser reports it as 0), or the request was blocked. Not a server verdict.
|
|
57
|
+
if (status === 0) return AUTH_ERROR_KEYS.networkError;
|
|
55
58
|
if (status === 401 || status === 403) return AUTH_ERROR_KEYS.invalidCredentials;
|
|
56
59
|
// A 404 here is a missing endpoint, not a missing page — see AUTH_ERROR_KEYS.
|
|
57
60
|
if (status === 404) return AUTH_ERROR_KEYS.serverUnavailable;
|
package/src/auth/utils/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
type GuardInput,
|
|
24
24
|
} from './guard';
|
|
25
25
|
export { normalizePath, isAllowedAuthPath } from './path';
|
|
26
|
+
export { resolveAuthStatus, type AuthStatus, type AuthStateInput } from './authState';
|
|
26
27
|
export { logger, authLogger } from './logger';
|
|
27
28
|
export {
|
|
28
29
|
Analytics,
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* await cfgAccountsApi.accounts.cfgAccountsLogin({ body });
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
// Installs `window.__cfgAuth` (the auth trace reader) as soon as the package loads.
|
|
12
|
+
import './auth/trace';
|
|
13
|
+
|
|
11
14
|
export * from './_api/generated';
|
|
12
15
|
|
|
13
16
|
// Hey API SDK classes (one per OpenAPI tag) — direct callable static methods.
|