@djangocfg/api 2.1.580 → 2.1.581

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.
@@ -863,6 +863,14 @@ type OtpRequestResponse = {
863
863
  * Webmail deep-link for the recipient's provider, or null if the domain is unknown.
864
864
  */
865
865
  webmail?: WebmailLink | null;
866
+ /**
867
+ * Seconds until a new code may be requested for this email.
868
+ */
869
+ resend_in?: number;
870
+ /**
871
+ * Seconds the code stays valid.
872
+ */
873
+ expires_in?: number;
866
874
  };
867
875
  /**
868
876
  * Serializer for OTP verification.
package/dist/clients.d.ts CHANGED
@@ -863,6 +863,14 @@ type OtpRequestResponse = {
863
863
  * Webmail deep-link for the recipient's provider, or null if the domain is unknown.
864
864
  */
865
865
  webmail?: WebmailLink | null;
866
+ /**
867
+ * Seconds until a new code may be requested for this email.
868
+ */
869
+ resend_in?: number;
870
+ /**
871
+ * Seconds the code stays valid.
872
+ */
873
+ expires_in?: number;
866
874
  };
867
875
  /**
868
876
  * Serializer for OTP verification.
package/dist/index.d.cts CHANGED
@@ -1009,6 +1009,14 @@ type OtpRequestResponse = {
1009
1009
  * Webmail deep-link for the recipient's provider, or null if the domain is unknown.
1010
1010
  */
1011
1011
  webmail?: WebmailLink | null;
1012
+ /**
1013
+ * Seconds until a new code may be requested for this email.
1014
+ */
1015
+ resend_in?: number;
1016
+ /**
1017
+ * Seconds the code stays valid.
1018
+ */
1019
+ expires_in?: number;
1012
1020
  };
1013
1021
  /**
1014
1022
  * Serializer for OTP verification.
package/dist/index.d.ts CHANGED
@@ -1009,6 +1009,14 @@ type OtpRequestResponse = {
1009
1009
  * Webmail deep-link for the recipient's provider, or null if the domain is unknown.
1010
1010
  */
1011
1011
  webmail?: WebmailLink | null;
1012
+ /**
1013
+ * Seconds until a new code may be requested for this email.
1014
+ */
1015
+ resend_in?: number;
1016
+ /**
1017
+ * Seconds the code stays valid.
1018
+ */
1019
+ expires_in?: number;
1012
1020
  };
1013
1021
  /**
1014
1022
  * Serializer for OTP verification.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.580",
3
+ "version": "2.1.581",
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",
@@ -85,7 +85,7 @@
85
85
  "zod": "^4.3.6"
86
86
  },
87
87
  "devDependencies": {
88
- "@djangocfg/typescript-config": "^2.1.580",
88
+ "@djangocfg/typescript-config": "^2.1.581",
89
89
  "@testing-library/react": "^16.3.2",
90
90
  "@types/node": "^25.9.5",
91
91
  "@types/react": "19.2.15",
@@ -1730,6 +1730,16 @@
1730
1730
  }
1731
1731
  ],
1732
1732
  "description": "Webmail deep-link for the recipient's provider, or null if the domain is unknown."
1733
+ },
1734
+ "resend_in": {
1735
+ "type": "integer",
1736
+ "minimum": 0,
1737
+ "description": "Seconds until a new code may be requested for this email."
1738
+ },
1739
+ "expires_in": {
1740
+ "type": "integer",
1741
+ "minimum": 0,
1742
+ "description": "Seconds the code stays valid."
1733
1743
  }
1734
1744
  },
1735
1745
  "required": [
@@ -8,6 +8,8 @@ import { WebmailLinkSchema } from "./WebmailLink";
8
8
  export const OTPRequestResponseSchema = z.object({
9
9
  message: z.string(),
10
10
  webmail: WebmailLinkSchema.nullable().optional(),
11
+ resend_in: z.number().int().min(0).optional(),
12
+ expires_in: z.number().int().min(0).optional(),
11
13
  });
12
14
 
13
15
  export type OTPRequestResponse = z.infer<typeof OTPRequestResponseSchema>;
@@ -2365,6 +2365,16 @@
2365
2365
  }
2366
2366
  ],
2367
2367
  "description": "Webmail deep-link for the recipient's provider, or null if the domain is unknown."
2368
+ },
2369
+ "resend_in": {
2370
+ "type": "integer",
2371
+ "minimum": 0,
2372
+ "description": "Seconds until a new code may be requested for this email."
2373
+ },
2374
+ "expires_in": {
2375
+ "type": "integer",
2376
+ "minimum": 0,
2377
+ "description": "Seconds the code stays valid."
2368
2378
  }
2369
2379
  },
2370
2380
  "required": [
@@ -522,6 +522,14 @@ export type OtpRequestResponse = {
522
522
  * Webmail deep-link for the recipient's provider, or null if the domain is unknown.
523
523
  */
