@djangocfg/api 2.1.428 → 2.1.430
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 +58 -40
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.mjs +71 -53
- package/dist/auth.mjs.map +1 -1
- package/package.json +2 -2
- package/src/auth/hooks/useAuthForm.ts +39 -26
- package/src/auth/hooks/useAutoAuth.ts +35 -26
- package/src/auth/hooks/useQueryParams.ts +20 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.430",
|
|
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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^25.2.3",
|
|
86
86
|
"@types/react": "^19.2.15",
|
|
87
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
87
|
+
"@djangocfg/typescript-config": "^2.1.430",
|
|
88
88
|
"next": "^16.2.2",
|
|
89
89
|
"react": "^19.2.4",
|
|
90
90
|
"tsup": "^8.5.0",
|
|
@@ -32,6 +32,11 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
32
32
|
|
|
33
33
|
// Ref to track auto-submit from URL
|
|
34
34
|
const isAutoSubmitFromUrlRef = useRef(false);
|
|
35
|
+
// Remembers the exact `identifier:otp` we already attempted from a magic-link
|
|
36
|
+
// URL. We verify a given code at most ONCE — even if it turns out invalid or
|
|
37
|
+
// expired — so re-renders never re-fire the request. Without this, the auto
|
|
38
|
+
// submit loops and hammers /verify/ until the backend returns 429.
|
|
39
|
+
const processedOtpKeyRef = useRef<string | null>(null);
|
|
35
40
|
|
|
36
41
|
// Auth context for API calls and storage
|
|
37
42
|
const {
|
|
@@ -296,38 +301,46 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
296
301
|
// Auto-auth from URL
|
|
297
302
|
// ─────────────────────────────────────────────────────────────────────────
|
|
298
303
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
+
const handleOTPFromUrl = useCallback((detectedOtp: string, detectedEmail?: string) => {
|
|
305
|
+
// Prefer the email from the magic-link URL (works on a fresh browser/device),
|
|
306
|
+
// fall back to the one saved from a previous request on this browser.
|
|
307
|
+
const autoIdentifier = detectedEmail || getSavedEmail() || '';
|
|
308
|
+
|
|
309
|
+
if (!autoIdentifier) {
|
|
310
|
+
authLogger.warn('No identifier found for auto-submit (no email in URL or storage)');
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Verify each magic-link code at most once. A re-render must never re-fire
|
|
315
|
+
// the request — a failed/expired code that retried in a loop is exactly what
|
|
316
|
+
// drove the /verify/ spam and the backend's 429 lockout.
|
|
317
|
+
const otpKey = `${autoIdentifier}:${detectedOtp}`;
|
|
318
|
+
if (processedOtpKeyRef.current === otpKey) return;
|
|
319
|
+
if (isAutoSubmitFromUrlRef.current) return;
|
|
304
320
|
|
|
305
|
-
|
|
321
|
+
processedOtpKeyRef.current = otpKey;
|
|
322
|
+
isAutoSubmitFromUrlRef.current = true;
|
|
306
323
|
|
|
307
|
-
|
|
308
|
-
// fall back to the one saved from a previous request on this browser.
|
|
309
|
-
const autoIdentifier = detectedEmail || getSavedEmail() || '';
|
|
324
|
+
authLogger.info('OTP detected from URL, auto-submitting');
|
|
310
325
|
|
|
311
|
-
|
|
312
|
-
|
|
326
|
+
// Update UI state
|
|
327
|
+
setIdentifier(autoIdentifier);
|
|
328
|
+
setOtp(detectedOtp);
|
|
329
|
+
setStep('otp');
|
|
330
|
+
|
|
331
|
+
// Submit with explicit values
|
|
332
|
+
setTimeout(async () => {
|
|
333
|
+
try {
|
|
334
|
+
await submitOTP(autoIdentifier, detectedOtp);
|
|
335
|
+
} finally {
|
|
313
336
|
isAutoSubmitFromUrlRef.current = false;
|
|
314
|
-
return;
|
|
315
337
|
}
|
|
338
|
+
}, 100);
|
|
339
|
+
}, [getSavedEmail, setIdentifier, setOtp, setStep, submitOTP]);
|
|
316
340
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
setStep('otp');
|
|
321
|
-
|
|
322
|
-
// Submit with explicit values
|
|
323
|
-
setTimeout(async () => {
|
|
324
|
-
try {
|
|
325
|
-
await submitOTP(autoIdentifier, detectedOtp);
|
|
326
|
-
} finally {
|
|
327
|
-
isAutoSubmitFromUrlRef.current = false;
|
|
328
|
-
}
|
|
329
|
-
}, 100);
|
|
330
|
-
},
|
|
341
|
+
useAutoAuth({
|
|
342
|
+
allowedPaths: [authPath],
|
|
343
|
+
onOTPDetected: handleOTPFromUrl,
|
|
331
344
|
cleanupUrl: true,
|
|
332
345
|
});
|
|
333
346
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { usePathname } from 'next/navigation';
|
|
4
|
-
import { useEffect } from 'react';
|
|
4
|
+
import { useEffect, useRef } from 'react';
|
|
5
5
|
|
|
6
6
|
import { AUTH_CONSTANTS } from '../constants';
|
|
7
7
|
import { authLogger } from '../utils/logger';
|
|
@@ -45,36 +45,45 @@ export const useAutoAuth = (options: UseAutoAuthOptions = {}) => {
|
|
|
45
45
|
path => normalizedPath === path || normalizedPath.startsWith(path + '/')
|
|
46
46
|
);
|
|
47
47
|
|
|
48
|
-
const
|
|
48
|
+
const queryOtp = queryParams.get('otp') || '';
|
|
49
|
+
const queryEmail = queryParams.get('email') || undefined;
|
|
50
|
+
const hasOTP = !!queryOtp;
|
|
49
51
|
const isReady = !!pathname && hasOTP && isAllowedPath;
|
|
52
|
+
const otpValid = queryOtp.length === AUTH_CONSTANTS.EMAIL_OTP_LENGTH;
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
// Keep the latest callback in a ref so its (unstable) identity never lands in
|
|
55
|
+
// the effect deps below. Otherwise a parent that passes an inline function
|
|
56
|
+
// re-runs this effect on every render and re-fires the OTP submission.
|
|
57
|
+
const onOTPDetectedRef = useRef(onOTPDetected);
|
|
58
|
+
onOTPDetectedRef.current = onOTPDetected;
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
// Detect OTP and notify the parent. Keyed on the OTP *value* (a string), not
|
|
61
|
+
// on the `queryParams` object reference — so the effect fires once per code,
|
|
62
|
+
// not on every poll tick / re-render.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!isReady || !otpValid) return;
|
|
65
|
+
// Only on allowed paths to avoid conflicts with other pages
|
|
58
66
|
// (e.g., /dashboard/device uses ?otp= for device authorization, not user auth)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
67
|
+
authLogger.info('OTP detected in URL on auth page:', queryOtp);
|
|
68
|
+
onOTPDetectedRef.current?.(queryOtp, queryEmail);
|
|
69
|
+
}, [isReady, otpValid, queryOtp, queryEmail]);
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
// Clean up URL to remove sensitive params for security. Separate effect so it
|
|
72
|
+
// does not depend on `onOTPDetected` and cannot re-trigger the submission.
|
|
73
|
+
// Use window.location.pathname to preserve the locale prefix (e.g. /ru/auth)
|
|
74
|
+
// because usePathname() strips the locale, and router.push('/auth') would cause
|
|
75
|
+
// next-intl middleware to redirect back to /ru/auth — triggering a full page reload
|
|
76
|
+
// that loses the in-flight OTP submission.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!isReady || !cleanupUrl || !queryOtp) return;
|
|
79
|
+
const cleanQuery = Object.fromEntries(queryParams.entries());
|
|
80
|
+
delete cleanQuery.otp;
|
|
81
|
+
delete cleanQuery.email;
|
|
82
|
+
const queryString = new URLSearchParams(cleanQuery).toString();
|
|
83
|
+
const realPathname = typeof window !== 'undefined' ? window.location.pathname : pathname;
|
|
84
|
+
router.replace(queryString ? `${realPathname}?${queryString}` : realPathname);
|
|
85
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
86
|
+
}, [isReady, cleanupUrl, queryOtp]);
|
|
78
87
|
|
|
79
88
|
return {
|
|
80
89
|
isReady,
|
|
@@ -52,19 +52,33 @@ export function useQueryParams(): URLSearchParams {
|
|
|
52
52
|
}
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
-
//
|
|
55
|
+
// Sync on mount / pathname change (Next.js navigation).
|
|
56
56
|
updateQueryParams();
|
|
57
57
|
|
|
58
|
-
//
|
|
58
|
+
// back/forward navigation fires popstate natively.
|
|
59
59
|
window.addEventListener('popstate', updateQueryParams);
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
61
|
+
// Next.js router.push/replace updates the URL via history.pushState/replaceState,
|
|
62
|
+
// which do NOT emit any event. Patch them to notify us, so we react to query-only
|
|
63
|
+
// changes (same pathname) without polling. This replaces the old setInterval(100ms),
|
|
64
|
+
// whose churning object reference helped drive the OTP auto-submit loop.
|
|
65
|
+
const origPush = window.history.pushState;
|
|
66
|
+
const origReplace = window.history.replaceState;
|
|
67
|
+
window.history.pushState = function (...args) {
|
|
68
|
+
const result = origPush.apply(this, args);
|
|
69
|
+
updateQueryParams();
|
|
70
|
+
return result;
|
|
71
|
+
};
|
|
72
|
+
window.history.replaceState = function (...args) {
|
|
73
|
+
const result = origReplace.apply(this, args);
|
|
74
|
+
updateQueryParams();
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
64
77
|
|
|
65
78
|
return () => {
|
|
66
79
|
window.removeEventListener('popstate', updateQueryParams);
|
|
67
|
-
|
|
80
|
+
window.history.pushState = origPush;
|
|
81
|
+
window.history.replaceState = origReplace;
|
|
68
82
|
};
|
|
69
83
|
}, [pathname]);
|
|
70
84
|
|