@djangocfg/api 2.1.227 → 2.1.228
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 +218 -219
- package/dist/clients.d.ts +218 -219
- 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 +70 -91
- package/dist/hooks.d.ts +70 -91
- 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 +116 -106
- package/dist/index.d.ts +116 -106
- 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 +25 -26
- package/src/_api/generated/cfg_accounts/accounts__auth/models.ts +5 -5
- package/src/_api/generated/cfg_accounts/accounts__oauth/models.ts +42 -42
- package/src/_api/generated/cfg_accounts/accounts__user_profile/models.ts +23 -23
- 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 +57 -57
- package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_monitoring/models.ts +24 -24
- package/src/_api/generated/cfg_centrifugo/centrifugo__centrifugo_testing/models.ts +14 -14
- package/src/_api/generated/cfg_totp/totp__backup_codes/models.ts +14 -14
- package/src/_api/generated/cfg_totp/totp__totp_setup/models.ts +10 -10
- 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,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);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { apiAccounts, apiTotp } from '../../clients';
|
|
6
|
+
import { APIError } from '../../_api/generated/cfg_totp/errors';
|
|
6
7
|
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
7
8
|
import { authLogger } from '../utils/logger';
|
|
8
9
|
import { useCfgRouter } from './useCfgRouter';
|
|
@@ -27,6 +28,8 @@ export interface UseTwoFactorReturn {
|
|
|
27
28
|
warning: string | null;
|
|
28
29
|
/** Remaining backup codes (if backup code was used) */
|
|
29
30
|
remainingBackupCodes: number | null;
|
|
31
|
+
/** Remaining verification attempts before lockout */
|
|
32
|
+
attemptsRemaining: number | null;
|
|
30
33
|
/** Verify TOTP code */
|
|
31
34
|
verifyTOTP: (sessionId: string, code: string) => Promise<boolean>;
|
|
32
35
|
/** Verify backup code */
|
|
@@ -68,6 +71,7 @@ export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorRet
|
|
|
68
71
|
const [error, setError] = useState<string | null>(null);
|
|
69
72
|
const [warning, setWarning] = useState<string | null>(null);
|
|
70
73
|
const [remainingBackupCodes, setRemainingBackupCodes] = useState<number | null>(null);
|
|
74
|
+
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
|
71
75
|
|
|
72
76
|
const clearError = useCallback(() => {
|
|
73
77
|
setError(null);
|
|
@@ -155,8 +159,13 @@ export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorRet
|
|
|
155
159
|
return true;
|
|
156
160
|
|
|
157
161
|
} catch (err) {
|
|
158
|
-
const errorMessage = err instanceof Error ? err.message : 'Invalid verification code';
|
|
159
162
|
authLogger.error('2FA TOTP verification error:', err);
|
|
163
|
+
const errorMessage = err instanceof APIError
|
|
164
|
+
? (err.response?.error || err.response?.detail || err.response?.message || err.errorMessage)
|
|
165
|
+
: (err instanceof Error ? err.message : 'Invalid verification code');
|
|
166
|
+
if (err instanceof APIError && typeof err.response?.attempts_remaining === 'number') {
|
|
167
|
+
setAttemptsRemaining(err.response.attempts_remaining);
|
|
168
|
+
}
|
|
160
169
|
setError(errorMessage);
|
|
161
170
|
onError?.(errorMessage);
|
|
162
171
|
|
|
@@ -209,8 +218,13 @@ export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorRet
|
|
|
209
218
|
return true;
|
|
210
219
|
|
|
211
220
|
} catch (err) {
|
|
212
|
-
const errorMessage = err instanceof Error ? err.message : 'Invalid backup code';
|
|
213
221
|
authLogger.error('2FA backup code verification error:', err);
|
|
222
|
+
const errorMessage = err instanceof APIError
|
|
223
|
+
? (err.response?.error || err.response?.detail || err.response?.message || err.errorMessage)
|
|
224
|
+
: (err instanceof Error ? err.message : 'Invalid backup code');
|
|
225
|
+
if (err instanceof APIError && typeof err.response?.attempts_remaining === 'number') {
|
|
226
|
+
setAttemptsRemaining(err.response.attempts_remaining);
|
|
227
|
+
}
|
|
214
228
|
setError(errorMessage);
|
|
215
229
|
onError?.(errorMessage);
|
|
216
230
|
|
|
@@ -231,6 +245,7 @@ export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorRet
|
|
|
231
245
|
error,
|
|
232
246
|
warning,
|
|
233
247
|
remainingBackupCodes,
|
|
248
|
+
attemptsRemaining,
|
|
234
249
|
verifyTOTP,
|
|
235
250
|
verifyBackupCode,
|
|
236
251
|
clearError,
|
package/src/auth/types/form.ts
CHANGED
|
@@ -8,21 +8,29 @@
|
|
|
8
8
|
import type { MutableRefObject } from 'react';
|
|
9
9
|
|
|
10
10
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
11
|
-
//
|
|
11
|
+
// Step Types
|
|
12
12
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
13
|
|
|
14
|
-
export type AuthChannel = 'email' | 'phone';
|
|
15
14
|
export type AuthStep = 'identifier' | 'otp' | '2fa' | '2fa-setup' | 'success';
|
|
16
15
|
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// OTP Result Types
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface OTPRequestResult {
|
|
21
|
+
success: boolean;
|
|
22
|
+
message: string;
|
|
23
|
+
statusCode?: number;
|
|
24
|
+
retryAfter?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
18
28
|
// Form State
|
|
19
29
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
20
30
|
|
|
21
31
|
export interface AuthFormState {
|
|
22
|
-
/** Email
|
|
32
|
+
/** Email address */
|
|
23
33
|
identifier: string;
|
|
24
|
-
/** Current auth channel */
|
|
25
|
-
channel: AuthChannel;
|
|
26
34
|
/** OTP code input */
|
|
27
35
|
otp: string;
|
|
28
36
|
/** Loading state */
|
|
@@ -41,6 +49,12 @@ export interface AuthFormState {
|
|
|
41
49
|
twoFactorCode: string;
|
|
42
50
|
/** Using backup code instead of TOTP */
|
|
43
51
|
useBackupCode: boolean;
|
|
52
|
+
/** Seconds remaining until rate limit lifts (0 = not rate limited) */
|
|
53
|
+
rateLimitSeconds: number;
|
|
54
|
+
/** True when rateLimitSeconds > 0 — submit should be disabled */
|
|
55
|
+
isRateLimited: boolean;
|
|
56
|
+
/** Formatted countdown label, e.g. "19:00" or "42s" */
|
|
57
|
+
rateLimitLabel: string;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -49,7 +63,6 @@ export interface AuthFormState {
|
|
|
49
63
|
|
|
50
64
|
export interface AuthFormStateHandlers {
|
|
51
65
|
setIdentifier: (identifier: string) => void;
|
|
52
|
-
setChannel: (channel: AuthChannel) => void;
|
|
53
66
|
setOtp: (otp: string) => void;
|
|
54
67
|
setAcceptedTerms: (accepted: boolean) => void;
|
|
55
68
|
setError: (error: string) => void;
|
|
@@ -60,6 +73,8 @@ export interface AuthFormStateHandlers {
|
|
|
60
73
|
setShouldPrompt2FA: (prompt: boolean) => void;
|
|
61
74
|
setTwoFactorCode: (code: string) => void;
|
|
62
75
|
setUseBackupCode: (useBackup: boolean) => void;
|
|
76
|
+
/** Start a countdown timer that disables submit for `seconds` seconds */
|
|
77
|
+
startRateLimitCountdown: (seconds: number) => void;
|
|
63
78
|
}
|
|
64
79
|
|
|
65
80
|
export interface AuthFormSubmitHandlers {
|
|
@@ -81,10 +96,8 @@ export interface AuthFormSubmitHandlers {
|
|
|
81
96
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
82
97
|
|
|
83
98
|
export interface AuthFormValidation {
|
|
84
|
-
/** Detect channel from identifier string */
|
|
85
|
-
detectChannelFromIdentifier: (identifier: string) => AuthChannel | null;
|
|
86
99
|
/** Validate identifier format */
|
|
87
|
-
validateIdentifier: (identifier: string
|
|
100
|
+
validateIdentifier: (identifier: string) => boolean;
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -105,6 +118,8 @@ export interface AuthForm2FAState {
|
|
|
105
118
|
is2FALoading: boolean;
|
|
106
119
|
/** Warning message from 2FA (e.g., low backup codes) */
|
|
107
120
|
twoFactorWarning: string | null;
|
|
121
|
+
/** Remaining attempts before 2FA lockout (null = unknown) */
|
|
122
|
+
twoFactorAttemptsRemaining: number | null;
|
|
108
123
|
}
|
|
109
124
|
|
|
110
125
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -125,7 +140,7 @@ export interface AuthFormReturn extends
|
|
|
125
140
|
|
|
126
141
|
export interface UseAuthFormOptions {
|
|
127
142
|
/** Callback when identifier step succeeds */
|
|
128
|
-
onIdentifierSuccess?: (identifier: string
|
|
143
|
+
onIdentifierSuccess?: (identifier: string) => void;
|
|
129
144
|
/** Callback when OTP verification succeeds */
|
|
130
145
|
onOTPSuccess?: () => void;
|
|
131
146
|
/** Callback on any error */
|
|
@@ -147,63 +162,3 @@ export interface UseAuthFormOptions {
|
|
|
147
162
|
enable2FASetup?: boolean;
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
-
// Layout Context (UI-specific additions)
|
|
152
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
export interface AuthLayoutConfig {
|
|
155
|
-
/** Support page URL */
|
|
156
|
-
supportUrl?: string;
|
|
157
|
-
/** Terms of service URL */
|
|
158
|
-
termsUrl?: string;
|
|
159
|
-
/** Privacy policy URL */
|
|
160
|
-
privacyUrl?: string;
|
|
161
|
-
/** Source URL for tracking */
|
|
162
|
-
sourceUrl: string;
|
|
163
|
-
/** Enable phone authentication tab */
|
|
164
|
-
enablePhoneAuth?: boolean;
|
|
165
|
-
/** Enable GitHub OAuth button */
|
|
166
|
-
enableGithubAuth?: boolean;
|
|
167
|
-
/** Logo URL for success screen (SVG recommended) */
|
|
168
|
-
logoUrl?: string;
|
|
169
|
-
/** URL to redirect after successful auth (default: /dashboard) */
|
|
170
|
-
redirectUrl?: string;
|
|
171
|
-
/**
|
|
172
|
-
* Enable 2FA setup prompt after successful authentication.
|
|
173
|
-
* When true (default), users without 2FA will see a setup prompt after login.
|
|
174
|
-
* When false, users go directly to success without 2FA setup prompt.
|
|
175
|
-
* Note: This only affects the setup prompt - existing 2FA verification still works.
|
|
176
|
-
* @default true
|
|
177
|
-
*/
|
|
178
|
-
enable2FASetup?: boolean;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export interface AuthFormContextType extends AuthFormReturn, AuthLayoutConfig {}
|
|
182
|
-
|
|
183
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
-
// Layout Props
|
|
185
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
export interface AuthLayoutProps extends AuthLayoutConfig {
|
|
188
|
-
children?: React.ReactNode;
|
|
189
|
-
className?: string;
|
|
190
|
-
/** URL to redirect after successful auth (default: /dashboard) */
|
|
191
|
-
redirectUrl?: string;
|
|
192
|
-
/** Callback when identifier step succeeds */
|
|
193
|
-
onIdentifierSuccess?: (identifier: string, channel: AuthChannel) => void;
|
|
194
|
-
/** Callback when OTP verification succeeds */
|
|
195
|
-
onOTPSuccess?: () => void;
|
|
196
|
-
/** Callback when OAuth succeeds */
|
|
197
|
-
onOAuthSuccess?: (user: any, isNewUser: boolean, provider: string) => void;
|
|
198
|
-
/** Callback on any error */
|
|
199
|
-
onError?: (message: string) => void;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
203
|
-
// Help Component Props
|
|
204
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
export interface AuthHelpProps {
|
|
207
|
-
className?: string;
|
|
208
|
-
variant?: 'default' | 'compact';
|
|
209
|
-
}
|
package/src/auth/types/index.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
* Auth Types - Single source of truth
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
// Form types (
|
|
5
|
+
// Form types (auth logic only)
|
|
6
6
|
export type {
|
|
7
|
-
AuthChannel,
|
|
8
7
|
AuthStep,
|
|
9
8
|
AuthFormState,
|
|
10
9
|
AuthFormStateHandlers,
|
|
@@ -13,10 +12,7 @@ export type {
|
|
|
13
12
|
AuthFormAutoSubmit,
|
|
14
13
|
AuthFormReturn,
|
|
15
14
|
UseAuthFormOptions,
|
|
16
|
-
|
|
17
|
-
AuthFormContextType,
|
|
18
|
-
AuthLayoutProps,
|
|
19
|
-
AuthHelpProps,
|
|
15
|
+
OTPRequestResult,
|
|
20
16
|
} from './form';
|
|
21
17
|
|
|
22
18
|
// Re-export context types
|