@djangocfg/api 2.1.455 → 2.1.457

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.
Files changed (89) hide show
  1. package/dist/auth.cjs +1611 -2148
  2. package/dist/auth.cjs.map +1 -1
  3. package/dist/auth.d.cts +37 -82
  4. package/dist/auth.d.ts +37 -82
  5. package/dist/auth.mjs +1581 -2118
  6. package/dist/auth.mjs.map +1 -1
  7. package/dist/chunk-2G67QRNU.mjs +2096 -0
  8. package/dist/chunk-2G67QRNU.mjs.map +1 -0
  9. package/dist/chunk-32SRQGAC.mjs +2109 -0
  10. package/dist/chunk-32SRQGAC.mjs.map +1 -0
  11. package/dist/chunk-4BPRCONN.mjs +2098 -0
  12. package/dist/chunk-4BPRCONN.mjs.map +1 -0
  13. package/dist/chunk-5UZ2Z323.mjs +2021 -0
  14. package/dist/chunk-5UZ2Z323.mjs.map +1 -0
  15. package/dist/chunk-7KFTJXNM.mjs +2097 -0
  16. package/dist/chunk-7KFTJXNM.mjs.map +1 -0
  17. package/dist/chunk-BK4K5CVT.mjs +2019 -0
  18. package/dist/chunk-BK4K5CVT.mjs.map +1 -0
  19. package/dist/chunk-JLPJZ6WB.mjs +2020 -0
  20. package/dist/chunk-JLPJZ6WB.mjs.map +1 -0
  21. package/dist/chunk-TVU6PYJH.mjs +2032 -0
  22. package/dist/chunk-TVU6PYJH.mjs.map +1 -0
  23. package/dist/clients.cjs +292 -47
  24. package/dist/clients.cjs.map +1 -1
  25. package/dist/clients.d.cts +23 -0
  26. package/dist/clients.d.ts +23 -0
  27. package/dist/clients.mjs +292 -47
  28. package/dist/clients.mjs.map +1 -1
  29. package/dist/hooks.cjs +200 -32
  30. package/dist/hooks.cjs.map +1 -1
  31. package/dist/hooks.mjs +200 -32
  32. package/dist/hooks.mjs.map +1 -1
  33. package/dist/index.cjs +292 -47
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.cts +81 -4
  36. package/dist/index.d.ts +81 -4
  37. package/dist/index.mjs +292 -47
  38. package/dist/index.mjs.map +1 -1
  39. package/dist/sdk.gen-2SQOPTWJ.mjs +26 -0
  40. package/dist/sdk.gen-2SQOPTWJ.mjs.map +1 -0
  41. package/dist/sdk.gen-HSGK4C5S.mjs +26 -0
  42. package/dist/sdk.gen-HSGK4C5S.mjs.map +1 -0
  43. package/dist/sdk.gen-NUK2VGHO.mjs +25 -0
  44. package/dist/sdk.gen-NUK2VGHO.mjs.map +1 -0
  45. package/dist/sdk.gen-O4KAQUZB.mjs +26 -0
  46. package/dist/sdk.gen-O4KAQUZB.mjs.map +1 -0
  47. package/dist/sdk.gen-PPAVSBNT.mjs +26 -0
  48. package/dist/sdk.gen-PPAVSBNT.mjs.map +1 -0
  49. package/dist/sdk.gen-XLHLOCJ2.mjs +25 -0
  50. package/dist/sdk.gen-XLHLOCJ2.mjs.map +1 -0
  51. package/dist/sdk.gen-ZOE6NQAL.mjs +26 -0
  52. package/dist/sdk.gen-ZOE6NQAL.mjs.map +1 -0
  53. package/dist/sdk.gen-ZT7LGJVO.mjs +26 -0
  54. package/dist/sdk.gen-ZT7LGJVO.mjs.map +1 -0
  55. package/package.json +2 -2
  56. package/src/_api/generated/_cfg_accounts/hooks/index.ts +1 -0
  57. package/src/_api/generated/_cfg_accounts/hooks/useCfgAccountsTokenBlacklistCreate.ts +28 -0
  58. package/src/_api/generated/_cfg_accounts/openapi.json +99 -0
  59. package/src/_api/generated/_cfg_accounts/schemas/TokenBlacklistRequest.ts +11 -0
  60. package/src/_api/generated/_cfg_accounts/schemas/index.ts +1 -0
  61. package/src/_api/generated/_cfg_centrifugo/openapi.json +9 -0
  62. package/src/_api/generated/_cfg_totp/openapi.json +33 -0
  63. package/src/_api/generated/helpers/auth.ts +288 -31
  64. package/src/_api/generated/openapi.json +129 -0
  65. package/src/_api/generated/sdk.gen.ts +94 -16
  66. package/src/_api/generated/types.gen.ts +18 -0
  67. package/src/auth/__tests__/guard.test.ts +0 -31
  68. package/src/auth/constants.ts +6 -0
  69. package/src/auth/context/AccountsContext.tsx +14 -24
  70. package/src/auth/context/AuthContext.tsx +263 -607
  71. package/src/auth/context/types.ts +13 -0
  72. package/src/auth/hooks/index.ts +8 -5
  73. package/src/auth/hooks/useAuthRedirect.ts +17 -0
  74. package/src/auth/hooks/useGithubAuth.ts +3 -3
  75. package/src/auth/hooks/useSession.ts +22 -0
  76. package/src/auth/hooks/useTwoFactor.ts +8 -6
  77. package/src/auth/middlewares/index.ts +5 -6
  78. package/src/auth/utils/guard.ts +0 -22
  79. package/src/auth/utils/index.ts +0 -2
  80. package/src/_api/generated/_cfg_accounts/events.ts +0 -198
  81. package/src/_api/generated/_cfg_centrifugo/events.ts +0 -198
  82. package/src/_api/generated/_cfg_totp/events.ts +0 -198
  83. package/src/auth/__tests__/jwt.test.ts +0 -119
  84. package/src/auth/__tests__/sessionBootstrap.test.ts +0 -111
  85. package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +0 -167
  86. package/src/auth/hooks/useAuthGuard.ts +0 -35
  87. package/src/auth/hooks/useTokenRefresh.ts +0 -131
  88. package/src/auth/refreshHandler.ts +0 -79
  89. package/src/auth/utils/jwt.ts +0 -66
