@djangocfg/api 2.1.227 → 2.1.229

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 (55) hide show
  1. package/README.md +8 -9
  2. package/dist/auth-server.cjs +4 -9
  3. package/dist/auth-server.cjs.map +1 -1
  4. package/dist/auth-server.mjs +4 -9
  5. package/dist/auth-server.mjs.map +1 -1
  6. package/dist/auth.cjs +120 -158
  7. package/dist/auth.cjs.map +1 -1
  8. package/dist/auth.d.cts +120 -177
  9. package/dist/auth.d.ts +120 -177
  10. package/dist/auth.mjs +149 -191
  11. package/dist/auth.mjs.map +1 -1
  12. package/dist/clients.cjs +5 -11
  13. package/dist/clients.cjs.map +1 -1
  14. package/dist/clients.d.cts +253 -254
  15. package/dist/clients.d.ts +253 -254
  16. package/dist/clients.mjs +5 -11
  17. package/dist/clients.mjs.map +1 -1
  18. package/dist/hooks.cjs +4 -9
  19. package/dist/hooks.cjs.map +1 -1
  20. package/dist/hooks.d.cts +64 -85
  21. package/dist/hooks.d.ts +64 -85
  22. package/dist/hooks.mjs +4 -9
  23. package/dist/hooks.mjs.map +1 -1
  24. package/dist/index.cjs +5 -11
  25. package/dist/index.cjs.map +1 -1
  26. package/dist/index.d.cts +109 -99
  27. package/dist/index.d.ts +109 -99
  28. package/dist/index.mjs +5 -11
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +2 -2
  31. package/src/_api/generated/cfg_accounts/_utils/schemas/OTPErrorResponse.schema.ts +24 -2
  32. package/src/_api/generated/cfg_accounts/_utils/schemas/OTPRequestRequest.schema.ts +0 -2
  33. package/src/_api/generated/cfg_accounts/_utils/schemas/OTPVerifyRequest.schema.ts +0 -2
  34. package/src/_api/generated/cfg_accounts/accounts/client.ts +1 -1
  35. package/src/_api/generated/cfg_accounts/accounts/models.ts +37 -38
  36. package/src/_api/generated/cfg_accounts/accounts__oauth/models.ts +36 -36
  37. package/src/_api/generated/cfg_accounts/accounts__user_profile/models.ts +15 -15
  38. package/src/_api/generated/cfg_accounts/enums.ts +0 -10
  39. package/src/_api/generated/cfg_accounts/schema.json +31 -25
  40. package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_admin_api/models.ts +74 -74
  41. package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_monitoring/models.ts +36 -36
  42. package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_testing/models.ts +22 -22
  43. package/src/_api/generated/cfg_totp/totp__totp_management/models.ts +10 -10
  44. package/src/_api/generated/cfg_totp/totp__totp_setup/models.ts +22 -22
  45. package/src/_api/generated/cfg_totp/totp__totp_verification/models.ts +8 -8
  46. package/src/auth/context/AccountsContext.tsx +6 -2
  47. package/src/auth/context/AuthContext.tsx +32 -39
  48. package/src/auth/context/types.ts +5 -9
  49. package/src/auth/hooks/index.ts +1 -1
  50. package/src/auth/hooks/useAuthForm.ts +42 -75
  51. package/src/auth/hooks/useAuthFormState.ts +35 -6
  52. package/src/auth/hooks/useAuthValidation.ts +5 -65
  53. package/src/auth/hooks/useTwoFactor.ts +17 -2
  54. package/src/auth/types/form.ts +25 -70
  55. package/src/auth/types/index.ts +2 -6
@@ -9,7 +9,8 @@ import { SWRConfig } from 'swr';
9
9
 
10
10
  import { useCfgRouter, useLocalStorage, useQueryParams } from '../hooks';
11
11
 
12
- import { api as apiAccounts, Enums } from '../../';
12
+ import { api as apiAccounts } from '../../';
13
+ import { APIError } from '../../_api/generated/cfg_accounts/errors';
13
14
  import { clearProfileCache, getCachedProfile } from '../hooks/useProfileCache';