524
524
  webmail?: WebmailLink | null;
525
+ /**
526
+ * Seconds until a new code may be requested for this email.
527
+ */
528
+ resend_in?: number;
529
+ /**
530
+ * Seconds the code stays valid.
531
+ */
532
+ expires_in?: number;
525
533
  };
526
534
 
527
535
  /**
@@ -0,0 +1,37 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { afterEach, describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ beginTwoFactorChallenge,
7
+ endTwoFactorChallenge,
8
+ useTwoFactorChallenge,
9
+ } from '../hooks/useTwoFactorChallenge';
10
+
11
+ describe('twoFactorChallenge', () => {
12
+ afterEach(() => act(() => endTwoFactorChallenge()));
13
+
14
+ it('starts with no challenge', () => {
15
+ const { result } = renderHook(() => useTwoFactorChallenge());
16
+ expect(result.current).toBeNull();
17
+ });
18
+
19
+ it('publishes a begun challenge to every subscriber', () => {
20
+ const a = renderHook(() => useTwoFactorChallenge());
21
+ const b = renderHook(() => useTwoFactorChallenge());
22
+
23
+ act(() => beginTwoFactorChallenge({ sessionId: 's-1' }));
24
+
25
+ expect(a.result.current).toEqual({ sessionId: 's-1' });
26
+ expect(b.result.current).toEqual({ sessionId: 's-1' });
27
+ });
28
+
29
+ it('clears the challenge when it ends', () => {
30
+ const { result } = renderHook(() => useTwoFactorChallenge());
31
+
32
+ act(() => beginTwoFactorChallenge({ sessionId: 's-1' }));
33
+ act(() => endTwoFactorChallenge());
34
+
35
+ expect(result.current).toBeNull();
36
+ });
37
+ });
@@ -10,6 +10,8 @@ export const AUTH_CONSTANTS = {
10
10
  TOTP_LENGTH: 6,
11
11
  /** Backup code max length. */
12
12
  BACKUP_CODE_MAX_LENGTH: 12,
13
+ /** Resend cooldown (s) when the server does not state one. Mirrors the backend per-email cooldown. */
14
+ RESEND_COOLDOWN_SECONDS: 30,
13
15
  } as const;
14
16
 
15
17
  // Route conventions are NOT exported here on purpose. The '/auth' and '/'
@@ -257,6 +257,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
257
257
  success: true,
258
258
  message: result.message || 'OTP code sent to your email address',
259
259
  webmail: result.webmail ?? null,
260
+ resendIn: result.resend_in,
260
261
  };
