@djangocfg/api 2.1.57 → 2.1.59

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 (75) hide show
  1. package/README.md +125 -9
  2. package/dist/auth.cjs +1865 -402
  3. package/dist/auth.cjs.map +1 -1
  4. package/dist/auth.d.cts +352 -76
  5. package/dist/auth.d.ts +352 -76
  6. package/dist/auth.mjs +1867 -404
  7. package/dist/auth.mjs.map +1 -1
  8. package/dist/clients.cjs +1637 -137
  9. package/dist/clients.cjs.map +1 -1
  10. package/dist/clients.d.cts +1394 -282
  11. package/dist/clients.d.ts +1394 -282
  12. package/dist/clients.mjs +1637 -137
  13. package/dist/clients.mjs.map +1 -1
  14. package/dist/hooks.cjs +24 -11
  15. package/dist/hooks.cjs.map +1 -1
  16. package/dist/hooks.d.cts +88 -21
  17. package/dist/hooks.d.ts +88 -21
  18. package/dist/hooks.mjs +24 -11
  19. package/dist/hooks.mjs.map +1 -1
  20. package/dist/index.cjs +38 -17
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +94 -21
  23. package/dist/index.d.ts +94 -21
  24. package/dist/index.mjs +38 -17
  25. package/dist/index.mjs.map +1 -1
  26. package/package.json +3 -3
  27. package/src/auth/context/AccountsContext.tsx +8 -1
  28. package/src/auth/context/AuthContext.tsx +31 -8
  29. package/src/auth/context/types.ts +8 -1
  30. package/src/auth/hooks/index.ts +29 -5
  31. package/src/auth/hooks/useAuthForm.ts +292 -226
  32. package/src/auth/hooks/useAuthFormState.ts +60 -0
  33. package/src/auth/hooks/useAuthValidation.ts +77 -0
  34. package/src/auth/hooks/useGithubAuth.ts +26 -5
  35. package/src/auth/hooks/useTwoFactor.ts +239 -0
  36. package/src/auth/hooks/useTwoFactorSetup.ts +213 -0
  37. package/src/auth/index.ts +3 -0
  38. package/src/auth/types/form.ts +194 -0
  39. package/src/auth/types/index.ts +28 -0
  40. package/src/clients.ts +10 -0
  41. package/src/generated/cfg_accounts/_utils/schemas/OAuthTokenResponse.schema.ts +26 -3
  42. package/src/generated/cfg_accounts/_utils/schemas/OTPVerifyResponse.schema.ts +26 -3
  43. package/src/generated/cfg_accounts/accounts/client.ts +4 -1
  44. package/src/generated/cfg_accounts/accounts/models.ts +15 -6
  45. package/src/generated/cfg_accounts/accounts__oauth/models.ts +16 -7
  46. package/src/generated/cfg_accounts/client.ts +5 -2
  47. package/src/generated/cfg_accounts/http.ts +8 -2
  48. package/src/generated/cfg_accounts/schema.json +47 -19
  49. package/src/generated/cfg_centrifugo/client.ts +5 -2
  50. package/src/generated/cfg_centrifugo/http.ts +8 -2
  51. package/src/generated/cfg_totp/CLAUDE.md +12 -12
  52. package/src/generated/cfg_totp/_utils/fetchers/index.ts +3 -3
  53. package/src/generated/cfg_totp/_utils/fetchers/{totp__2fa_management.ts → totp__totp_management.ts} +3 -3
  54. package/src/generated/cfg_totp/_utils/fetchers/{totp__2fa_setup.ts → totp__totp_setup.ts} +3 -3
  55. package/src/generated/cfg_totp/_utils/fetchers/{totp__2fa_verification.ts → totp__totp_verification.ts} +3 -3
  56. package/src/generated/cfg_totp/_utils/hooks/index.ts +3 -3
  57. package/src/generated/cfg_totp/_utils/hooks/{totp__2fa_management.ts → totp__totp_management.ts} +2 -2
  58. package/src/generated/cfg_totp/_utils/hooks/{totp__2fa_setup.ts → totp__totp_setup.ts} +2 -2
  59. package/src/generated/cfg_totp/_utils/hooks/{totp__2fa_verification.ts → totp__totp_verification.ts} +2 -2
  60. package/src/generated/cfg_totp/_utils/schemas/DeviceList.schema.ts +1 -1
  61. package/src/generated/cfg_totp/client.ts +14 -11
  62. package/src/generated/cfg_totp/http.ts +8 -2
  63. package/src/generated/cfg_totp/index.ts +16 -16
  64. package/src/generated/cfg_totp/schema.json +8 -7
  65. package/src/generated/cfg_totp/{totp__2fa_management → totp__totp_management}/client.ts +2 -2
  66. package/src/generated/cfg_totp/{totp__2fa_management → totp__totp_management}/models.ts +1 -1
  67. package/src/generated/cfg_totp/{totp__2fa_setup → totp__totp_setup}/client.ts +4 -4
  68. package/src/generated/cfg_totp/{totp__2fa_verification → totp__totp_verification}/client.ts +2 -2
  69. package/src/generated/cfg_webpush/client.ts +5 -2
  70. package/src/generated/cfg_webpush/http.ts +8 -2
  71. /package/src/generated/cfg_totp/{totp__2fa_management → totp__totp_management}/index.ts +0 -0
  72. /package/src/generated/cfg_totp/{totp__2fa_setup → totp__totp_setup}/index.ts +0 -0
  73. /package/src/generated/cfg_totp/{totp__2fa_setup → totp__totp_setup}/models.ts +0 -0
  74. /package/src/generated/cfg_totp/{totp__2fa_verification → totp__totp_verification}/index.ts +0 -0
  75. /package/src/generated/cfg_totp/{totp__2fa_verification → totp__totp_verification}/models.ts +0 -0