14
15
  import { useAuthRedirectManager } from '../hooks/useAuthRedirect';
15
16
  import { useTokenRefresh } from '../hooks/useTokenRefresh';
@@ -30,7 +31,6 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
30
31
 
31
32
  // Constants
32
33
  const EMAIL_STORAGE_KEY = 'auth_email';
33
- const PHONE_STORAGE_KEY = 'auth_phone';
34
34
 
35
35
  const hasValidTokens = (): boolean => {
36
36
  if (typeof window === 'undefined') return false;
@@ -63,9 +63,8 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
63
63
  const pathname = usePathname();
64
64
  const queryParams = useQueryParams();
65
65
 
66
- // Use localStorage hooks for email and phone
66
+ // Use localStorage hook for email
67
67
  const [storedEmail, setStoredEmail, clearStoredEmail] = useLocalStorage<string | null>(EMAIL_STORAGE_KEY, null);
68
- const [storedPhone, setStoredPhone, clearStoredPhone] = useLocalStorage<string | null>(PHONE_STORAGE_KEY, null);
69
68
 
70
69
  // Automatic token refresh - refreshes token before expiry, on focus, and on network reconnect
71
70
  useTokenRefresh({
@@ -350,35 +349,43 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
350
349
  }
351
350
  }, [loadCurrentProfile, clearAuthState, pushToDefaultCallbackUrl, pushToDefaultAuthCallbackUrl, handleGlobalAuthError]);
352
351
 
