@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.
- package/README.md +8 -9
- package/dist/auth-server.cjs +4 -9
- package/dist/auth-server.cjs.map +1 -1
- package/dist/auth-server.mjs +4 -9
- package/dist/auth-server.mjs.map +1 -1
- package/dist/auth.cjs +120 -158
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +120 -177
- package/dist/auth.d.ts +120 -177
- package/dist/auth.mjs +149 -191
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +5 -11
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +253 -254
- package/dist/clients.d.ts +253 -254
- package/dist/clients.mjs +5 -11
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +4 -9
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.d.cts +64 -85
- package/dist/hooks.d.ts +64 -85
- package/dist/hooks.mjs +4 -9
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +5 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +109 -99
- package/dist/index.d.ts +109 -99
- package/dist/index.mjs +5 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/_api/generated/cfg_accounts/_utils/schemas/OTPErrorResponse.schema.ts +24 -2
- package/src/_api/generated/cfg_accounts/_utils/schemas/OTPRequestRequest.schema.ts +0 -2
- package/src/_api/generated/cfg_accounts/_utils/schemas/OTPVerifyRequest.schema.ts +0 -2
- package/src/_api/generated/cfg_accounts/accounts/client.ts +1 -1
- package/src/_api/generated/cfg_accounts/accounts/models.ts +37 -38
- package/src/_api/generated/cfg_accounts/accounts__oauth/models.ts +36 -36
- package/src/_api/generated/cfg_accounts/accounts__user_profile/models.ts +15 -15
- package/src/_api/generated/cfg_accounts/enums.ts +0 -10
- package/src/_api/generated/cfg_accounts/schema.json +31 -25
- package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_admin_api/models.ts +74 -74
- package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_monitoring/models.ts +36 -36
- package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_testing/models.ts +22 -22
- package/src/_api/generated/cfg_totp/totp__totp_management/models.ts +10 -10
- package/src/_api/generated/cfg_totp/totp__totp_setup/models.ts +22 -22
- package/src/_api/generated/cfg_totp/totp__totp_verification/models.ts +8 -8
- package/src/auth/context/AccountsContext.tsx +6 -2
- package/src/auth/context/AuthContext.tsx +32 -39
- package/src/auth/context/types.ts +5 -9
- package/src/auth/hooks/index.ts +1 -1
- package/src/auth/hooks/useAuthForm.ts +42 -75
- package/src/auth/hooks/useAuthFormState.ts +35 -6
- package/src/auth/hooks/useAuthValidation.ts +5 -65
- package/src/auth/hooks/useTwoFactor.ts +17 -2
- package/src/auth/types/form.ts +25 -70
- 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
|
|
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
|
|
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 -
|
|
352
|
+
// OTP methods - email only - now uses AccountsContext
|
|
354
353
|
const requestOTP = useCallback(
|
|
355
|
-
async (identifier: 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
|
-
|
|
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:
|
|
367
|
+
label: 'email',
|
|
374
368
|
});
|
|
375
369
|
|
|
376
370
|
return {
|
|
377
371
|
success: true,
|
|
378
|
-
message: result.message || `OTP code sent to your
|
|
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,
|
|
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
|
-
|
|
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
|
|
433
|
-
if (
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
38
|
-
|
|
39
|
-
|
|
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
|
+
}
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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 {
|
|
81
|
+
const { validateIdentifier } = validation;
|
|
87
82
|
|
|
88
83
|
// ─────────────────────────────────────────────────────────────────────────
|
|
89
84
|
// Storage Helper
|
|
90
85
|
// ─────────────────────────────────────────────────────────────────────────
|
|
91
86
|
|
|
92
|
-
const saveIdentifierToStorage = useCallback((id: string
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
148
|
-
const msg =
|
|
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,
|
|
136
|
+
const result = await requestOTP(identifier, sourceUrl);
|
|
168
137
|
|
|
169
138
|
if (result.success) {
|
|
170
|
-
saveIdentifierToStorage(identifier
|
|
139
|
+
saveIdentifierToStorage(identifier);
|
|
171
140
|
setStep('otp');
|
|
172
|
-
onIdentifierSuccess?.(identifier
|
|
141
|
+
onIdentifierSuccess?.(identifier);
|
|
173
142
|
} else {
|
|
174
|
-
|
|
175
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
261
|
-
}, [identifier, otp,
|
|
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,
|
|
241
|
+
const result = await requestOTP(identifier, sourceUrl);
|
|
269
242
|
|
|
270
243
|
if (result.success) {
|
|
271
|
-
saveIdentifierToStorage(identifier
|
|
244
|
+
saveIdentifierToStorage(identifier);
|
|
272
245
|
setOtp('');
|
|
273
246
|
} else {
|
|
274
|
-
|
|
275
|
-
|
|
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,
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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);
|