@@ -1,339 +1,405 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useEffect, useState } from 'react';
3
+ import { useCallback, useEffect, useRef } from 'react';
4
4
 
5
5
  import { useAuth } from '../context';
6
+ import type { AuthChannel, AuthFormReturn, UseAuthFormOptions } from '../types';
6
7
  import { authLogger } from '../utils/logger';
8
+ import { useAuthFormState } from './useAuthFormState';
9
+ import { useAuthValidation } from './useAuthValidation';
7
10
  import { useAutoAuth } from './useAutoAuth';
8
- import { useLocalStorage } from './useLocalStorage';
9
-
10
- export interface AuthFormState {
11
- identifier: string; // Email or phone number
12
- channel: 'email' | 'phone';
13
- otp: string;
14
- isLoading: boolean;
15
- acceptedTerms: boolean;
16
- step: 'identifier' | 'otp';
17
- error: string;
18
- }
19
-
20
- export interface AuthFormHandlers {
21
- setIdentifier: (identifier: string) => void;
22
- setChannel: (channel: 'email' | 'phone') => void;
23
- setOtp: (otp: string) => void;
24
- setAcceptedTerms: (accepted: boolean) => void;
25
- setError: (error: string) => void;
26
- clearError: () => void;
27
- handleIdentifierSubmit: (e: React.FormEvent) => Promise<void>;
28
- handleOTPSubmit: (e: React.FormEvent) => Promise<void>;
29
- handleResendOTP: () => Promise<void>;
30
- handleBackToIdentifier: () => void;
31
- forceOTPStep: () => void;
32
- // Utility methods
33
- detectChannelFromIdentifier: (identifier: string) => 'email' | 'phone' | null;
34
- validateIdentifier: (identifier: string, channel?: 'email' | 'phone') => boolean;
35
- }
36
-
37
- export interface UseAuthFormOptions {
38
- onIdentifierSuccess?: (identifier: string, channel: 'email' | 'phone') => void;
39
- onOTPSuccess?: () => void;
40
- onError?: (message: string) => void;
41
- sourceUrl: string;
42
- /** URL to redirect after successful OTP verification */
43
- redirectUrl?: string;
44
- /** If true, user must accept terms before submitting. Default: false */
45
- requireTermsAcceptance?: boolean;
46
- /** Path to auth page for auto-OTP detection. Default: '/auth' */
47
- authPath?: string;
48
- }
49
-
50
- export const useAuthForm = (options: UseAuthFormOptions): AuthFormState & AuthFormHandlers => {
51
- const { onIdentifierSuccess, onOTPSuccess, onError, sourceUrl, redirectUrl, requireTermsAcceptance = false, authPath = '/auth' } = options;
52
-
53
- // Form state
54
- const [identifier, setIdentifier] = useState('');
55
- const [channel, setChannel] = useState<'email' | 'phone'>('email');
56
- const [otp, setOtp] = useState('');
57
- const [isLoading, setIsLoading] = useState(false);
58
- const [acceptedTerms, setAcceptedTerms] = useState(false);
59
- const [step, setStep] = useState<'identifier' | 'otp'>('identifier');
60
- const [error, setError] = useState('');
61
-
62
-
63
-
64
- // Auth hooks
65
- const { requestOTP, verifyOTP, getSavedEmail, saveEmail, getSavedPhone, savePhone } = useAuth();
66
- const [savedTermsAccepted, setSavedTermsAccepted] = useLocalStorage('auth_terms_accepted', false);
67
- const [savedEmail, setSavedEmail] = useLocalStorage('auth_email', '');
68
- const [savedPhone, setSavedPhone] = useLocalStorage('auth_phone', '');
69
-
70
- // Utility functions
71
- const detectChannelFromIdentifier = useCallback((identifier: string): 'email' | 'phone' | null => {
72
- if (!identifier) return null;
73
-
74
- // Email detection
75
- if (identifier.includes('@')) {
76
- return 'email';
77
- }
78
-
79
- // Phone detection (starts with + and contains digits)
80
- if (identifier.startsWith('+') && /^\+[1-9]\d{6,14}$/.test(identifier)) {
81
- return 'phone';
82
- }
83
-
84
- return null;
85
- }, []);
86
-
87
- const validateIdentifier = useCallback((identifier: string, channelType?: 'email' | 'phone'): boolean => {
88
- if (!identifier) return false;
89
-
90
- const detectedChannel = channelType || detectChannelFromIdentifier(identifier);
91
-
92
- if (detectedChannel === 'email') {
93
- // Basic email validation
94
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
95
- } else if (detectedChannel === 'phone') {
96
- // E.164 phone validation
97
- return /^\+[1-9]\d{6,14}$/.test(identifier);
11
+ import { useTwoFactor } from './useTwoFactor';
12
+
13
+ /**
14
+ * Complete auth form hook.
15
+ * Composes smaller hooks for state, validation, and submission.
16
+ */
17
+ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
18
+ const {
19
+ onIdentifierSuccess,
20
+ onOTPSuccess,
21
+ onError,
22
+ sourceUrl,
23
+ redirectUrl,
24
+ requireTermsAcceptance = false,
25
+ authPath = '/auth',
26
+ } = options;
27
+
28
+ // Compose smaller hooks
29
+ const formState = useAuthFormState();
30
+ const validation = useAuthValidation();
31
+
32
+ // Ref to track auto-submit from URL
33
+ const isAutoSubmitFromUrlRef = useRef(false);
34
+
35
+ // Auth context for API calls and storage
36
+ const {
37
+ requestOTP,
38
+ verifyOTP,
39
+ getSavedEmail,
40
+ saveEmail,
41
+ clearSavedEmail,
42
+ getSavedPhone,
43
+ savePhone,
44
+ clearSavedPhone,
45
+ } = useAuth();
46
+
47
+ // Destructure for convenience
48
+ const {
49
+ identifier,
50
+ channel,
51
+ otp,
52
+ isLoading,
53
+ acceptedTerms,
54
+ twoFactorSessionId,
55
+ twoFactorCode,
56
+ useBackupCode,
57
+ setIdentifier,
58
+ setChannel,
59
+ setOtp,
60
+ setStep,
61
+ setError,
62
+ setIsLoading,
63
+ clearError,
64
+ setTwoFactorSessionId,
65
+ setShouldPrompt2FA,
66
+ setTwoFactorCode,
67
+ setUseBackupCode,
68
+ } = formState;
69
+
70
+ // 2FA verification hook - skip redirect so we can show success screen
71
+ const twoFactor = useTwoFactor({
72
+ onSuccess: () => {
73
+ authLogger.info('2FA verification successful, showing success screen');
74
+ setStep('success');
75
+ onOTPSuccess?.();
76
+ },
77
+ onError: (error) => {
78
+ setError(error);
79
+ onError?.(error);
80
+ },
81
+ redirectUrl,
82
+ skipRedirect: true, // We handle navigation via success step
83
+ });
84
+
85
+ const { detectChannelFromIdentifier, validateIdentifier } = validation;
86
+
87
+ // ─────────────────────────────────────────────────────────────────────────
88
+ // Storage Helper
89
+ // ─────────────────────────────────────────────────────────────────────────
90
+
91
+ const saveIdentifierToStorage = useCallback((id: string, ch: AuthChannel) => {
92
+ if (ch === 'email') {
93
+ saveEmail(id);
94
+ clearSavedPhone();
95
+ } else {
96
+ savePhone(id);
97
+ clearSavedEmail();
98
98
  }
99
-
100
- return false;
101
- }, [detectChannelFromIdentifier]);
99
+ }, [saveEmail, savePhone, clearSavedEmail, clearSavedPhone]);
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────
102
+ // Effects
103
+ // ─────────────────────────────────────────────────────────────────────────
102
104
 
103
- // Load saved data on mount
105
+ // Load saved identifier on mount
104
106
  useEffect(() => {
105
- const authSavedEmail = getSavedEmail();
106
- const authSavedPhone = getSavedPhone();
107
-
108
- // Prioritize phone over email if both exist
109
- if (authSavedPhone) {
110
- setIdentifier(authSavedPhone);
107
+ const savedPhone = getSavedPhone();
108
+ const savedEmail = getSavedEmail();
109
+
110
+ // Prioritize phone over email
111
+ if (savedPhone) {
112
+ setIdentifier(savedPhone);
111
113
  setChannel('phone');
112
- } else if (authSavedEmail) {
113
- setIdentifier(authSavedEmail);
114
+ } else if (savedEmail) {
115
+ setIdentifier(savedEmail);
114
116
  setChannel('email');
115
117
  }
116
-
117
- if (savedTermsAccepted) {
118
- setAcceptedTerms(savedTermsAccepted);
119
- }
120
- }, [getSavedEmail, getSavedPhone, savedTermsAccepted]);
118
+ }, [getSavedEmail, getSavedPhone, setIdentifier, setChannel]);
121
119
 
122
120
  // Auto-detect channel when identifier changes
123
121
  useEffect(() => {
124
122
  if (identifier) {
125
- const detectedChannel = detectChannelFromIdentifier(identifier);
126
- if (detectedChannel && detectedChannel !== channel) {
127
- setChannel(detectedChannel);
123
+ const detected = detectChannelFromIdentifier(identifier);
124
+ if (detected && detected !== channel) {
125
+ setChannel(detected);
128
126
  }
129
127
  }
130
- }, [identifier, channel, detectChannelFromIdentifier]);
131
-
128
+ }, [identifier, channel, detectChannelFromIdentifier, setChannel]);
132
129
 