353
- // OTP methods - supports both email and phone - now uses AccountsContext
352
+ // OTP methods - email only - now uses AccountsContext
354
353
  const requestOTP = useCallback(
355
- async (identifier: string, channel?: 'email' | 'phone', sourceUrl?: string): Promise<{ success: boolean; message: string }> => {
354
+ async (identifier: string, sourceUrl?: string): Promise<{ success: boolean; message: string; statusCode?: number; retryAfter?: number }> => {
356
355
  // Clear tokens before requesting OTP
357
356
  apiAccounts.clearTokens();
358
357
 
359
358
  try {
360
- const channelValue = channel === 'phone'
361
- ? Enums.OTPRequestRequestChannel.PHONE
362
- : Enums.OTPRequestRequestChannel.EMAIL;
363
359
  const result = await accounts.requestOTP({
364
360
  identifier,
365
- channel: channelValue,
361
+ source_url: sourceUrl,
366
362
  });
367
363
 
368
- const channelName = channel === 'phone' ? 'phone number' : 'email address';
369
-
370
364
  // Track OTP request
371
365
  Analytics.event(AnalyticsEvent.AUTH_OTP_REQUEST, {
372
366
  category: AnalyticsCategory.AUTH,
373
- label: channel || 'email',
367
+ label: 'email',
374
368
  });
375
369
 
376
370
  return {
377
371
  success: true,
378
- message: result.message || `OTP code sent to your ${channelName}`,
372
+ message: result.message || `OTP code sent to your email address`,
379
373
  };
380
374
  } catch (error) {
381
375
  authLogger.error('Request OTP error:', error);
376
+
377
+ if (error instanceof APIError) {
378
+ const retryAfter = error.response?.retry_after ?? error.response?.retryAfter;
379
+ // Django returns { error, error_code, retry_after } — errorMessage reads 'detail'/'message' only
380
+ const message = error.response?.error || error.response?.detail || error.response?.message || error.errorMessage;
381
+ return {
382
+ success: false,
383
+ statusCode: error.statusCode,
384
+ message,
385
+ retryAfter: typeof retryAfter === 'number' ? retryAfter : undefined,
386
+ };
387
+ }
388
+
382
389
  return {
383
390
  success: false,
384
391
  message: 'Failed to send OTP',
@@ -389,7 +396,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
389
396
  );
390
397
 
391
398
  const verifyOTP = useCallback(
392
- async (identifier: string, otpCode: string, channel?: 'email' | 'phone', sourceUrl?: string, redirectUrl?: string, skipRedirect?: boolean): Promise<{
399
+ async (identifier: string, otpCode: string, sourceUrl?: string, redirectUrl?: string, skipRedirect?: boolean): Promise<{
393
400
  success: boolean;
394
401
  message: string;
395
402
  user?: UserProfile;
@@ -398,14 +405,11 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
398
405
  should_prompt_2fa?: boolean;
399
406
  }> => {
400
407
  try {
401
- const channelValue = channel === 'phone'
402
- ? Enums.OTPRequestRequestChannel.PHONE
403
- : Enums.OTPRequestRequestChannel.EMAIL;
404
408
  // AccountsContext automatically saves tokens and refreshes profile
405
409
  const result = await accounts.verifyOTP({
406
410
  identifier,
407
411
  otp: otpCode,
408
- channel: channelValue,
412
+ source_url: sourceUrl,
409
413
  });
410
414
 
411
415
  // Check if 2FA is required - return early with session info
@@ -429,13 +433,9 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
429
433
  };
430
434
  }
431
435
 
432
- // Save identifier based on channel and clear opposite channel
433
- if (channel === 'phone') {
434
- setStoredPhone(identifier);
435
- clearStoredEmail();
436
- } else if (identifier.includes('@')) {
436
+ // Save email identifier
437
+ if (identifier.includes('@')) {
437
438
  setStoredEmail(identifier);
438
- clearStoredPhone();
439
439
  }
440
440
 
441
441
  // Small delay to ensure profile state is updated
@@ -444,7 +444,7 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
444
444
  // Track successful login
445
445
  Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
446
446
  category: AnalyticsCategory.AUTH,
447
- label: channel || 'email',
447
+ label: 'email',
448
448
  });
449
449
 
450
450
  // Set user ID for future tracking
@@ -474,16 +474,20 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
474
474
  // Track failed verification
475
475
  Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
476
476
  category: AnalyticsCategory.AUTH,
477
- label: channel || 'email',
477
+ label: 'email',
478
478
  });
479
479
 
480
+ if (error instanceof APIError) {
481
+ const message = error.response?.error || error.response?.detail || error.response?.message || error.errorMessage;
482
+ return { success: false, message };
483
+ }
480
484
  return {
481
485
  success: false,
482
486
  message: 'Failed to verify OTP',
483
487
  };
484
488
  }
485
489
  },
486
- [setStoredEmail, setStoredPhone, clearStoredEmail, clearStoredPhone, config?.routes?.defaultCallback, accounts, router],
490
+ [setStoredEmail, config?.routes?.defaultCallback, accounts, router],
487
491
  );
488
492
 
489
493
  const refreshToken = useCallback(async (): Promise<{ success: boolean; message: string }> => {
@@ -584,9 +588,6 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
584
588
  getSavedEmail: () => storedEmail,
585
589
  saveEmail: setStoredEmail,
586
590
  clearSavedEmail: clearStoredEmail,
587
- getSavedPhone: () => storedPhone,
588
- savePhone: setStoredPhone,
589
- clearSavedPhone: clearStoredPhone,
590
591
  requestOTP,
591
592
  verifyOTP,
592
593
  refreshToken,
@@ -609,9 +610,6 @@ const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config })
609
610
  storedEmail,
610
611
  setStoredEmail,
611
612
  clearStoredEmail,
612
- storedPhone,
613
- setStoredPhone,
614
- clearStoredPhone,
615
613
  requestOTP,
616
614
  verifyOTP,
617
615
  refreshToken,
@@ -662,11 +660,6 @@ const defaultAuthState: AuthContextType = {
662
660
  authLogger.warn('useAuth: saveEmail called outside AuthProvider');
663
661
  },
664
662
  clearSavedEmail: () => {},