@@ -1,198 +0,0 @@
1
- // AUTO-GENERATED by cmdop_server / devtools.generator.ts_extras.events
2
- // Hooks Hey API client interceptors → typed CustomEvents on `window`.
3
- // DO NOT EDIT — re-run `make gen-clients`.
4
- //
5
- // Usage:
6
- // import "./generated/events" // side-effect: registers interceptors
7
- //
8
- // window.addEventListener("cmdop:network-error", (e) => {
9
- // console.log(e.detail.status, e.detail.method, e.detail.url);
10
- // });
11
-
12
- import { client } from "../client.gen";
13
-
14
- // ─────────────────────────────────────────────────────────────────────────────
15
- // Event names + detail types
16
- // ─────────────────────────────────────────────────────────────────────────────
17
-
18
- export const NETWORK_ERROR_EVENT = "cmdop:network-error" as const;
19
- export const VALIDATION_ERROR_EVENT = "cmdop:validation-error" as const;
20
- export const RUNTIME_ERROR_EVENT = "cmdop:runtime-error" as const;
21
-
22
- export interface NetworkErrorDetail {
23
- /** HTTP method (GET/POST/...). */
24
- method: string;
25
- /** Request URL. */
26
- url: string;
27
- /** HTTP status if response arrived; null on transport failure. */
28
- status: number | null;
29
- /** Response body parsed as text (best-effort, may be empty). */
30
- body?: string;
31
- /** Underlying error message (network failures, abort, etc.). */
32
- error?: string;
33
- /** Heuristic: did the request likely fail due to CORS? */
34
- possiblyCors: boolean;
35
- timestamp: Date;
36
- }
37
-
38
- export interface ValidationErrorDetail {
39
- /** Operation ID this validation applies to (from the OpenAPI spec). */
40
- operation: string;
41
- /** Schema name that failed (if known). */
42
- schema?: string;
43
- /** Issues array — shape mirrors zod's ZodIssue but kept opaque here to
44
- * avoid forcing a hard zod dep on consumers that don't use schemas. */
45
- issues: unknown;
46
- /** Raw response data that failed validation. */
47
- data: unknown;
48
- timestamp: Date;
49
- }
50
-
51
- export interface RuntimeErrorDetail {
52
- /** Free-form source identifier — component name, hook name, etc. */
53
- source: string;
54
- message: string;
55
- /** Original Error object (may be undefined when caller lacks one). */
56
- error?: Error;
57
- /** Arbitrary context payload from the caller. */
58
- context?: Record<string, unknown>;
59
- timestamp: Date;
60
- }
61
-
62
- // Augment the global WindowEventMap so addEventListener gets typed details.
63
- declare global {
64
- interface WindowEventMap {
65
- "cmdop:network-error": CustomEvent<NetworkErrorDetail>;
66
- "cmdop:validation-error": CustomEvent<ValidationErrorDetail>;
67
- "cmdop:runtime-error": CustomEvent<RuntimeErrorDetail>;
68
- }
69
- }
70
-
71
- // ─────────────────────────────────────────────────────────────────────────────
72
- // Dispatch helpers
73
- // ─────────────────────────────────────────────────────────────────────────────
74
-
75
- function _dispatch<T>(name: string, detail: T): void {
76
- if (typeof window === "undefined") return;
77
- try {
78
- window.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
79
- } catch {
80
- // Event dispatch must never crash the app.
81
- }
82
- }
83
-
84
- /** Emit a runtime error from anywhere in app code. */
85
- export function emitRuntimeError(
86
- source: string,
87
- message: string,
88
- error?: Error,
89
- context?: Record<string, unknown>,
90
- ): void {
91
- _dispatch<RuntimeErrorDetail>(RUNTIME_ERROR_EVENT, {
92
- source,
93
- message,
94
- error,
95
- context,
96
- timestamp: new Date(),
97
- });
98
- }
99
-
100
- /** Emit a zod validation failure. Called by hooks when `validate` is provided. */
101
- export function dispatchValidationError(
102
- operation: string,
103
- data: unknown,
104
- issues: unknown,
105
- schema?: string,
106
- ): void {
107
- _dispatch<ValidationErrorDetail>(VALIDATION_ERROR_EVENT, {
108
- operation,
109
- schema,
110
- issues,
111
- data,
112
- timestamp: new Date(),
113
- });
114
- }
115
-
116
- // ─────────────────────────────────────────────────────────────────────────────
117
- // Listen helpers — typed wrappers over addEventListener, return cleanup fn.
118
- // ─────────────────────────────────────────────────────────────────────────────
119
-
120
- export function onNetworkError(
121
- cb: (detail: NetworkErrorDetail) => void,
122
- ): () => void {
123
- if (typeof window === "undefined") return () => {};
124
- const handler = (e: CustomEvent<NetworkErrorDetail>) => cb(e.detail);
125
- window.addEventListener(NETWORK_ERROR_EVENT, handler);
126
- return () => window.removeEventListener(NETWORK_ERROR_EVENT, handler);
127
- }
128
-
129
- export function onValidationError(
130
- cb: (detail: ValidationErrorDetail) => void,
131
- ): () => void {
132
- if (typeof window === "undefined") return () => {};
133
- const handler = (e: CustomEvent<ValidationErrorDetail>) => cb(e.detail);
134
- window.addEventListener(VALIDATION_ERROR_EVENT, handler);
135
- return () => window.removeEventListener(VALIDATION_ERROR_EVENT, handler);
136
- }
137
-
138
- export function onRuntimeError(
139
- cb: (detail: RuntimeErrorDetail) => void,
140
- ): () => void {
141
- if (typeof window === "undefined") return () => {};
142
- const handler = (e: CustomEvent<RuntimeErrorDetail>) => cb(e.detail);
143
- window.addEventListener(RUNTIME_ERROR_EVENT, handler);
144
- return () => window.removeEventListener(RUNTIME_ERROR_EVENT, handler);
145
- }
146
-
147
- // ─────────────────────────────────────────────────────────────────────────────
148
- // Hey API interceptors — wire once, emit on response/error.
149
- // ─────────────────────────────────────────────────────────────────────────────
150
-
151
- let _wired = false;
152
-
153
- function _wire(): void {
154
- if (_wired) return;
155
- _wired = true;
156
-
157
- // response interceptor: every response, OK or not, lets us see status.
158
- // We only emit for non-2xx — successful requests are silent.
159
- client.interceptors.response.use(async (response, request) => {
160
- if (!response.ok) {
161
- let body: string | undefined;
162
- try {
163
- body = await response.clone().text();
164
- if (body.length > 4000) body = body.slice(0, 4000) + "…";
165
- } catch {
166
- // ignore — body inaccessible
167
- }
168
- _dispatch<NetworkErrorDetail>(NETWORK_ERROR_EVENT, {
169
- method: request.method,
170
- url: request.url,
171
- status: response.status,
172
- body,
173
- possiblyCors: false,
174
- timestamp: new Date(),
175
- });
176
- }
177
- return response;
178
- });
179
-
180
- // error interceptor: transport-level failures (abort, DNS, CORS, offline).
181
- client.interceptors.error.use((error, _response, request) => {
182
- const msg = error instanceof Error ? error.message : String(error);
183
- // Heuristic: CORS-blocked fetches usually surface as "Failed to fetch"
184
- // without a status. Not perfect, but catches the common case.
185
- const possiblyCors = /failed to fetch|network error|cors/i.test(msg);
186
- _dispatch<NetworkErrorDetail>(NETWORK_ERROR_EVENT, {
187
- method: request?.method ?? "UNKNOWN",
188
- url: request?.url ?? "",
189
- status: null,
190
- error: msg,
191
- possiblyCors,
192
- timestamp: new Date(),
193
- });
194
- return error;
195
- });
196
- }
197
-
198
- _wire();
@@ -1,119 +0,0 @@
1
- /**
2
- * JWT expiry-helper tests (auth/utils/jwt.ts).
3
- *
4
- * These functions decide whether the client should trust a persisted token or
5
- * treat the session as dead. They must FAIL CLOSED: anything undecodable counts
6
- * as expired, so garbage in localStorage sends the user to the login form
7
- * rather than looping on 401s. (This is the automated version of the user's
8
- * manual "clear localStorage and the form appeared".)
9
- */
10
-
11
- import { describe, expect, it } from 'vitest';
12
-
13
- import { getTokenExpiry, isTokenExpired, isTokenExpiringSoon } from '../utils/jwt';
14
-
15
- const NOW = 1_800_000_000_000; // fixed "now" in ms (injected, not Date.now())
16
-
17
- /** Base64url without padding — mirrors real JWT segment encoding. */
18
- function b64url(obj: unknown): string {
19
- const json = JSON.stringify(obj);
20
- const b64 = Buffer.from(json, 'utf8').toString('base64');
21
- return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
- }
23
-
24
- /** Build a syntactically valid 3-segment JWT with the given payload. */
25
- function jwt(payload: Record<string, unknown>): string {
26
- const header = b64url({ alg: 'HS256', typ: 'JWT' });
27
- const body = b64url(payload);
28
- return `${header}.${body}.signature`;
29
- }
30
-
31
- /** exp claim is in SECONDS in a real JWT. */
32
- const expSeconds = (ms: number) => Math.floor(ms / 1000);
33
-
34
- describe('getTokenExpiry', () => {
35
- it('extracts exp (converted to ms) from a well-formed token', () => {
36
- const token = jwt({ exp: expSeconds(NOW) });
37
- expect(getTokenExpiry(token)).toBe(expSeconds(NOW) * 1000);
38
- });
39
-
40
- it('returns null for null / undefined / empty', () => {
41
- expect(getTokenExpiry(null)).toBeNull();
42
- expect(getTokenExpiry(undefined)).toBeNull();
43
- expect(getTokenExpiry('')).toBeNull();
44
- });
45
-
46
- it('returns null for a non-JWT string (wrong segment count)', () => {
47
- expect(getTokenExpiry('not-a-jwt')).toBeNull();
48
- expect(getTokenExpiry('only.two')).toBeNull();
49
- expect(getTokenExpiry('a.b.c.d')).toBeNull();
50
- });
51
-
52
- it('returns null when the payload is not valid base64/JSON', () => {
53
- expect(getTokenExpiry('aaa.!!!!.bbb')).toBeNull();
54
- });
55
-
56
- it('returns null when exp is missing or non-numeric', () => {
57
- expect(getTokenExpiry(jwt({ sub: 'user' }))).toBeNull();
58
- expect(getTokenExpiry(jwt({ exp: 'soon' }))).toBeNull();
59
- expect(getTokenExpiry(jwt({ exp: null }))).toBeNull();
60
- });
61
-
62
- it('handles a non-object payload (e.g. a JSON number) as no-exp', () => {
63
- const header = b64url({ alg: 'HS256' });
64
- const body = b64url(42 as unknown as Record<string, unknown>);
65
- expect(getTokenExpiry(`${header}.${body}.sig`)).toBeNull();
66
- });
67
- });
68
-
69
- describe('isTokenExpired (fail-closed)', () => {
70
- it('false for a token that expires in the future', () => {
71
- const token = jwt({ exp: expSeconds(NOW + 60_000) });
72
- expect(isTokenExpired(token, 0, NOW)).toBe(false);
73
- });
74
-
75
- it('true for a token already past exp', () => {
76
- const token = jwt({ exp: expSeconds(NOW - 1) });
77
- expect(isTokenExpired(token, 0, NOW)).toBe(true);
78
- });
79
-
80
- it('true exactly at exp (boundary is inclusive → expired)', () => {
81
- const token = jwt({ exp: expSeconds(NOW) });
82
- expect(isTokenExpired(token, 0, NOW)).toBe(true);
83
- });
84
-
85
- it('skew treats a soon-to-expire token as already expired', () => {
86
- const token = jwt({ exp: expSeconds(NOW + 30_000) }); // expires in 30s
87
- expect(isTokenExpired(token, 60_000, NOW)).toBe(true); // 60s skew
88
- expect(isTokenExpired(token, 10_000, NOW)).toBe(false); // 10s skew
89
- });
90
-
91
- it('true (fail-closed) for null / garbage / no-exp tokens', () => {
92
- expect(isTokenExpired(null, 0, NOW)).toBe(true);
93
- expect(isTokenExpired(undefined, 0, NOW)).toBe(true);
94
- expect(isTokenExpired('garbage', 0, NOW)).toBe(true);
95
- expect(isTokenExpired(jwt({ sub: 'x' }), 0, NOW)).toBe(true);
96
- });
97
- });
98
-
99
- describe('isTokenExpiringSoon', () => {
100
- it('true when expiry is within the threshold', () => {
101
- const token = jwt({ exp: expSeconds(NOW + 5 * 60_000) }); // 5 min
102
- expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
103
- });
104
-
105
- it('false when expiry is beyond the threshold', () => {
106
- const token = jwt({ exp: expSeconds(NOW + 20 * 60_000) }); // 20 min
107
- expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(false);
108
- });
109
-
110
- it('false (not "soon") for undecodable tokens — the expired path owns those', () => {
111
- expect(isTokenExpiringSoon(null, 10 * 60_000, NOW)).toBe(false);
112
- expect(isTokenExpiringSoon('garbage', 10 * 60_000, NOW)).toBe(false);
113
- });
114
-
115
- it('true for an already-expired but decodable token', () => {
116
- const token = jwt({ exp: expSeconds(NOW - 1000) });
117
- expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
118
- });
119
- });
@@ -1,111 +0,0 @@
1
- /**
2
- * Bootstrap dead-session detection (isSessionDeadOnBootstrap in AuthContext).
3
- *
4
- * This is the decision that, at app start, either shows the login form or lets
5
- * the app try to use / refresh the persisted tokens. It reproduces the exact
6
- * scenario the user hit: stale tokens in localStorage that can never recover,
7
- * where the fix used to be "clear localStorage by hand". The session is DEAD
8
- * only when the access token is unusable AND the refresh token can't save it.
9
- */
10
-
11
- import { describe, expect, it } from 'vitest';
12
-
13
- import { isSessionDeadOnBootstrap } from '../context/AuthContext';
14
-
15
- const NOW = 1_800_000_000_000;
16
- const expSeconds = (ms: number) => Math.floor(ms / 1000);
17
-
18
- function b64url(obj: unknown): string {
19
- return Buffer.from(JSON.stringify(obj), 'utf8')
20
- .toString('base64')
21
- .replace(/\+/g, '-')
22
- .replace(/\//g, '_')
23
- .replace(/=+$/, '');
24
- }
25
- function jwt(payload: Record<string, unknown>): string {
26
- return `${b64url({ alg: 'HS256' })}.${b64url(payload)}.sig`;
27
- }
28
- const live = () => jwt({ exp: expSeconds(NOW + 3_600_000) }); // +1h
29
- const dead = () => jwt({ exp: expSeconds(NOW - 3_600_000) }); // -1h
30
-
31
- describe('isSessionDeadOnBootstrap', () => {
32
- it('ALIVE: live access + live refresh', () => {
33
- expect(isSessionDeadOnBootstrap(live(), live(), NOW)).toBe(false);
34
- });
35
-
36
- it('ALIVE: live access even if refresh is dead (access still works)', () => {
37
- expect(isSessionDeadOnBootstrap(live(), dead(), NOW)).toBe(false);
38
- });
39
-
40
- it('ALIVE: live access even with no refresh token', () => {
41
- expect(isSessionDeadOnBootstrap(live(), null, NOW)).toBe(false);
42
- });
43
-
44
- it('ALIVE: dead access but LIVE refresh (refresh can rescue it)', () => {
45
- expect(isSessionDeadOnBootstrap(dead(), live(), NOW)).toBe(false);
46
- });
47
-
48
- it('DEAD: dead access + dead refresh (the classic stuck state)', () => {
49
- expect(isSessionDeadOnBootstrap(dead(), dead(), NOW)).toBe(true);
50
- });
51
-
52
- it('DEAD: dead access + no refresh token', () => {
53
- expect(isSessionDeadOnBootstrap(dead(), null, NOW)).toBe(true);
54
- });
55
-
56
- it('DEAD: both tokens null (empty storage that still triggered init)', () => {
57
- expect(isSessionDeadOnBootstrap(null, null, NOW)).toBe(true);
58
- });
59
-
60
- it('DEAD: garbage access + garbage refresh (corrupt storage — user cleared this by hand)', () => {
61
- expect(isSessionDeadOnBootstrap('garbage', 'also-garbage', NOW)).toBe(true);
62
- });
63
-
64
- it('DEAD: garbage access + no refresh', () => {
65
- expect(isSessionDeadOnBootstrap('xxxxx', null, NOW)).toBe(true);
66
- });
67
-
68
- it('ALIVE: garbage access but a LIVE refresh token still lets us recover', () => {
69
- expect(isSessionDeadOnBootstrap('garbage', live(), NOW)).toBe(false);
70
- });
71
-
72
- it('DEAD: access expired exactly at now + refresh expired (inclusive boundary)', () => {
73
- const atNow = jwt({ exp: expSeconds(NOW) });
74
- expect(isSessionDeadOnBootstrap(atNow, atNow, NOW)).toBe(true);
75
- });
76
- });
77
-
78
- /**
79
- * `isAuthenticated` is now derived as `!isSessionDeadOnBootstrap(...)` (via the
80
- * private isSessionAlive wrapper in AuthContext). These assertions read the same
81
- * predicate from the guard's point of view — proving the fix is self-sufficient:
82
- * a stale session reads as unauthenticated from LOCAL exp alone, with no server
83
- * 401 required (the exact case a CSP-blocked / hung request used to swallow).
84
- */
85
- describe('isAuthenticated gate (derived from session liveness, network-free)', () => {
86
- const alive = (a: string | null, r: string | null) => !isSessionDeadOnBootstrap(a, r, NOW);
87
-
88
- it('reports authenticated while the access token is still valid', () => {
89
- expect(alive(live(), null)).toBe(true);
90
- });
91
-
92
- it('stays authenticated when access is expired but refresh can still renew', () => {
93
- // Between access expiry and the silent refresh — NOT bounced to /auth.
94
- expect(alive(dead(), live())).toBe(true);
95
- });
96
-
97
- it('reports UNAUTHENTICATED the instant both tokens are expired — no 401 needed', () => {
98
- // This is the reporter's scenario: an expired session where the 401 that
99
- // would normally trigger logout is swallowed by CSP / a hung request.
100
- expect(alive(dead(), dead())).toBe(false);
101
- });
102
-
103
- it('reports UNAUTHENTICATED for a stale access with no refresh token', () => {
104
- expect(alive(dead(), null)).toBe(false);
105
- });
106
-
107
- it('reports UNAUTHENTICATED for garbage tokens (would have read "present" before)', () => {
108
- // The OLD isAuthenticated() returned true for any non-null token string.
109
- expect(alive('stale-non-jwt', 'also-garbage')).toBe(false);
110
- });
111
- });
@@ -1,167 +0,0 @@
1
- // @vitest-environment jsdom
2
-
3
- /**
4
- * useTokenRefresh DOM tests.
5
- *
6
- * The proactive refresher decides WHEN to refresh (expiry timer, window focus,
7
- * network reconnect) and delegates HOW to triggerRefresh. It is central to the
8
- * stuck-auth bug: a proactive refresh that fails on a dead/blacklisted token
9
- * must surface onRefreshError so AuthContext can collapse the session to the
10
- * login form. These tests mock the api store + triggerRefresh and drive timers
11
- * and window events.
12
- */
13
-
14
- import { renderHook, act } from '@testing-library/react';
15
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
-
17
- // Mocks must be declared before importing the hook under test.
18
- const mockGetToken = vi.fn<() => string | null>();
19
- const mockGetRefreshToken = vi.fn<() => string | null>();
20
- const mockTriggerRefresh = vi.fn<() => Promise<string | null>>();
21
-
22
- vi.mock('../../', () => ({
23
- api: {
24
- getToken: () => mockGetToken(),
25
- getRefreshToken: () => mockGetRefreshToken(),
26
- },
27
- }));
28
- vi.mock('../refreshHandler', () => ({
29
- triggerRefresh: () => mockTriggerRefresh(),
30
- }));
31
-
32
- import { useTokenRefresh } from '../hooks/useTokenRefresh';
33
-
34
- const NOW = 1_800_000_000_000;
35
- const expSeconds = (ms: number) => Math.floor(ms / 1000);
36
- function b64url(o: unknown) {
37
- return Buffer.from(JSON.stringify(o), 'utf8').toString('base64')
38
- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
39
- }
40
- function jwt(exp: number) {
41
- return `${b64url({ alg: 'HS256' })}.${b64url({ exp })}.sig`;
42
- }
43
- const liveToken = () => jwt(expSeconds(NOW + 60 * 60_000)); // +1h — not expiring
44
- const expiringToken = () => jwt(expSeconds(NOW + 60_000)); // +1m — within 10m threshold
45
-
46
- describe('useTokenRefresh', () => {
47
- beforeEach(() => {
48
- vi.useFakeTimers();
49
- vi.setSystemTime(NOW);
50
- mockGetToken.mockReset();
51
- mockGetRefreshToken.mockReset();
52
- mockTriggerRefresh.mockReset();
53
- mockGetRefreshToken.mockReturnValue('R-valid');
54
- mockTriggerRefresh.mockResolvedValue('access-new');
55
- });
56
- afterEach(() => {
57
- vi.useRealTimers();
58
- vi.restoreAllMocks();
59
- });
60
-
61
- it('refreshes on mount when the token is expiring soon', async () => {
62
- mockGetToken.mockReturnValue(expiringToken());
63
- renderHook(() => useTokenRefresh({ enabled: true }));
64
- // initial checkAndRefresh runs in an effect
65
- await act(async () => { await Promise.resolve(); });
66
- expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
67
- });
68
-
69
- it('does NOT refresh on mount when the token is fresh', async () => {
70
- mockGetToken.mockReturnValue(liveToken());
71
- renderHook(() => useTokenRefresh({ enabled: true }));
72
- await act(async () => { await Promise.resolve(); });
73
- expect(mockTriggerRefresh).not.toHaveBeenCalled();
74
- });
75
-
76
- it('does nothing when there is no access token', async () => {
77
- mockGetToken.mockReturnValue(null);
78
- renderHook(() => useTokenRefresh({ enabled: true }));
79
- await act(async () => { await Promise.resolve(); });
80
- expect(mockTriggerRefresh).not.toHaveBeenCalled();
81
- });
82
-
83
- it('is inert when disabled', async () => {
84
- mockGetToken.mockReturnValue(expiringToken());
85
- renderHook(() => useTokenRefresh({ enabled: false }));
86
- await act(async () => { await Promise.resolve(); });
87
- expect(mockTriggerRefresh).not.toHaveBeenCalled();
88
- });
89
-
90
- it('fires onRefresh with the new token on success', async () => {
91
- mockGetToken.mockReturnValue(expiringToken());
92
- const onRefresh = vi.fn();
93
- const { result } = renderHook(() => useTokenRefresh({ enabled: true, onRefresh }));
94
- await act(async () => { await result.current.refreshToken(); });
95
- expect(onRefresh).toHaveBeenCalledWith('access-new');
96
- });
97
-
98
- it('fires onRefreshError when the refresh fails (dead/blacklisted token)', async () => {
99
- // Fresh token so the mount-effect check does NOT auto-refresh; we drive the
100
- // failing refresh explicitly to isolate the error path.
101
- mockGetToken.mockReturnValue(liveToken());
102
- mockTriggerRefresh.mockResolvedValue(null); // refresh failed
103
- const onRefreshError = vi.fn();
104
- const { result } = renderHook(() => useTokenRefresh({ enabled: true, onRefreshError }));
105
- await act(async () => { await Promise.resolve(); }); // let mount effect settle (no-op)
106
- expect(mockTriggerRefresh).not.toHaveBeenCalled();
107
-
108
- await act(async () => {
109
- const ok = await result.current.refreshToken();
110
- expect(ok).toBe(false);
111
- });
112
- expect(onRefreshError).toHaveBeenCalledTimes(1);
113
- expect(onRefreshError.mock.calls[0][0]).toBeInstanceOf(Error);
114
- });
115
-
116
- it('refreshToken returns false and skips trigger when no refresh token exists', async () => {
117
- mockGetToken.mockReturnValue(expiringToken());
118
- mockGetRefreshToken.mockReturnValue(null);
119
- const { result } = renderHook(() => useTokenRefresh({ enabled: true }));
120
- await act(async () => {
121
- const ok = await result.current.refreshToken();
122
- expect(ok).toBe(false);
123
- });
124
- expect(mockTriggerRefresh).not.toHaveBeenCalled();
125
- });
126
-
127
- it('refreshes on window focus when the token is expiring', async () => {
128
- mockGetToken.mockReturnValue(expiringToken());
129
- renderHook(() => useTokenRefresh({ enabled: true }));
130
- await act(async () => { await Promise.resolve(); }); // consume mount check
131
- mockTriggerRefresh.mockClear();
132
-
133
- await act(async () => {
134
- window.dispatchEvent(new Event('focus'));
135
- await Promise.resolve();
136
- });
137
- expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
138
- });
139
-
140
- it('refreshes on network reconnect (online event)', async () => {
141
- mockGetToken.mockReturnValue(expiringToken());
142
- renderHook(() => useTokenRefresh({ enabled: true }));
143
- await act(async () => { await Promise.resolve(); });
144
- mockTriggerRefresh.mockClear();
145
-
146
- await act(async () => {
147
- window.dispatchEvent(new Event('online'));
148
- await Promise.resolve();
149
- });
150
- expect(mockTriggerRefresh).toHaveBeenCalledTimes(1);
151
- });
152
-
153
- it('removes its event listeners on unmount', async () => {
154
- mockGetToken.mockReturnValue(expiringToken());
155
- const { unmount } = renderHook(() => useTokenRefresh({ enabled: true }));
156
- await act(async () => { await Promise.resolve(); });
157
- unmount();
158
- mockTriggerRefresh.mockClear();
159
-
160
- await act(async () => {
161
- window.dispatchEvent(new Event('focus'));
162
- window.dispatchEvent(new Event('online'));
163
- await Promise.resolve();
164
- });
165
- expect(mockTriggerRefresh).not.toHaveBeenCalled();
166
- });
167
- });
@@ -1,35 +0,0 @@
1
- "use client"
2
-
3
- import { useEffect, useState } from 'react';
4
-
5
- import { useAuth } from '../context';
6
- import { useCfgRouter } from './useCfgRouter';
7
-
8
- interface UseAuthGuardOptions {
9
- redirectTo?: string;
10
- requireAuth?: boolean;
11
- /** Whether to save current URL for redirect after auth (default: true) */
12
- saveRedirectUrl?: boolean;
13
- }
14
-
15
- export const useAuthGuard = (options: UseAuthGuardOptions = {}) => {
16
- const { redirectTo = '/auth', requireAuth = true, saveRedirectUrl: shouldSaveUrl = true } = options;
17
- const { isAuthenticated, isLoading, saveRedirectUrl } = useAuth();
18
- const router = useCfgRouter();
19
- const [isRedirecting, setIsRedirecting] = useState(false);
20
-
21
- useEffect(() => {
22
- if (!isLoading && requireAuth && !isAuthenticated && !isRedirecting) {
23
- // Save current URL before redirecting
24
- if (shouldSaveUrl && typeof window !== 'undefined') {
25
- const currentUrl = window.location.pathname + window.location.search;
26
- saveRedirectUrl(currentUrl);
27
- }
28
-
29
- setIsRedirecting(true);
30
- router.push(redirectTo);
31
- }
32
- }, [isAuthenticated, isLoading, router, redirectTo, requireAuth, isRedirecting, shouldSaveUrl, saveRedirectUrl]);
33
-
34
- return { isAuthenticated, isLoading, isRedirecting };
35
- };