133
-
134
- const clearError = useCallback(() => setError(''), []);
130
+ // ─────────────────────────────────────────────────────────────────────────
131
+ // Submit Handlers
132
+ // ─────────────────────────────────────────────────────────────────────────
135
133
 
136
134
  const handleIdentifierSubmit = useCallback(async (e: React.FormEvent) => {
137
135
  e.preventDefault();
138
-
136
+
139
137
  if (!identifier) {
140
- const message = channel === 'phone' ? 'Please enter your phone number' : 'Please enter your email address';
141
- setError(message);
142
- onError?.(message);
138
+ const msg = channel === 'phone'
139
+ ? 'Please enter your phone number'
140
+ : 'Please enter your email address';
141
+ setError(msg);
142
+ onError?.(msg);
143
143
  return;
144
144
  }
145
145
 
146
- // Validate identifier format
147
146
  if (!validateIdentifier(identifier, channel)) {
148
- const message = channel === 'phone'
149
- ? 'Please enter a valid phone number (e.g., +1234567890)'
147
+ const msg = channel === 'phone'
148
+ ? 'Please enter a valid phone number (e.g., +1234567890)'
150
149
  : 'Please enter a valid email address';
151
- setError(message);
152
- onError?.(message);
150
+ setError(msg);
151
+ onError?.(msg);
153
152
  return;
154
153
  }
155
154
 
156
155
  if (requireTermsAcceptance && !acceptedTerms) {
157
- const message = 'Please accept the Terms of Service and Privacy Policy';
158
- setError(message);
159
- onError?.(message);
156
+ const msg = 'Please accept the Terms of Service and Privacy Policy';
157
+ setError(msg);
158
+ onError?.(msg);
160
159
  return;
161
160
  }
162
161
 
163
162
  setIsLoading(true);
164
163
  clearError();
165
-
164
+
166
165
  try {
167
166
  const result = await requestOTP(identifier, channel, sourceUrl);
168
-
167
+
169
168
  if (result.success) {
170
- // Save identifier and terms acceptance on successful request, clear opposite channel
171
- if (channel === 'email') {
172
- saveEmail(identifier);
173
- setSavedPhone(''); // Clear phone storage
174
- } else if (channel === 'phone') {
175
- savePhone(identifier);
176
- setSavedEmail(''); // Clear email storage
177
- }
178
- setSavedTermsAccepted(true);
169
+ saveIdentifierToStorage(identifier, channel);
179
170
  setStep('otp');
180
171
  onIdentifierSuccess?.(identifier, channel);
181
172
  } else {
182
173
  setError(result.message);
183
174
  onError?.(result.message);
184
175
  }
185
- } catch (error) {
186
- const message = 'An unexpected error occurred';
187
- setError(message);
188
- onError?.(message);
176
+ } catch {
177
+ const msg = 'An unexpected error occurred';
178
+ setError(msg);
179
+ onError?.(msg);
189
180
  } finally {
190
181
  setIsLoading(false);
191
182
  }
192
- }, [identifier, channel, acceptedTerms, validateIdentifier, requestOTP, saveEmail, clearError, setSavedTermsAccepted, onIdentifierSuccess, onError, sourceUrl]);
193
-
194
- const handleOTPSubmit = useCallback(async (e: React.FormEvent) => {
195
- e.preventDefault();
196
-
197
- if (!otp || otp.length < 6) {
198
- const message = 'Please enter the 6-digit verification code';
199
- setError(message);
200
- onError?.(message);
201
- return;
183
+ }, [
184
+ identifier, channel, acceptedTerms, requireTermsAcceptance,
185
+ validateIdentifier, requestOTP, saveIdentifierToStorage,
186
+ setError, setIsLoading, setStep, clearError,
187
+ onIdentifierSuccess, onError, sourceUrl,
188
+ ]);
189
+
190
+ // Core OTP submit - accepts explicit values to avoid stale closures
191
+ const submitOTP = useCallback(async (
192
+ submitIdentifier: string,
193
+ submitOtp: string,
194
+ submitChannel: AuthChannel
195
+ ): Promise<boolean> => {
196
+ if (!submitOtp || submitOtp.length < 6) {
197
+ const msg = 'Please enter the 6-digit verification code';
198
+ setError(msg);
199
+ onError?.(msg);
200
+ return false;
202
201
  }
203
202
 
204
203
  setIsLoading(true);
205
204
  clearError();
206
205
 
207
206
  try {
208
- const result = await verifyOTP(identifier, otp, channel, sourceUrl, redirectUrl);
207
+ // Skip redirect - we'll show success screen first
208
+ const result = await verifyOTP(submitIdentifier, submitOtp, submitChannel, sourceUrl, redirectUrl, true);
209
+
210
+ // Check if 2FA is required (user has TOTP device)
211
+ if (result.requires_2fa && result.session_id) {
212
+ authLogger.info('2FA required after OTP verification');
213
+ setTwoFactorSessionId(result.session_id);
214
+ setShouldPrompt2FA(result.should_prompt_2fa || false);
215
+ setStep('2fa');
216
+ saveIdentifierToStorage(submitIdentifier, submitChannel);
217
+ return true; // OTP was successful, now need 2FA
218
+ }
209
219
 
210
220
  if (result.success) {
211
- // Save identifier on successful login, clear opposite channel
212
- if (channel === 'email') {
213
- setSavedEmail(identifier);
214
- setSavedPhone(''); // Clear phone storage
215
- } else if (channel === 'phone') {
216
- setSavedPhone(identifier);
217
- setSavedEmail(''); // Clear email storage
221
+ saveIdentifierToStorage(submitIdentifier, submitChannel);
222
+
223
+ // Check if user should be prompted to set up 2FA
224
+ if (result.should_prompt_2fa) {
225
+ authLogger.info('OTP verification successful, prompting 2FA setup');
226
+ setShouldPrompt2FA(true);
227
+ setStep('2fa-setup');
228
+ onOTPSuccess?.();
229
+ return true;
218
230
  }
231
+
232
+ authLogger.info('OTP verification successful, showing success screen');
233
+ setStep('success');
219
234
  onOTPSuccess?.();
235
+ return true;
220
236
  } else {
221
237
  setError(result.message);
222
238
  onError?.(result.message);
239
+ return false;
223
240
  }
224
- } catch (error) {
225
- const message = 'An unexpected error occurred';
226
- setError(message);
227
- onError?.(message);
241
+ } catch {
242
+ const msg = 'An unexpected error occurred';
243
+ setError(msg);
244
+ onError?.(msg);
245
+ return false;
228
246
  } finally {
229
247
  setIsLoading(false);
230
248
  }
231
- }, [identifier, otp, channel, verifyOTP, clearError, setSavedEmail, onOTPSuccess, onError, sourceUrl, redirectUrl]);
249
+ }, [verifyOTP, saveIdentifierToStorage, setError, setIsLoading, clearError, onOTPSuccess, onError, sourceUrl, redirectUrl, setTwoFactorSessionId, setShouldPrompt2FA, setStep]);
250
+
251
+ const handleOTPSubmit = useCallback(async (e: React.FormEvent) => {
252
+ e.preventDefault();
253
+ await submitOTP(identifier, otp, channel);
254
+ }, [identifier, otp, channel, submitOTP]);
232
255
 
