@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.428",
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.428",
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
- useAutoAuth({
300
- allowedPaths: [authPath],
301
- onOTPDetected: (detectedOtp: string, detectedEmail?: string) => {
302
- if (isAutoSubmitFromUrlRef.current || isLoading) return;
303
- isAutoSubmitFromUrlRef.current = true;
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
- authLogger.info('OTP detected from URL, auto-submitting');
321
+ processedOtpKeyRef.current = otpKey;
322
+ isAutoSubmitFromUrlRef.current = true;
306
323
 
307
- // Prefer the email from the magic-link URL (works on a fresh browser/device),
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
- if (!autoIdentifier) {
312
- authLogger.warn('No identifier found for auto-submit (no email in URL or storage)');
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
- // Update UI state
318
- setIdentifier(autoIdentifier);
319
- setOtp(detectedOtp);
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 hasOTP = !!(queryParams.get('otp'));
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
- useEffect(() => {
52
- if (!isReady) return;
53
-
54
- const queryOtp = queryParams.get('otp') as string;
55
- const queryEmail = queryParams.get('email') || undefined;
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
- // Handle OTP detection - only on allowed paths to avoid conflicts with other pages
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
- if (queryOtp && typeof queryOtp === 'string' && queryOtp.length === AUTH_CONSTANTS.EMAIL_OTP_LENGTH) {
60
- authLogger.info('OTP detected in URL on auth page:', queryOtp);
61
- onOTPDetected?.(queryOtp, queryEmail);
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
- // Clean up URL to remove sensitive params for security
65
- // Use window.location.pathname to preserve the locale prefix (e.g. /ru/auth)
66
- // because usePathname() strips the locale, and router.push('/auth') would cause
67
- // next-intl middleware to redirect back to /ru/auth triggering a full page reload
68
- // that loses the in-flight OTP submission.
69
- if (cleanupUrl && queryOtp) {
70
- const cleanQuery = Object.fromEntries(queryParams.entries());
71
- delete cleanQuery.otp;
72
- delete cleanQuery.email;
73
- const queryString = new URLSearchParams(cleanQuery).toString();
74
- const realPathname = typeof window !== 'undefined' ? window.location.pathname : pathname;
75
- router.replace(queryString ? `${realPathname}?${queryString}` : realPathname);
76
- }
77
- }, [pathname, queryParams, onOTPDetected, cleanupUrl, router, isReady]);
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
- // Update when pathname changes (Next.js navigation)
55
+ // Sync on mount / pathname change (Next.js navigation).
56
56
  updateQueryParams();
57
57
 
58
- // Listen to popstate (back/forward navigation)
58
+ // back/forward navigation fires popstate natively.
59
59
  window.addEventListener('popstate', updateQueryParams);
60
60
 
61
- // Poll for query param changes (for router.push with same pathname)
62
- // This handles cases where Next.js router.push updates query params without changing pathname
63
- const intervalId = setInterval(updateQueryParams, 100);
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
- clearInterval(intervalId);
80
+ window.history.pushState = origPush;
81
+ window.history.replaceState = origReplace;
68
82
  };
69
83
  }, [pathname]);
70
84