@djangocfg/api 2.1.457 → 2.1.460
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 +1 -1
- package/dist/auth.cjs +54 -31
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +51 -57
- package/dist/auth.d.ts +51 -57
- package/dist/auth.mjs +54 -31
- package/dist/auth.mjs.map +1 -1
- package/package.json +2 -2
- package/src/auth/__tests__/useAuthForm.2fa.test.ts +2 -2
- package/src/auth/hooks/index.ts +11 -7
- package/src/auth/hooks/twoFactor.shared.ts +54 -0
- package/src/auth/hooks/useAuthForm.ts +2 -2
- package/src/auth/hooks/useTwoFactor.ts +55 -241
- package/src/auth/hooks/useTwoFactorSetup.ts +9 -47
- package/src/auth/hooks/useTwoFactorStatus.ts +6 -40
- package/src/auth/hooks/useTwoFactorVerify.ts +214 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.460",
|
|
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",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"zod": "^4.3.6"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
87
|
+
"@djangocfg/typescript-config": "^2.1.460",
|
|
88
88
|
"@testing-library/react": "^16.3.2",
|
|
89
89
|
"@types/node": "^25.2.3",
|
|
90
90
|
"@types/react": "^19.2.15",
|
|
@@ -35,8 +35,8 @@ vi.mock('../context', () => ({
|
|
|
35
35
|
}),
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
|
-
vi.mock('../hooks/
|
|
39
|
-
|
|
38
|
+
vi.mock('../hooks/useTwoFactorVerify', () => ({
|
|
39
|
+
useTwoFactorVerify: () => ({
|
|
40
40
|
isLoading: false,
|
|
41
41
|
warning: null,
|
|
42
42
|
verifyTOTP: vi.fn(),
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -20,19 +20,23 @@ export { useAutoAuth, type UseAutoAuthOptions } from './useAutoAuth';
|
|
|
20
20
|
// OAuth
|
|
21
21
|
export { useGithubAuth, type UseGithubAuthOptions, type UseGithubAuthReturn } from './useGithubAuth';
|
|
22
22
|
|
|
23
|
-
// 2FA
|
|
24
|
-
|
|
23
|
+
// 2FA — one consolidated hook (verify / setup / status namespaces),
|
|
24
|
+
// plus the underlying sub-hooks for single-concern consumers.
|
|
25
25
|
export {
|
|
26
|
+
useTwoFactor,
|
|
27
|
+
useTwoFactorVerify,
|
|
26
28
|
useTwoFactorSetup,
|
|
29
|
+
useTwoFactorStatus,
|
|
30
|
+
type UseTwoFactorOptions,
|
|
31
|
+
type UseTwoFactorReturn,
|
|
32
|
+
type UseTwoFactorVerifyOptions,
|
|
33
|
+
type UseTwoFactorVerifyReturn,
|
|
27
34
|
type UseTwoFactorSetupOptions,
|
|
28
35
|
type UseTwoFactorSetupReturn,
|
|
29
|
-
type TwoFactorSetupData,
|
|
30
|
-
} from './useTwoFactorSetup';
|
|
31
|
-
export {
|
|
32
|
-
useTwoFactorStatus,
|
|
33
36
|
type UseTwoFactorStatusReturn,
|
|
37
|
+
type TwoFactorSetupData,
|
|
34
38
|
type TwoFactorDevice,
|
|
35
|
-
} from './
|
|
39
|
+
} from './useTwoFactor';
|
|
36
40
|
|
|
37
41
|
// Auth guards and redirects
|
|
38
42
|
export {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { APIError } from '../../_api/generated/helpers';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared internals for the 2FA hook family (verify / setup / status).
|
|
7
|
+
*
|
|
8
|
+
* Before consolidation each of the three hooks re-implemented error extraction
|
|
9
|
+
* and 6-digit validation slightly differently — three copies that drifted. This
|
|
10
|
+
* module is the single source for both, so every 2FA verb reports errors and
|
|
11
|
+
* validates codes identically.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const TOTP_CODE_LENGTH = 6;
|
|
15
|
+
|
|
16
|
+
/** A 2FA API error body may carry any of these message-ish fields. */
|
|
17
|
+
interface TwoFactorErrorBody {
|
|
18
|
+
error?: unknown;
|
|
19
|
+
detail?: unknown;
|
|
20
|
+
message?: unknown;
|
|
21
|
+
attempts_remaining?: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract a human-readable message from a thrown error, unwrapping the
|
|
26
|
+
* APIError response body (error → detail → message) before falling back.
|
|
27
|
+
*/
|
|
28
|
+
export function extractTwoFactorError(err: unknown, fallback: string): string {
|
|
29
|
+
if (err instanceof APIError) {
|
|
30
|
+
const body = (err.response ?? null) as TwoFactorErrorBody | null;
|
|
31
|
+
if (typeof body?.error === 'string') return body.error;
|
|
32
|
+
if (typeof body?.detail === 'string') return body.detail;
|
|
33
|
+
if (typeof body?.message === 'string') return body.message;
|
|
34
|
+
return err.errorMessage ?? err.message;
|
|
35
|
+
}
|
|
36
|
+
if (err instanceof Error) return err.message;
|
|
37
|
+
return fallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Pull `attempts_remaining` off an APIError body, or null if absent. */
|
|
41
|
+
export function extractAttemptsRemaining(err: unknown): number | null {
|
|
42
|
+
if (err instanceof APIError) {
|
|
43
|
+
const body = (err.response ?? null) as TwoFactorErrorBody | null;
|
|
44
|
+
if (typeof body?.attempts_remaining === 'number') return body.attempts_remaining;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** True when `code` is a well-formed 6-digit TOTP code. */
|
|
50
|
+
export function isValidTotpCode(code: string): boolean {
|
|
51
|
+
return !!code && code.length === TOTP_CODE_LENGTH;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { TOTP_CODE_LENGTH };
|
|
@@ -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 { useTwoFactorVerify } from './useTwoFactorVerify';
|
|
12
12
|
|
|
13
13
|
import type { AuthFormReturn, UseAuthFormOptions } from '../types';
|
|
14
14
|
/**
|
|
@@ -70,7 +70,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
70
70
|
} = formState;
|
|
71
71
|
|
|
72
72
|
// 2FA verification hook - skip redirect so we can show success screen
|
|
73
|
-
const twoFactor =
|
|
73
|
+
const twoFactor = useTwoFactorVerify({
|
|
74
74
|
onSuccess: () => {
|
|
75
75
|
authLogger.info('2FA verification successful, showing success screen');
|
|
76
76
|
setStep('success');
|
|
@@ -1,262 +1,76 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
3
|
+
import {
|
|
4
|
+
useTwoFactorVerify,
|
|
5
|
+
type UseTwoFactorVerifyOptions,
|
|
6
|
+
type UseTwoFactorVerifyReturn,
|
|
7
|
+
} from './useTwoFactorVerify';
|
|
8
|
+
import {
|
|
9
|
+
useTwoFactorSetup,
|
|
10
|
+
type TwoFactorSetupData,
|
|
11
|
+
type UseTwoFactorSetupOptions,
|
|
12
|
+
type UseTwoFactorSetupReturn,
|
|
13
|
+
} from './useTwoFactorSetup';
|
|
14
|
+
import {
|
|
15
|
+
useTwoFactorStatus,
|
|
16
|
+
type TwoFactorDevice,
|
|
17
|
+
type UseTwoFactorStatusReturn,
|
|
18
|
+
} from './useTwoFactorStatus';
|
|
13
19
|
|
|
14
20
|
export interface UseTwoFactorOptions {
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
/** URL to redirect after successful verification */
|
|
20
|
-
redirectUrl?: string;
|
|
21
|
-
/** Skip automatic redirect after success (caller handles navigation) */
|
|
22
|
-
skipRedirect?: boolean;
|
|
21
|
+
/** Options for the login-time verification namespace */
|
|
22
|
+
verify?: UseTwoFactorVerifyOptions;
|
|
23
|
+
/** Options for the setup (enable) namespace */
|
|
24
|
+
setup?: UseTwoFactorSetupOptions;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface UseTwoFactorReturn {
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
/** Remaining backup codes (if backup code was used) */
|
|
33
|
-
remainingBackupCodes: number | null;
|
|
34
|
-
/** Remaining verification attempts before lockout */
|
|
35
|
-
attemptsRemaining: number | null;
|
|
36
|
-
/** Verify TOTP code */
|
|
37
|
-
verifyTOTP: (sessionId: string, code: string) => Promise<boolean>;
|
|
38
|
-
/** Verify backup code */
|
|
39
|
-
verifyBackupCode: (sessionId: string, backupCode: string) => Promise<boolean>;
|
|
40
|
-
/** Clear error */
|
|
41
|
-
clearError: () => void;
|
|
28
|
+
/** Verify a 2FA challenge during login (TOTP / backup code). */
|
|
29
|
+
verify: UseTwoFactorVerifyReturn;
|
|
30
|
+
/** Enable 2FA — QR provisioning → confirm → backup codes. */
|
|
31
|
+
setup: UseTwoFactorSetupReturn;
|
|
32
|
+
/** Read device list / enabled flag and disable 2FA. */
|
|
33
|
+
status: UseTwoFactorStatusReturn;
|
|
42
34
|
}
|
|
43
35
|
|
|
44
36
|
/**
|
|
45
|
-
*
|
|
37
|
+
* Single entry point for all two-factor authentication flows.
|
|
46
38
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* 3. Call verifyTOTP(sessionId, code) to complete authentication
|
|
39
|
+
* The three concerns — **verify** (login), **setup** (enable) and **status**
|
|
40
|
+
* (manage/disable) — are distinct verbs over the same `CfgTotp*` API, so each
|
|
41
|
+
* is exposed as its own namespace with an independent loading/error surface:
|
|
51
42
|
*
|
|
52
43
|
* @example
|
|
53
44
|
* ```tsx
|
|
54
|
-
* const {
|
|
55
|
-
* onSuccess: (user) => console.log('Logged in:', user),
|
|
56
|
-
* onError: (error) => console.error(error),
|
|
57
|
-
* redirectUrl: '/dashboard',
|
|
58
|
-
* });
|
|
45
|
+
* const two = useTwoFactor({ verify: { redirectUrl: '/dashboard' } });
|
|
59
46
|
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* } else {
|
|
64
|
-
* await verifyTOTP(sessionId, code);
|
|
65
|
-
* }
|
|
66
|
-
* };
|
|
47
|
+
* await two.verify.verifyTOTP(sessionId, code); // login
|
|
48
|
+
* await two.setup.startSetup('My iPhone'); // enable
|
|
49
|
+
* await two.status.fetchStatus(); // manage
|
|
67
50
|
* ```
|
|
51
|
+
*
|
|
52
|
+
* Consumers that only need one concern can compose the underlying hooks
|
|
53
|
+
* directly (`useTwoFactorVerify` / `useTwoFactorSetup` / `useTwoFactorStatus`),
|
|
54
|
+
* all re-exported alongside this one, to avoid mounting the others' state.
|
|
68
55
|
*/
|
|
69
56
|
export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorReturn => {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
74
|
-
const [error, setError] = useState<string | null>(null);
|
|
75
|
-
const [warning, setWarning] = useState<string | null>(null);
|
|
76
|
-
const [remainingBackupCodes, setRemainingBackupCodes] = useState<number | null>(null);
|
|
77
|
-
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
|
78
|
-
|
|
79
|
-
const clearError = useCallback(() => {
|
|
80
|
-
setError(null);
|
|
81
|
-
}, []);
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Handle successful 2FA verification
|
|
85
|
-
*/
|
|
86
|
-
const handleSuccess = useCallback((response: {
|
|
87
|
-
access_token: string;
|
|
88
|
-
refresh_token: string;
|
|
89
|
-
user: any;
|
|
90
|
-
warning?: string;
|
|
91
|
-
remaining_backup_codes?: number;
|
|
92
|
-
}) => {
|
|
93
|
-
// Save tokens — the ONE write path (atomic pair + session notification).
|
|
94
|
-
auth.setSession({ access: response.access_token, refresh: response.refresh_token });
|
|
95
|
-
|
|
96
|
-
// Set warning if any
|
|
97
|
-
if (response.warning) {
|
|
98
|
-
setWarning(response.warning);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Set remaining backup codes if provided
|
|
102
|
-
if (response.remaining_backup_codes !== undefined) {
|
|
103
|
-
setRemainingBackupCodes(response.remaining_backup_codes);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Track successful 2FA
|
|
107
|
-
Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
|
|
108
|
-
category: AnalyticsCategory.AUTH,
|
|
109
|
-
label: '2fa',
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
// Set user ID for tracking
|
|
113
|
-
if (response.user?.id) {
|
|
114
|
-
Analytics.setUser(String(response.user.id));
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Call success callback
|
|
118
|
-
onSuccess?.(response.user);
|
|
119
|
-
|
|
120
|
-
// Redirect (unless skipRedirect is true).
|
|
121
|
-
// Priority: saved back-url (guard intent) > explicit option > default.
|
|
122
|
-
if (!skipRedirect) {
|
|
123
|
-
const finalRedirectUrl = consumeSavedRedirect() || redirectUrl || '/';
|
|
124
|
-
authLogger.info('2FA successful, redirecting to:', finalRedirectUrl);
|
|
125
|
-
router.hardPush(finalRedirectUrl);
|
|
126
|
-
}
|
|
127
|
-
}, [onSuccess, redirectUrl, router, skipRedirect]);
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Verify TOTP code from authenticator app
|
|
131
|
-
*/
|
|
132
|
-
const verifyTOTP = useCallback(async (sessionId: string, code: string): Promise<boolean> => {
|
|
133
|
-
if (!sessionId) {
|
|
134
|
-
const msg = 'Missing 2FA session ID';
|
|
135
|
-
setError(msg);
|
|
136
|
-
onError?.(msg);
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (!code || code.length !== 6) {
|
|
141
|
-
const msg = 'Please enter a 6-digit code';
|
|
142
|
-
setError(msg);
|
|
143
|
-
onError?.(msg);
|
|
144
|
-
return false;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
setIsLoading(true);
|
|
148
|
-
setError(null);
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
authLogger.info('Verifying TOTP code...');
|
|
152
|
-
|
|
153
|
-
const result = await CfgTotpVerify.cfgTotpVerifyCreate({
|
|
154
|
-
body: { session_id: sessionId, code },
|
|
155
|
-
throwOnError: true,
|
|
156
|
-
});
|
|
157
|
-
const response = result.data;
|
|
57
|
+
const verify = useTwoFactorVerify(options.verify);
|
|
58
|
+
const setup = useTwoFactorSetup(options.setup);
|
|
59
|
+
const status = useTwoFactorStatus();
|
|
158
60
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
handleSuccess(response);
|
|
164
|
-
return true;
|
|
165
|
-
|
|
166
|
-
} catch (err) {
|
|
167
|
-
authLogger.error('2FA TOTP verification error:', err);
|
|
168
|
-
const errorMessage = err instanceof APIError
|
|
169
|
-
? (err.response?.error || err.response?.detail || err.response?.message || err.errorMessage)
|
|
170
|
-
: (err instanceof Error ? err.message : 'Invalid verification code');
|
|
171
|
-
if (err instanceof APIError && typeof err.response?.attempts_remaining === 'number') {
|
|
172
|
-
setAttemptsRemaining(err.response.attempts_remaining);
|
|
173
|
-
}
|
|
174
|
-
setError(errorMessage);
|
|
175
|
-
onError?.(errorMessage);
|
|
176
|
-
|
|
177
|
-
// Track failed 2FA
|
|
178
|
-
Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
|
|
179
|
-
category: AnalyticsCategory.AUTH,
|
|
180
|
-
label: '2fa-totp',
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
return false;
|
|
184
|
-
} finally {
|
|
185
|
-
setIsLoading(false);
|
|
186
|
-
}
|
|
187
|
-
}, [handleSuccess, onError]);
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Verify backup recovery code
|
|
191
|
-
*/
|
|
192
|
-
const verifyBackupCode = useCallback(async (sessionId: string, backupCode: string): Promise<boolean> => {
|
|
193
|
-
if (!sessionId) {
|
|
194
|
-
const msg = 'Missing 2FA session ID';
|
|
195
|
-
setError(msg);
|
|
196
|
-
onError?.(msg);
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (!backupCode || backupCode.length < 8) {
|
|
201
|
-
const msg = 'Please enter your backup code';
|
|
202
|
-
setError(msg);
|
|
203
|
-
onError?.(msg);
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
setIsLoading(true);
|
|
208
|
-
setError(null);
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
authLogger.info('Verifying backup code...');
|
|
212
|
-
|
|
213
|
-
const result = await CfgTotpVerify.cfgTotpVerifyBackupCreate({
|
|
214
|
-
body: {
|
|
215
|
-
session_id: sessionId,
|
|
216
|
-
backup_code: backupCode.replace(/\s+/g, ''),
|
|
217
|
-
},
|
|
218
|
-
throwOnError: true,
|
|
219
|
-
});
|
|
220
|
-
const response = result.data;
|
|
221
|
-
|
|
222
|
-
if (!response.access_token || !response.refresh_token) {
|
|
223
|
-
throw new Error('Invalid response from backup code verification');
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
handleSuccess(response);
|
|
227
|
-
return true;
|
|
228
|
-
|
|
229
|
-
} catch (err) {
|
|
230
|
-
authLogger.error('2FA backup code verification error:', err);
|
|
231
|
-
const errorMessage = err instanceof APIError
|
|
232
|
-
? (err.response?.error || err.response?.detail || err.response?.message || err.errorMessage)
|
|
233
|
-
: (err instanceof Error ? err.message : 'Invalid backup code');
|
|
234
|
-
if (err instanceof APIError && typeof err.response?.attempts_remaining === 'number') {
|
|
235
|
-
setAttemptsRemaining(err.response.attempts_remaining);
|
|
236
|
-
}
|
|
237
|
-
setError(errorMessage);
|
|
238
|
-
onError?.(errorMessage);
|
|
239
|
-
|
|
240
|
-
// Track failed 2FA
|
|
241
|
-
Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
|
|
242
|
-
category: AnalyticsCategory.AUTH,
|
|
243
|
-
label: '2fa-backup',
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return false;
|
|
247
|
-
} finally {
|
|
248
|
-
setIsLoading(false);
|
|
249
|
-
}
|
|
250
|
-
}, [handleSuccess, onError]);
|
|
61
|
+
return { verify, setup, status };
|
|
62
|
+
};
|
|
251
63
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
64
|
+
// Namespaced sub-hooks — use directly when only one concern is needed.
|
|
65
|
+
export {
|
|
66
|
+
useTwoFactorVerify,
|
|
67
|
+
useTwoFactorSetup,
|
|
68
|
+
useTwoFactorStatus,
|
|
69
|
+
type UseTwoFactorVerifyOptions,
|
|
70
|
+
type UseTwoFactorVerifyReturn,
|
|
71
|
+
type UseTwoFactorSetupOptions,
|
|
72
|
+
type UseTwoFactorSetupReturn,
|
|
73
|
+
type UseTwoFactorStatusReturn,
|
|
74
|
+
type TwoFactorSetupData,
|
|
75
|
+
type TwoFactorDevice,
|
|
262
76
|
};
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useState } from 'react';
|
|
4
4
|
|
|
5
|
-
import { apiTotp } from '../../clients';
|
|
6
5
|
import { CfgTotpSetup } from '../../_api/generated/sdk.gen';
|
|
7
6
|
import { authLogger } from '../utils/logger';
|
|
7
|
+
import { extractTwoFactorError, isValidTotpCode } from './twoFactor.shared';
|
|
8
8
|
|
|
9
9
|
export interface TwoFactorSetupData {
|
|
10
10
|
/** Device ID to use for confirmation */
|
|
@@ -50,40 +50,13 @@ export interface UseTwoFactorSetupReturn {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
*
|
|
53
|
+
* 2FA setup — enabling TOTP (internal, composed by {@link useTwoFactor}).
|
|
54
54
|
*
|
|
55
|
-
* Flow:
|
|
56
|
-
* 1. Call startSetup() to get QR code and provisioning URI
|
|
57
|
-
* 2. User scans QR code with authenticator app
|
|
58
|
-
* 3. Call confirmSetup(code) with the 6-digit code from app
|
|
59
|
-
* 4. Show backup codes to user (they must save these!)
|
|
60
|
-
*
|
|
61
|
-
* @example
|
|
62
|
-
* ```tsx
|
|
63
|
-
* const {
|
|
64
|
-
* isLoading,
|
|
65
|
-
* error,
|
|
66
|
-
* setupData,
|
|
67
|
-
* backupCodes,
|
|
68
|
-
* setupStep,
|
|
69
|
-
* startSetup,
|
|
70
|
-
* confirmSetup,
|
|
71
|
-
* } = useTwoFactorSetup({
|
|
72
|
-
* onComplete: (codes) => console.log('Backup codes:', codes),
|
|
73
|
-
* onError: (error) => console.error(error),
|
|
74
|
-
* });
|
|
75
|
-
*
|
|
76
|
-
* // Start setup
|
|
77
|
-
* const data = await startSetup('My iPhone');
|
|
78
|
-
*
|
|
79
|
-
* // Show QR code
|
|
80
|
-
* <QRCodeSVG value={data.provisioningUri} />
|
|
81
|
-
*
|
|
82
|
-
* // Confirm with code from app
|
|
83
|
-
* const codes = await confirmSetup('123456');
|
|
84
|
-
* ```
|
|
55
|
+
* Flow: startSetup() → user scans QR → confirmSetup(code) → show backup codes.
|
|
85
56
|
*/
|
|
86
|
-
export const useTwoFactorSetup = (
|
|
57
|
+
export const useTwoFactorSetup = (
|
|
58
|
+
options: UseTwoFactorSetupOptions = {},
|
|
59
|
+
): UseTwoFactorSetupReturn => {
|
|
87
60
|
const { onComplete, onError } = options;
|
|
88
61
|
|
|
89
62
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -105,9 +78,6 @@ export const useTwoFactorSetup = (options: UseTwoFactorSetupOptions = {}): UseTw
|
|
|
105
78
|
setError(null);
|
|
106
79
|
}, []);
|
|
107
80
|
|
|
108
|
-
/**
|
|
109
|
-
* Start 2FA setup - generates QR code and secret
|
|
110
|
-
*/
|
|
111
81
|
const startSetup = useCallback(async (deviceName?: string): Promise<TwoFactorSetupData | null> => {
|
|
112
82
|
setIsLoading(true);
|
|
113
83
|
setError(null);
|
|
@@ -134,9 +104,8 @@ export const useTwoFactorSetup = (options: UseTwoFactorSetupOptions = {}): UseTw
|
|
|
134
104
|
authLogger.info('2FA setup initiated, expires in:', data.expiresIn, 'seconds');
|
|
135
105
|
|
|
136
106
|
return data;
|
|
137
|
-
|
|
138
107
|
} catch (err) {
|
|
139
|
-
const errorMessage = err
|
|
108
|
+
const errorMessage = extractTwoFactorError(err, 'Failed to start 2FA setup');
|
|
140
109
|
authLogger.error('2FA setup error:', err);
|
|
141
110
|
setError(errorMessage);
|
|
142
111
|
setSetupStep('idle');
|
|
@@ -147,9 +116,6 @@ export const useTwoFactorSetup = (options: UseTwoFactorSetupOptions = {}): UseTw
|
|
|
147
116
|
}
|
|
148
117
|
}, [onError]);
|
|
149
118
|
|
|
150
|
-
/**
|
|
151
|
-
* Confirm 2FA setup with TOTP code from authenticator app
|
|
152
|
-
*/
|
|
153
119
|
const confirmSetup = useCallback(async (code: string): Promise<string[] | null> => {
|
|
154
120
|
if (!setupData) {
|
|
155
121
|
const msg = 'Setup not started. Call startSetup() first.';
|
|
@@ -157,8 +123,7 @@ export const useTwoFactorSetup = (options: UseTwoFactorSetupOptions = {}): UseTw
|
|
|
157
123
|
onError?.(msg);
|
|
158
124
|
return null;
|
|
159
125
|
}
|
|
160
|
-
|
|
161
|
-
if (!code || code.length !== 6) {
|
|
126
|
+
if (!isValidTotpCode(code)) {
|
|
162
127
|
const msg = 'Please enter a 6-digit code';
|
|
163
128
|
setError(msg);
|
|
164
129
|
onError?.(msg);
|
|
@@ -185,13 +150,10 @@ export const useTwoFactorSetup = (options: UseTwoFactorSetupOptions = {}): UseTw
|
|
|
185
150
|
|
|
186
151
|
authLogger.info('2FA setup confirmed, backup codes generated:', codes.length);
|
|
187
152
|
|
|
188
|
-
// Call completion callback
|
|
189
153
|
onComplete?.(codes);
|
|
190
|
-
|
|
191
154
|
return codes;
|
|
192
|
-
|
|
193
155
|
} catch (err) {
|
|
194
|
-
const errorMessage = err
|
|
156
|
+
const errorMessage = extractTwoFactorError(err, 'Invalid code. Please try again.');
|
|
195
157
|
authLogger.error('2FA setup confirmation error:', err);
|
|
196
158
|
setError(errorMessage);
|
|
197
159
|
setSetupStep('scanning'); // Go back to scanning step
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useState } from 'react';
|
|
4
4
|
|
|
5
|
-
import { apiTotp } from '../../clients';
|
|
6
|
-
import { APIError } from '../../_api/generated/helpers';
|
|
7
5
|
import { CfgTotp } from '../../_api/generated/sdk.gen';
|
|
8
6
|
import { authLogger } from '../utils/logger';
|
|
7
|
+
import { extractTwoFactorError, isValidTotpCode } from './twoFactor.shared';
|
|
9
8
|
|
|
10
9
|
export interface TwoFactorDevice {
|
|
11
10
|
id: string;
|
|
@@ -33,35 +32,10 @@ export interface UseTwoFactorStatusReturn {
|
|
|
33
32
|
}
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
|
-
*
|
|
35
|
+
* Read and manage 2FA status (internal, composed by {@link useTwoFactor}).
|
|
37
36
|
*
|
|
38
|
-
*
|
|
39
|
-
* ```tsx
|
|
40
|
-
* const { has2FAEnabled, devices, fetchStatus, disable2FA, isLoading } = useTwoFactorStatus();
|
|
41
|
-
*
|
|
42
|
-
* useEffect(() => {
|
|
43
|
-
* fetchStatus();
|
|
44
|
-
* }, [fetchStatus]);
|
|
45
|
-
*
|
|
46
|
-
* if (has2FAEnabled) {
|
|
47
|
-
* // Show disable button
|
|
48
|
-
* } else {
|
|
49
|
-
* // Show enable button
|
|
50
|
-
* }
|
|
51
|
-
* ```
|
|
37
|
+
* Fetches the device list / enabled flag and disables 2FA.
|
|
52
38
|
*/
|
|
53
|
-
/** Extract a human-readable message from a thrown error. */
|
|
54
|
-
function extractErrorMessage(err: unknown, fallback: string): string {
|
|
55
|
-
if (err instanceof APIError) {
|
|
56
|
-
const body = err.response as Record<string, unknown> | null;
|
|
57
|
-
if (typeof body?.error === 'string') return body.error;
|
|
58
|
-
if (typeof body?.detail === 'string') return body.detail;
|
|
59
|
-
return err.message;
|
|
60
|
-
}
|
|
61
|
-
if (err instanceof Error) return err.message;
|
|
62
|
-
return fallback;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
39
|
export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
|
|
66
40
|
const [isLoading, setIsLoading] = useState(false);
|
|
67
41
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -72,9 +46,6 @@ export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
|
|
|
72
46
|
setError(null);
|
|
73
47
|
}, []);
|
|
74
48
|
|
|
75
|
-
/**
|
|
76
|
-
* Fetch current 2FA status and devices
|
|
77
|
-
*/
|
|
78
49
|
const fetchStatus = useCallback(async (): Promise<void> => {
|
|
79
50
|
setIsLoading(true);
|
|
80
51
|
setError(null);
|
|
@@ -98,9 +69,8 @@ export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
|
|
|
98
69
|
setHas2FAEnabled(response.has_2fa_enabled);
|
|
99
70
|
|
|
100
71
|
authLogger.info('2FA status:', response.has_2fa_enabled ? 'enabled' : 'disabled');
|
|
101
|
-
|
|
102
72
|
} catch (err) {
|
|
103
|
-
const errorMessage =
|
|
73
|
+
const errorMessage = extractTwoFactorError(err, 'Failed to fetch 2FA status');
|
|
104
74
|
authLogger.error('Failed to fetch 2FA status:', err);
|
|
105
75
|
setError(errorMessage);
|
|
106
76
|
} finally {
|
|
@@ -108,11 +78,8 @@ export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
|
|
|
108
78
|
}
|
|
109
79
|
}, []);
|
|
110
80
|
|
|
111
|
-
/**
|
|
112
|
-
* Disable 2FA for the account
|
|
113
|
-
*/
|
|
114
81
|
const disable2FA = useCallback(async (code: string): Promise<boolean> => {
|
|
115
|
-
if (!code
|
|
82
|
+
if (!isValidTotpCode(code)) {
|
|
116
83
|
setError('Please enter a 6-digit code');
|
|
117
84
|
return false;
|
|
118
85
|
}
|
|
@@ -130,9 +97,8 @@ export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
|
|
|
130
97
|
|
|
131
98
|
authLogger.info('2FA disabled successfully');
|
|
132
99
|
return true;
|
|
133
|
-
|
|
134
100
|
} catch (err: unknown) {
|
|
135
|
-
const errorMessage =
|
|
101
|
+
const errorMessage = extractTwoFactorError(err, 'Invalid verification code');
|
|
136
102
|
authLogger.error('Failed to disable 2FA:', err);
|
|
137
103
|
setError(errorMessage);
|
|
138
104
|
return false;
|