233
256
  const handleResendOTP = useCallback(async () => {
234
257
  setIsLoading(true);
235
258
  clearError();
236
-
259
+
237
260
  try {
238
261
  const result = await requestOTP(identifier, channel, sourceUrl);
239
-
262
+
240
263
  if (result.success) {
241
- // Save identifier and clear OTP input, clear opposite channel
242
- if (channel === 'email') {
243
- saveEmail(identifier);
244
- setSavedPhone(''); // Clear phone storage
245
- } else if (channel === 'phone') {
246
- savePhone(identifier);
247
- setSavedEmail(''); // Clear email storage
248
- }
264
+ saveIdentifierToStorage(identifier, channel);
249
265
  setOtp('');
250
266
  } else {
251
267
  setError(result.message);
252
268
  onError?.(result.message);
253
269
  }
254
- } catch (error) {
255
- const message = 'Failed to resend verification code';
256
- setError(message);
257
- onError?.(message);
270
+ } catch {
271
+ const msg = 'Failed to resend verification code';
272
+ setError(msg);
273
+ onError?.(msg);
258
274
  } finally {
259
275
  setIsLoading(false);
260
276
  }
261
- }, [identifier, channel, requestOTP, saveEmail, clearError, setOtp, onError, sourceUrl]);
277
+ }, [identifier, channel, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, onError, sourceUrl]);
262
278
 