665
- getSavedPhone: () => null,
666
- savePhone: () => {
667
- authLogger.warn('useAuth: savePhone called outside AuthProvider');
668
- },
669
- clearSavedPhone: () => {},
670
663
  requestOTP: async () => {
671
664
  authLogger.warn('useAuth: requestOTP called outside AuthProvider');
672
665
  return { success: false, message: 'AuthProvider not available' };
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
 
3
3
  import type { User } from '../../';
4
+ import type { OTPRequestResult } from '../types';
4
5
 
5
6
  // User profile type
6
7
  export type UserProfile = User;
@@ -34,14 +35,9 @@ export interface AuthContextType {
34
35
  saveEmail: (email: string) => void;
35
36
  clearSavedEmail: () => void;
36
37
 
37
- // Phone Methods
38
- getSavedPhone: () => string | null;
39
- savePhone: (phone: string) => void;
40
- clearSavedPhone: () => void;
41
-
42
- // OTP Methods - Multi-channel support
43
- requestOTP: (identifier: string, channel?: 'email' | 'phone', sourceUrl?: string) => Promise<{ success: boolean; message: string }>;
44
- verifyOTP: (identifier: string, otpCode: string, channel?: 'email' | 'phone', sourceUrl?: string, redirectUrl?: string, skipRedirect?: boolean) => Promise<{
38
+ // OTP Methods - email only
39
+ requestOTP: (identifier: string, sourceUrl?: string) => Promise<OTPRequestResult>;
40
+ verifyOTP: (identifier: string, otpCode: string, sourceUrl?: string, redirectUrl?: string, skipRedirect?: boolean) => Promise<{
45
41
  success: boolean;
46
42
  message: string;
47
43
  user?: UserProfile;
@@ -67,4 +63,4 @@ export interface AuthContextType {
67
63
  export interface AuthProviderProps {
68
64
  children: React.ReactNode;
69
65
  config?: AuthConfig;
70
- }
66
+ }
@@ -6,7 +6,7 @@ export { useQueryParams } from './useQueryParams';
6
6
 
7
7
  // Core form hooks (decomposed)
8
8
  export { useAuthFormState, type UseAuthFormStateReturn } from './useAuthFormState';
9
- export { useAuthValidation, detectChannelFromIdentifier, validateIdentifier } from './useAuthValidation';
9
+ export { useAuthValidation, validateIdentifier } from './useAuthValidation';
10
10
 
11
11
  // Complete form hook (composes the above)
12
12
  export { useAuthForm, type AuthFormReturn, type UseAuthFormOptions } from './useAuthForm';
@@ -9,7 +9,7 @@ import { useAuthValidation } from './useAuthValidation';
9
9
  import { useAutoAuth } from './useAutoAuth';
10
10
  import { useTwoFactor } from './useTwoFactor';
11
11
 
12
- import type { AuthChannel, AuthFormReturn, UseAuthFormOptions } from '../types';
12
+ import type { AuthFormReturn, UseAuthFormOptions } from '../types';
13
13
  /**
14
14
  * Complete auth form hook.
15
15
  * Composes smaller hooks for state, validation, and submission.
@@ -39,16 +39,11 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
39
39
  verifyOTP,
40
40
  getSavedEmail,
41
41
  saveEmail,
42
- clearSavedEmail,
43
- getSavedPhone,
44
- savePhone,
45
- clearSavedPhone,
46
42
  } = useAuth();
47
43
 
48
44
  // Destructure for convenience
49
45
  const {
50
46
  identifier,
51
- channel,
52
47
  otp,
53
48
  isLoading,
54
49
  acceptedTerms,
@@ -56,7 +51,6 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
56
51
  twoFactorCode,
57
52
  useBackupCode,
58
53
  setIdentifier,
59
- setChannel,
60
54
  setOtp,
61
55
  setStep,
62
56
  setError,
@@ -66,6 +60,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
66
60
  setShouldPrompt2FA,
67
61
  setTwoFactorCode,
68
62
  setUseBackupCode,
63
+ startRateLimitCountdown,
69
64
  } = formState;
70
65
 
71
66
  // 2FA verification hook - skip redirect so we can show success screen
@@ -83,21 +78,15 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
83
78
  skipRedirect: true, // We handle navigation via success step
84
79
  });
85
80
 
86
- const { detectChannelFromIdentifier, validateIdentifier } = validation;
81
+ const { validateIdentifier } = validation;
87
82
 
88
83
  // ─────────────────────────────────────────────────────────────────────────
89
84
  // Storage Helper
90
85
  // ─────────────────────────────────────────────────────────────────────────
91
86
 
92
- const saveIdentifierToStorage = useCallback((id: string, ch: AuthChannel) => {
93
- if (ch === 'email') {
94
- saveEmail(id);
95
- clearSavedPhone();
96
- } else {
97
- savePhone(id);
98
- clearSavedEmail();
99
- }
100
- }, [saveEmail, savePhone, clearSavedEmail, clearSavedPhone]);
87
+ const saveIdentifierToStorage = useCallback((id: string) => {
88
+ saveEmail(id);
89
+ }, [saveEmail]);
101
90
 
102
91
  // ─────────────────────────────────────────────────────────────────────────
103
92
  // Effects
@@ -105,28 +94,12 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
105
94
 
106
95
  // Load saved identifier on mount
107
96
  useEffect(() => {
108
- const savedPhone = getSavedPhone();
109
97
  const savedEmail = getSavedEmail();
110
-
111
- // Prioritize phone over email
112
- if (savedPhone) {
113
- setIdentifier(savedPhone);
114
- setChannel('phone');
115
- } else if (savedEmail) {
98
+ if (savedEmail) {
116
99
  setIdentifier(savedEmail);
117
- setChannel('email');
118
100
  }
119
- }, [getSavedEmail, getSavedPhone, setIdentifier, setChannel]);
120
-
121
- // Auto-detect channel when identifier changes
122
- useEffect(() => {
123
- if (identifier) {
124
- const detected = detectChannelFromIdentifier(identifier);
125
- if (detected && detected !== channel) {
126
- setChannel(detected);
127
- }
128
- }
129
- }, [identifier, channel, detectChannelFromIdentifier, setChannel]);
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, []);
130
103
 
131
104
  // ─────────────────────────────────────────────────────────────────────────
132
105
  // Submit Handlers
@@ -136,18 +109,14 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
136
109
  e.preventDefault();
137
110
 
138
111
  if (!identifier) {
139
- const msg = channel === 'phone'
140
- ? 'Please enter your phone number'
141
- : 'Please enter your email address';
112
+ const msg = 'Please enter your email address';
142
113
  setError(msg);
143
114
  onError?.(msg);
144
115
  return;
145
116
  }
146
117
 
147
- if (!validateIdentifier(identifier, channel)) {
148
- const msg = channel === 'phone'
149
- ? 'Please enter a valid phone number (e.g., +1234567890)'
150
- : 'Please enter a valid email address';
118
+ if (!validateIdentifier(identifier)) {
119
+ const msg = 'Please enter a valid email address';
151
120
  setError(msg);
152
121
  onError?.(msg);
153
122
  return;
@@ -164,15 +133,20 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
164
133
  clearError();
165
134
 
166
135
  try {
167
- const result = await requestOTP(identifier, channel, sourceUrl);
136
+ const result = await requestOTP(identifier, sourceUrl);
168
137
 
169
138
  if (result.success) {
170
- saveIdentifierToStorage(identifier, channel);
139
+ saveIdentifierToStorage(identifier);
171
140
  setStep('otp');
172
- onIdentifierSuccess?.(identifier, channel);
141
+ onIdentifierSuccess?.(identifier);
173
142
  } else {
174
- setError(result.message);
175
- onError?.(result.message);
143
+ if (result.retryAfter) {
144
+ startRateLimitCountdown(result.retryAfter);
145
+ clearError();
146
+ } else {
147
+ setError(result.message);
148
+ onError?.(result.message);
149
+ }
176
150
  }
177
151
  } catch {
178
152
  const msg = 'An unexpected error occurred';
@@ -182,17 +156,16 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
182
156
  setIsLoading(false);
183
157
  }
184
158
  }, [
185
- identifier, channel, acceptedTerms, requireTermsAcceptance,
159
+ identifier, acceptedTerms, requireTermsAcceptance,
186
160
  validateIdentifier, requestOTP, saveIdentifierToStorage,
187
161
  setError, setIsLoading, setStep, clearError,
188
- onIdentifierSuccess, onError, sourceUrl,
162
+ startRateLimitCountdown, onIdentifierSuccess, onError, sourceUrl,
189
163
  ]);
190
164
 
191
165
  // Core OTP submit - accepts explicit values to avoid stale closures
192
166
  const submitOTP = useCallback(async (
193
167
  submitIdentifier: string,
194
168
  submitOtp: string,
195
- submitChannel: AuthChannel
196
169
  ): Promise<boolean> => {
197
170
  if (!submitOtp || submitOtp.length < 6) {
198
171
  const msg = 'Please enter the 6-digit verification code';
@@ -206,7 +179,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
206
179
 
207
180
  try {
208
181
  // Skip redirect - we'll show success screen first
209
- const result = await verifyOTP(submitIdentifier, submitOtp, submitChannel, sourceUrl, redirectUrl, true);
182
+ const result = await verifyOTP(submitIdentifier, submitOtp, sourceUrl, redirectUrl, true);
210
183
 
211
184
  // Check if 2FA is required (user has TOTP device)
212
185
  if (result.requires_2fa && result.session_id) {
@@ -214,12 +187,12 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
214
187
  setTwoFactorSessionId(result.session_id);
215
188
  setShouldPrompt2FA(result.should_prompt_2fa || false);
216
189
  setStep('2fa');
217
- saveIdentifierToStorage(submitIdentifier, submitChannel);
190
+ saveIdentifierToStorage(submitIdentifier);
218
191
  return true; // OTP was successful, now need 2FA
219
192
  }
220
193
 
221
194
  if (result.success) {
222
- saveIdentifierToStorage(submitIdentifier, submitChannel);
195
+ saveIdentifierToStorage(submitIdentifier);
223
196
 
224
197
  // Check if user should be prompted to set up 2FA
225
198
  // Only show 2FA setup prompt if enable2FASetup is true (default)
@@ -257,22 +230,27 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
257
230
 
258
231
  const handleOTPSubmit = useCallback(async (e: React.FormEvent) => {
259
232
  e.preventDefault();
260
- await submitOTP(identifier, otp, channel);
261
- }, [identifier, otp, channel, submitOTP]);
233
+ await submitOTP(identifier, otp);
234
+ }, [identifier, otp, submitOTP]);
262
235
 
263
236
  const handleResendOTP = useCallback(async () => {
264
237
  setIsLoading(true);
265
238
  clearError();
266
239
 
267
240
  try {
268
- const result = await requestOTP(identifier, channel, sourceUrl);
241
+ const result = await requestOTP(identifier, sourceUrl);
269
242
 
270
243
  if (result.success) {
271
- saveIdentifierToStorage(identifier, channel);
244
+ saveIdentifierToStorage(identifier);
272
245
  setOtp('');
273
246
  } else {
274
- setError(result.message);
275
- onError?.(result.message);
247
+ if (result.retryAfter) {
248
+ startRateLimitCountdown(result.retryAfter);
249
+ clearError(); // countdown in rateLimitLabel is enough feedback
250
+ } else {
251
+ setError(result.message);
252
+ onError?.(result.message);
253
+ }
276
254
  }
277
255
  } catch {
278
256
  const msg = 'Failed to resend verification code';
@@ -281,7 +259,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
281
259
  } finally {
282
260
  setIsLoading(false);
283
261
  }
284
- }, [identifier, channel, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, onError, sourceUrl]);
262
+ }, [identifier, requestOTP, saveIdentifierToStorage, setOtp, setError, setIsLoading, clearError, startRateLimitCountdown, onError, sourceUrl]);
285
263
 
286
264
  const handleBackToIdentifier = useCallback(() => {
287
265
  setStep('identifier');
@@ -338,19 +316,8 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
338
316
 
339
317
  authLogger.info('OTP detected from URL, auto-submitting');
340
318
 
341
- const savedPhone = getSavedPhone();
342
319
  const savedEmail = getSavedEmail();
343
-
344
- let autoIdentifier = '';
345
- let autoChannel: AuthChannel = 'email';
346
-
347
- if (savedPhone) {
348
- autoIdentifier = savedPhone;
349
- autoChannel = 'phone';
350
- } else if (savedEmail) {
351
- autoIdentifier = savedEmail;
352
- autoChannel = 'email';
353
- }
320
+ const autoIdentifier = savedEmail || '';
354
321
 
355
322
  if (!autoIdentifier) {
356
323
  authLogger.warn('No saved identifier found for auto-submit');
@@ -360,14 +327,13 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
360
327
 
361
328
  // Update UI state
362
329
  setIdentifier(autoIdentifier);
363
- setChannel(autoChannel);
364
330
  setOtp(detectedOtp);
365
331
  setStep('otp');
366
332
 
367
333
  // Submit with explicit values
368
334
  setTimeout(async () => {
369
335
  try {
370
- await submitOTP(autoIdentifier, detectedOtp, autoChannel);
336
+ await submitOTP(autoIdentifier, detectedOtp);
371
337
  } finally {
372
338
  isAutoSubmitFromUrlRef.current = false;
373
339
  }
@@ -405,6 +371,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
405
371
  // 2FA state from hook (for loading indicator)
406
372
  is2FALoading: twoFactor.isLoading,
407
373
  twoFactorWarning: twoFactor.warning,
374
+ twoFactorAttemptsRemaining: twoFactor.attemptsRemaining,
408
375
  };
409
376
  };
410
377
 
@@ -1,8 +1,14 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useState } from 'react';
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
4
 
5
- import type { AuthChannel, AuthFormState, AuthFormStateHandlers, AuthStep } from '../types';
5
+ import type { AuthFormState, AuthFormStateHandlers, AuthStep } from '../types';
6
+
7
+ const formatCountdown = (s: number): string => {
8
+ if (s <= 0) return '';
9
+ const m = Math.floor(s / 60);
10
+ return m > 0 ? `${m}:${String(s % 60).padStart(2, '0')}` : `${s}s`;
11
+ };
6
12
 
7
13
  export interface UseAuthFormStateReturn extends AuthFormState, AuthFormStateHandlers {}
8
14
 
@@ -12,10 +18,8 @@ export interface UseAuthFormStateReturn extends AuthFormState, AuthFormStateHand
12
18
  */
13
19
  export const useAuthFormState = (
14
20
  initialIdentifier = '',
15
- initialChannel: AuthChannel = 'email'
16
21
  ): UseAuthFormStateReturn => {
17
22
  const [identifier, setIdentifier] = useState(initialIdentifier);
18
- const [channel, setChannel] = useState<AuthChannel>(initialChannel);
19
23
  const [otp, setOtp] = useState('');
20
24
  const [isLoading, setIsLoading] = useState(false);
21
25
  const [acceptedTerms, setAcceptedTerms] = useState(true);
@@ -28,12 +32,34 @@ export const useAuthFormState = (
28
32
  const [twoFactorCode, setTwoFactorCode] = useState('');
29
33
  const [useBackupCode, setUseBackupCode] = useState(false);
30
34
 
35
+ // Rate limit countdown
36
+ const [rateLimitSeconds, setRateLimitSeconds] = useState(0);
37
+ const rateLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
38
+
39
+ const startRateLimitCountdown = useCallback((seconds: number) => {
40
+ if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
41
+ setRateLimitSeconds(seconds);
42
+ rateLimitTimerRef.current = setInterval(() => {
43
+ setRateLimitSeconds((prev) => {
44
+ if (prev <= 1) {
45
+ clearInterval(rateLimitTimerRef.current!);
46
+ rateLimitTimerRef.current = null;
47
+ return 0;
48
+ }
49
+ return prev - 1;
50
+ });
51
+ }, 1000);
52
+ }, []);
53
+
54
+ useEffect(() => () => {
55
+ if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
56
+ }, []);
57
+
31
58
  const clearError = useCallback(() => setError(''), []);
32
59
 
33
60
  return {
34
61
  // State
35
62
  identifier,
36
- channel,
37
63
  otp,
38
64
  isLoading,
39
65
  acceptedTerms,
@@ -43,9 +69,11 @@ export const useAuthFormState = (
43
69
  shouldPrompt2FA,
44
70
  twoFactorCode,
45
71
  useBackupCode,
72
+ rateLimitSeconds,
73
+ isRateLimited: rateLimitSeconds > 0,
74
+ rateLimitLabel: formatCountdown(rateLimitSeconds),
46
75
  // Handlers
47
76
  setIdentifier,
48
- setChannel,
49
77
  setOtp,
50
78
  setAcceptedTerms,
51
79
  setError,
@@ -56,5 +84,6 @@ export const useAuthFormState = (
56
84
  setShouldPrompt2FA,
57
85
  setTwoFactorCode,
58
86
  setUseBackupCode,
87
+ startRateLimitCountdown,
59
88
  };
60
89
  };
@@ -2,76 +2,16 @@
2
2
 
3
3
  import { useCallback } from 'react';
4
4
 
5
- import type { AuthChannel, AuthFormValidation } from '../types';
5
+ import type { AuthFormValidation } from '../types';
6
6
 
7
- // Email regex - RFC 5322 simplified
8
7
  const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
9
8
 
10
- // Phone regex - E.164 format (+[country][number], 7-15 digits)
11
- const PHONE_REGEX = /^\+[1-9]\d{6,14}$/;
12
-
13
- /**
14
- * Hook for auth identifier validation.
15
- * Pure functions - no state, no side effects.
16
- */
17
9
  export const useAuthValidation = (): AuthFormValidation => {
18
- /**
19
- * Detect channel type from identifier string.
20
- * Returns 'email' if contains @, 'phone' if starts with + and matches E.164.
21
- */
22
- const detectChannelFromIdentifier = useCallback((id: string): AuthChannel | null => {
23
- if (!id) return null;
24
-
25
- // Email detection - contains @
26
- if (id.includes('@')) {
27
- return 'email';
28
- }
29
-
30
- // Phone detection - starts with + and matches E.164 format
31
- if (id.startsWith('+') && PHONE_REGEX.test(id)) {
32
- return 'phone';
33
- }
34
-
35
- return null;
10
+ const validateIdentifier = useCallback((id: string): boolean => {
11
+ return EMAIL_REGEX.test(id);
36
12
  }, []);
37
13
 
38
- /**
39
- * Validate identifier format based on channel type.
40
- */
41
- const validateIdentifier = useCallback((id: string, channelType?: AuthChannel): boolean => {
42
- if (!id) return false;
43
-
44
- const channel = channelType || detectChannelFromIdentifier(id);
45
-
46
- if (channel === 'email') {
47
- return EMAIL_REGEX.test(id);
48
- }
49
-
50
- if (channel === 'phone') {
51
- return PHONE_REGEX.test(id);
52
- }
53
-
54
- return false;
55
- }, [detectChannelFromIdentifier]);
56
-
57
- return {
58
- detectChannelFromIdentifier,
59
- validateIdentifier,
60
- };
14
+ return { validateIdentifier };
61
15
  };
62
16
 
63
- // Export standalone functions for use outside React
64
- export const detectChannelFromIdentifier = (id: string): AuthChannel | null => {
65
- if (!id) return null;
66
- if (id.includes('@')) return 'email';
67
- if (id.startsWith('+') && PHONE_REGEX.test(id)) return 'phone';
68
- return null;
69
- };
70
-
71
- export const validateIdentifier = (id: string, channelType?: AuthChannel): boolean => {
72
- if (!id) return false;
73
- const channel = channelType || detectChannelFromIdentifier(id);
74
- if (channel === 'email') return EMAIL_REGEX.test(id);
75
- if (channel === 'phone') return PHONE_REGEX.test(id);
76
- return false;
77
- };
17
+ export const validateIdentifier = (id: string): boolean => EMAIL_REGEX.test(id);