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