263
279
  const handleBackToIdentifier = useCallback(() => {
264
280
  setStep('identifier');
265
281
  clearError();
266
- }, [clearError]);
282
+ }, [setStep, clearError]);
267
283
 
268
284
  const forceOTPStep = useCallback(() => {
269
285
  setStep('otp');
270
286
  clearError();
271
- }, [clearError]);
287
+ }, [setStep, clearError]);
288
+
289
+ // ─────────────────────────────────────────────────────────────────────────
290
+ // 2FA Handlers
291
+ // ─────────────────────────────────────────────────────────────────────────
292
+
293
+ const handle2FASubmit = useCallback(async (e: React.FormEvent) => {
294
+ e.preventDefault();
272
295
 
273
- const handleAcceptedTermsChange = useCallback((checked: boolean) => {
274
- setAcceptedTerms(checked);
275
- setSavedTermsAccepted(checked);
276
- }, [setSavedTermsAccepted]);
296
+ if (!twoFactorSessionId) {
297
+ const msg = 'Missing 2FA session';
298
+ setError(msg);
299
+ onError?.(msg);
300
+ return;
301
+ }
302
+
303
+ if (useBackupCode) {
304
+ await twoFactor.verifyBackupCode(twoFactorSessionId, twoFactorCode);
305
+ } else {
306
+ await twoFactor.verifyTOTP(twoFactorSessionId, twoFactorCode);
307
+ }
308
+ }, [twoFactorSessionId, twoFactorCode, useBackupCode, twoFactor, setError, onError]);
309
+
310
+ const handleUseBackupCode = useCallback(() => {
311
+ setUseBackupCode(true);
312
+ setTwoFactorCode('');
313
+ clearError();
314
+ }, [setUseBackupCode, setTwoFactorCode, clearError]);
315
+
316
+ const handleUseTOTP = useCallback(() => {
317
+ setUseBackupCode(false);
318
+ setTwoFactorCode('');
319
+ clearError();
320
+ }, [setUseBackupCode, setTwoFactorCode, clearError]);
321
+
322
+ // ─────────────────────────────────────────────────────────────────────────
323
+ // Auto-auth from URL
324
+ // ─────────────────────────────────────────────────────────────────────────
277
325
 