261
262
  } catch (error) {
262
263
  logAuthFailure(authLogger, 'Request OTP failed:', error);
@@ -38,6 +38,14 @@ export {
38
38
  type TwoFactorDevice,
39
39
  } from './useTwoFactor';
40
40
 
41
+ // 2FA challenge handoff between the sign-in step and the challenge dialog
42
+ export {
43
+ beginTwoFactorChallenge,
44
+ endTwoFactorChallenge,
45
+ useTwoFactorChallenge,
46
+ type TwoFactorChallenge,
47
+ } from './useTwoFactorChallenge';
48
+
41
49
  // Auth guards and redirects
42
50
  export {
43
51
  consumeSavedRedirect,
@@ -8,7 +8,7 @@ import { authLogger } from '../utils/logger';
8
8
  import { useAuthFormState } from './useAuthFormState';
9
9
  import { useAuthValidation } from './useAuthValidation';
10
10
  import { useAutoAuth } from './useAutoAuth';
11
- import { useTwoFactorVerify } from './useTwoFactorVerify';
11
+ import { beginTwoFactorChallenge } from './useTwoFactorChallenge';
12
12
 
13
13
  import type { AuthFormReturn, UseAuthFormOptions } from '../types';
14
14
  /**
@@ -56,38 +56,17 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
56
56
  acceptedTerms,
57
57
  marketingConsent,
58
58
  rememberMe,
59
- twoFactorSessionId,
60
- twoFactorCode,
61
- useBackupCode,
62
59
  setIdentifier,
63
60
  setOtp,
64
61
  setStep,
65
62
  setError,
66
63
  setIsLoading,
67
64
  clearError,
68
- setTwoFactorSessionId,
69
- setShouldPrompt2FA,
70
- setTwoFactorCode,
71
- setUseBackupCode,
72
65
  startRateLimitCountdown,
66
+ startResendCountdown,
73
67
  setWebmail,
74
68
  } = formState;
75
69
 
76
- // 2FA verification hook - skip redirect so we can show success screen
77
- const twoFactor = useTwoFactorVerify({
78
- onSuccess: () => {
79
- authLogger.info('2FA verification successful, showing success screen');
80
- setStep('success');
81
- onOTPSuccess?.();
82
- },
83
- onError: (error) => {
84
- setError(error);
85
- onError?.(error);
86
- },
87
- redirectUrl,
88
- skipRedirect: true, // We handle navigation via success step
89
- });
90
-
91
70
  const { validateIdentifier } = validation;
92
71
 
93
72
  // ─────────────────────────────────────────────────────────────────────────
@@ -139,8 +118,17 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
139
118
  return;
140
119
  }
141
120
 
142
- setIsLoading(true);
143
121
  clearError();
122
+ setOtp('');
123
+ setWebmail(null);
124
+ // Optimistic: the code screen opens now; a failed request rolls back to the identifier step.
125
+ setStep('otp');
126
+
127
+ const rollBack = (msg: string) => {
128
+ setStep('identifier');
129
+ setError(msg);
130
+ onError?.(msg);
131
+ };
144
132
 
145
133
  try {
146
134
  // Only apps that opted in send consent fields — absent option ⇒ body unchanged.
@@ -152,30 +140,23 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
152
140
  if (result.success) {
153
141
  saveIdentifierToStorage(identifier);
154
142
  setWebmail(result.webmail ?? null);
155
- setStep('otp');
143
+ startResendCountdown(result.resendIn ?? AUTH_CONSTANTS.RESEND_COOLDOWN_SECONDS);
156
144
  onIdentifierSuccess?.(identifier);
145
+ } else if (result.retryAfter) {
146
+ setStep('identifier');
147
+ startRateLimitCountdown(result.retryAfter);
157
148
  } else {
158
- if (result.retryAfter) {
159
- startRateLimitCountdown(result.retryAfter);
160
- clearError();
161
- } else {
162
- setError(result.message);
163
- onError?.(result.message);
164
- }
149
+ rollBack(result.message);
165
150
  }
166
151
  } catch {
167
- const msg = 'An unexpected error occurred';
168
- setError(msg);
169
- onError?.(msg);
170
- } finally {
171
- setIsLoading(false);
152
+ rollBack('An unexpected error occurred');
172
153
  }
173
154
  }, [
174
155
  identifier, acceptedTerms, requireTermsAcceptance,
175
156
  marketingConsent, marketingConsentOption,
176
157
  validateIdentifier, requestOTP, saveIdentifierToStorage,
177
- setError, setIsLoading, setStep, clearError, setWebmail,
178
- startRateLimitCountdown, onIdentifierSuccess, onError, sourceUrl,
158
+ setError, setOtp, setStep, clearError, setWebmail,
159
+ startRateLimitCountdown, startResendCountdown, onIdentifierSuccess, onError, sourceUrl,
179
160
  ]);
180
161
 
181
162
  // Core OTP submit - accepts explicit values to avoid stale closures
@@ -197,12 +178,9 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
197
178
  // Skip redirect - we'll show success screen first
198
179
  const result = await verifyOTP(submitIdentifier, submitOtp, sourceUrl, redirectUrl, true, rememberMe);
199
180
 
200
- // Check if 2FA is required (user has TOTP device)
201
181
  if (result.requires_2fa && result.session_id) {
202
182
  authLogger.info('2FA required after OTP verification');
203
- setTwoFactorSessionId(result.session_id);
204
- setShouldPrompt2FA(result.should_prompt_2fa || false);
205
- setStep('2fa');
183
+ beginTwoFactorChallenge({ sessionId: result.session_id });
206
184
  saveIdentifierToStorage(submitIdentifier);
207
185
  return true; // OTP was successful, now need 2FA
208
186
  }
@@ -210,9 +188,6 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
210
188
  if (result.success) {
211
189
  saveIdentifierToStorage(submitIdentifier);
212
190
 
213
- // Sign-in only verifies identity. Setting up 2FA is owned by
214
- // ProfileLayout, so we never branch into a setup prompt here — the
215
- // backend's `should_prompt_2fa` hint is intentionally ignored.
216
191
  authLogger.info('OTP verification successful, showing success screen');
217
192
  setStep('success');
218
193
  onOTPSuccess?.();
@@ -230,7 +205,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
230
205
  } finally {
231
206
  setIsLoading(false);
232
207
  }
233
- }, [verifyOTP, saveIdentifierToStorage, setError, setIsLoading, clearError, onOTPSuccess, onError, sourceUrl, redirectUrl, rememberMe, setTwoFactorSessionId, setShouldPrompt2FA, setStep]);
208
+ }, [verifyOTP, saveIdentifierToStorage, setError, setIsLoading, clearError, onOTPSuccess, onError, sourceUrl, redirectUrl, rememberMe, setStep]);
234
209
 
235
210
  const handleOTPSubmit = useCallback(async (e: React.FormEvent) => {
236
211
  e.preventDefault();
@@ -241,8 +216,8 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
241
216
  // Password Submit
242
217
  // ─────────────────────────────────────────────────────────────────────────
243
218
 
244
- // The password credential lands on the SAME step machine as the OTP code:
245
- // both end at '2fa' or 'success', so the layout renders one set of screens.
219
+ // The password credential lands on the same outcomes as the OTP code:
220
+ // a 2FA challenge or 'success'.
246
221
  const submitPassword = useCallback(async (
247
222
  submitIdentifier: string,
248
223
  submitPassword_: string,
@@ -274,9 +249,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
274
249
 
275
250
  if (result.requires_2fa && result.session_id) {
276
251
  authLogger.info('2FA required after password login');
277
- setTwoFactorSessionId(result.session_id);
278
- setShouldPrompt2FA(result.should_prompt_2fa || false);
279
- setStep('2fa');
252
+ beginTwoFactorChallenge({ sessionId: result.session_id });
280
253
  saveIdentifierToStorage(submitIdentifier);
281
254
  return true; // Password was accepted, now need 2FA
282
255
  }
@@ -310,7 +283,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
310
283
  }, [
311
284
  login, validateIdentifier, saveIdentifierToStorage, setError, setIsLoading,
312
285
  clearError, startRateLimitCountdown, onOTPSuccess, onError, redirectUrl,
313
- rememberMe, setTwoFactorSessionId, setShouldPrompt2FA, setStep,
286
+ rememberMe, setStep,
314
287
  ]);
315
288
 
316
289
  const handlePasswordSubmit = useCallback(async (e: React.FormEvent) => {
@@ -333,6 +306,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
333
306
  if (result.success) {
334
307
  saveIdentifierToStorage(identifier);
335
308
  setWebmail(result.webmail ?? null);
309
+ startResendCountdown(result.resendIn ?? AUTH_CONSTANTS.RESEND_COOLDOWN_SECONDS);
336
310
  setOtp('');
337
311
  } else {
338
312
  if (result.retryAfter) {
@@ -350,7 +324,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
350
324
  } finally {
351
325
  setIsLoading(false);
352
326
  }
353
- }, [identifier, marketingConsent, marketingConsentOption, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, setWebmail, onError, sourceUrl]);
327
+ }, [identifier, marketingConsent, marketingConsentOption, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, startResendCountdown, setWebmail, onError, sourceUrl]);
354
328
 
355
329
  const handleBackToIdentifier = useCallback(() => {
356
330
  setStep('identifier');
@@ -362,39 +336,6 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
362
336
  clearError();
363
337
  }, [setStep, clearError]);
364
338
 
365
- // ─────────────────────────────────────────────────────────────────────────
366
- // 2FA Handlers
367
- // ─────────────────────────────────────────────────────────────────────────
368
-
369
- const handle2FASubmit = useCallback(async (e: React.FormEvent) => {
370
- e.preventDefault();
371
-
372
- if (!twoFactorSessionId) {
373
- const msg = 'Missing 2FA session';
374
- setError(msg);
375
- onError?.(msg);
376
- return;
377
- }
378
-
379
- if (useBackupCode) {
380
- await twoFactor.verifyBackupCode(twoFactorSessionId, twoFactorCode);
381
- } else {
382
- await twoFactor.verifyTOTP(twoFactorSessionId, twoFactorCode);
383
- }
384
- }, [twoFactorSessionId, twoFactorCode, useBackupCode, twoFactor, setError, onError]);
385
-
386
- const handleUseBackupCode = useCallback(() => {
387
- setUseBackupCode(true);
388
- setTwoFactorCode('');
389
- clearError();
390
- }, [setUseBackupCode, setTwoFactorCode, clearError]);
391
-
392
- const handleUseTOTP = useCallback(() => {
393
- setUseBackupCode(false);
394
- setTwoFactorCode('');
395
- clearError();
396
- }, [setUseBackupCode, setTwoFactorCode, clearError]);
397
-
398
339
  // ─────────────────────────────────────────────────────────────────────────
399
340
  // Auto-auth from URL
400
341
  // ─────────────────────────────────────────────────────────────────────────
@@ -458,21 +399,11 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
458
399
  handleBackToIdentifier,
459
400
  forceOTPStep,
460
401
 
461
- // 2FA handlers
462
- handle2FASubmit,
463
- handleUseBackupCode,
464
- handleUseTOTP,
465
-
466
402
  // Validation
467
403
  ...validation,
468
404
 
469
405
  // Auto-submit ref
470
406
  isAutoSubmittingFromUrl: isAutoSubmitFromUrlRef,
471
-
472
- // 2FA state from hook (for loading indicator)
473
- is2FALoading: twoFactor.isLoading,
474
- twoFactorWarning: twoFactor.warning,
475
- twoFactorAttemptsRemaining: twoFactor.attemptsRemaining,
476
407
  };
477
408
  };
478
409
 
@@ -10,6 +10,32 @@ const formatCountdown = (s: number): string => {
10
10
  return m > 0 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${s}s`;
11
11
  };
12
12
 
13
+ const useCountdown = () => {
14
+ const [seconds, setSeconds] = useState(0);
15
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
16
+
17
+ const start = useCallback((from: number) => {
18
+ if (timerRef.current) clearInterval(timerRef.current);
19
+ setSeconds(from);
20
+ timerRef.current = setInterval(() => {
21
+ setSeconds((prev) => {
22
+ if (prev <= 1) {
23
+ clearInterval(timerRef.current!);
24
+ timerRef.current = null;
25
+ return 0;
26
+ }
27
+ return prev - 1;
28
+ });
29
+ }, 1000);
30
+ }, []);
31
+
32
+ useEffect(() => () => {
33
+ if (timerRef.current) clearInterval(timerRef.current);
34
+ }, []);
35
+
36
+ return { seconds, start };
37
+ };
38
+
13
39
  export interface UseAuthFormStateReturn extends AuthFormState, AuthFormStateHandlers {}
14
40
 
15
41
  /**
@@ -27,39 +53,14 @@ export const useAuthFormState = (
27
53
  // null until the marketing-consent feature (if enabled) resolves its
28
54
  // jurisdiction-aware default — the layout owns that decision.
29
55
  const [marketingConsent, setMarketingConsent] = useState<boolean | null>(null);
30
- const [rememberMe, setRememberMe] = useState(false);
56
+ // Signed in for 30 days unless an app opts into showing the choice.
57
+ const [rememberMe, setRememberMe] = useState(true);
31
58
  const [step, setStep] = useState<AuthStep>('identifier');
32
59
  const [error, setError] = useState('');
33
60
  const [webmail, setWebmail] = useState<WebmailLink | null>(null);
34
61
 
35
- // 2FA state
36
- const [twoFactorSessionId, setTwoFactorSessionId] = useState<string | null>(null);
37
- const [shouldPrompt2FA, setShouldPrompt2FA] = useState(false);
38
- const [twoFactorCode, setTwoFactorCode] = useState('');
39
- const [useBackupCode, setUseBackupCode] = useState(false);
40
-
41
- // Rate limit countdown
42
- const [rateLimitSeconds, setRateLimitSeconds] = useState(0);
43
- const rateLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
44
-
45
- const startRateLimitCountdown = useCallback((seconds: number) => {
46
- if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
47
- setRateLimitSeconds(seconds);
48
- rateLimitTimerRef.current = setInterval(() => {
49
- setRateLimitSeconds((prev) => {
50
- if (prev <= 1) {
51
- clearInterval(rateLimitTimerRef.current!);
52
- rateLimitTimerRef.current = null;
53
- return 0;
54
- }
55
- return prev - 1;
56
- });
57
- }, 1000);
58
- }, []);
59
-
60
- useEffect(() => () => {
61
- if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
62
- }, []);
62
+ const rateLimit = useCountdown();
63
+ const resend = useCountdown();
63
64
 
64
65
  const clearError = useCallback(() => setError(''), []);
65
66
 
@@ -74,13 +75,11 @@ export const useAuthFormState = (
74
75
  rememberMe,
75
76
  step,
76
77
  error,
77
- twoFactorSessionId,
78
- shouldPrompt2FA,
79
- twoFactorCode,
80
- useBackupCode,
81
- rateLimitSeconds,
82
- isRateLimited: rateLimitSeconds > 0,
83
- rateLimitLabel: formatCountdown(rateLimitSeconds),
78
+ rateLimitSeconds: rateLimit.seconds,
79
+ isRateLimited: rateLimit.seconds > 0,
80
+ rateLimitLabel: formatCountdown(rateLimit.seconds),
81
+ resendSeconds: resend.seconds,
82
+ resendLabel: formatCountdown(resend.seconds),
84
83
  webmail,
85
84
  // Handlers
86
85
  setIdentifier,
@@ -93,11 +92,8 @@ export const useAuthFormState = (
93
92
  clearError,
94
93
  setStep,
95
94
  setIsLoading,
96
- setTwoFactorSessionId,
97
- setShouldPrompt2FA,
98
- setTwoFactorCode,
99
- setUseBackupCode,
100
- startRateLimitCountdown,
95
+ startRateLimitCountdown: rateLimit.start,
96
+ startResendCountdown: resend.start,
101
97
  setWebmail,
102
98
  };
103
99
  };
@@ -0,0 +1,35 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ /** A pending login that needs a TOTP or backup code before tokens are issued. */
6
+ export interface TwoFactorChallenge {
7
+ sessionId: string;
8
+ }
9
+
10
+ // Module-scoped on purpose: one sign-in is in flight per page, and the sign-in
11
+ // form and the challenge dialog share it without either importing the other.
12
+ let current: TwoFactorChallenge | null = null;
13
+ const listeners = new Set<() => void>();
14
+
15
+ const set = (next: TwoFactorChallenge | null): void => {
16
+ current = next;
17
+ for (const listener of [...listeners]) listener();
18
+ };
19
+
20
+ const subscribe = (listener: () => void): (() => void) => {
21
+ listeners.add(listener);
22
+ return () => {
23
+ listeners.delete(listener);
24
+ };
25
+ };
26
+
27
+ /** Called by whichever step received `requires_2fa` (OTP, password, OAuth callback). */
28
+ export const beginTwoFactorChallenge = (challenge: TwoFactorChallenge): void => set(challenge);
29
+
30
+ /** Called when the challenge is verified or abandoned. */
31
+ export const endTwoFactorChallenge = (): void => set(null);
32
+
33
+ /** The pending challenge, or null. */
34
+ export const useTwoFactorChallenge = (): TwoFactorChallenge | null =>
35
+ useSyncExternalStore(subscribe, () => current, () => null);