278
- // Auto-detect OTP from URL query parameters (only on auth page)
279
326
  useAutoAuth({
280
327
  allowedPaths: [authPath],
281
- onOTPDetected: (otp: string) => {
282
- authLogger.info('OTP detected, auto-submitting');
328
+ onOTPDetected: (detectedOtp: string) => {
329
+ if (isAutoSubmitFromUrlRef.current || isLoading) return;
330
+ isAutoSubmitFromUrlRef.current = true;
331
+
332
+ authLogger.info('OTP detected from URL, auto-submitting');
283
333
 
284
- // Get saved identifier from auth context
285
- const savedEmail = getSavedEmail();
286
334
  const savedPhone = getSavedPhone();
335
+ const savedEmail = getSavedEmail();
336
+
337
+ let autoIdentifier = '';
338
+ let autoChannel: AuthChannel = 'email';
287
339
 
288
- // Prioritize phone over email if both exist
289
340
  if (savedPhone) {
290
- setIdentifier(savedPhone);
291
- setChannel('phone');
341
+ autoIdentifier = savedPhone;
342
+ autoChannel = 'phone';
292
343
  } else if (savedEmail) {
293
- setIdentifier(savedEmail);
294
- setChannel('email');
344
+ autoIdentifier = savedEmail;
345
+ autoChannel = 'email';
346
+ }
347
+
348
+ if (!autoIdentifier) {
349
+ authLogger.warn('No saved identifier found for auto-submit');
350
+ isAutoSubmitFromUrlRef.current = false;
351
+ return;
295
352
  }
296
353
 
297
- // Set OTP and force OTP step
298
- setOtp(otp);
354
+ // Update UI state
355
+ setIdentifier(autoIdentifier);
356
+ setChannel(autoChannel);
357
+ setOtp(detectedOtp);
299
358
  setStep('otp');
300
359
 
301
- // Auto-submit after a short delay to ensure state is updated
302
- setTimeout(() => {
303
- const fakeEvent = { preventDefault: () => {} } as React.FormEvent;
304
- handleOTPSubmit(fakeEvent);
305
- }, 200);
360
+ // Submit with explicit values
361
+ setTimeout(async () => {
362
+ try {
363
+ await submitOTP(autoIdentifier, detectedOtp, autoChannel);
364
+ } finally {
365
+ isAutoSubmitFromUrlRef.current = false;
366
+ }
367
+ }, 100);
306
368
  },
307
369
  cleanupUrl: true,
308
370
  });
309
371
 
372
+ // ─────────────────────────────────────────────────────────────────────────
373
+ // Return
374
+ // ─────────────────────────────────────────────────────────────────────────
375
+
310
376
  return {
311
- // Form state
312
- identifier,
313
- channel,
314
- otp,
315
- isLoading,
316
- acceptedTerms,
317
- step,
318
- error,
319
-
320
- // Form handlers
321
- setIdentifier,
322
- setChannel,
323
- setOtp,
324
- setAcceptedTerms: handleAcceptedTermsChange,
325
- setError,
326
- clearError,
327
-
328
- // Auth handlers
377
+ // State
378
+ ...formState,
379
+
380
+ // Submit handlers
329
381
  handleIdentifierSubmit,
330
382
  handleOTPSubmit,
331
383
  handleResendOTP,
332
384
  handleBackToIdentifier,
333
385
  forceOTPStep,
334
-
335
- // Utility methods
336
- detectChannelFromIdentifier,
337
- validateIdentifier,
386
+
387
+ // 2FA handlers
388
+ handle2FASubmit,
389
+ handleUseBackupCode,
390
+ handleUseTOTP,
391
+
392
+ // Validation
393
+ ...validation,
394
+
395
+ // Auto-submit ref
396
+ isAutoSubmittingFromUrl: isAutoSubmitFromUrlRef,
397
+
398
+ // 2FA state from hook (for loading indicator)
399
+ is2FALoading: twoFactor.isLoading,
400
+ twoFactorWarning: twoFactor.warning,
338
401
  };
339
- };
402
+ };
403
+
404
+ // Re-export types for convenience
405
+ export type { AuthFormReturn, UseAuthFormOptions } from '../types';