@djangocfg/api 2.1.57 → 2.1.59
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 +125 -9
- package/dist/auth.cjs +1865 -402
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +352 -76
- package/dist/auth.d.ts +352 -76
- package/dist/auth.mjs +1867 -404
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +1637 -137
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +1394 -282
- package/dist/clients.d.ts +1394 -282
- package/dist/clients.mjs +1637 -137
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +24 -11
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.d.cts +88 -21
- package/dist/hooks.d.ts +88 -21
- package/dist/hooks.mjs +24 -11
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +38 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +94 -21
- package/dist/index.d.ts +94 -21
- package/dist/index.mjs +38 -17
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/auth/context/AccountsContext.tsx +8 -1
- package/src/auth/context/AuthContext.tsx +31 -8
- package/src/auth/context/types.ts +8 -1
- package/src/auth/hooks/index.ts +29 -5
- package/src/auth/hooks/useAuthForm.ts +292 -226
- package/src/auth/hooks/useAuthFormState.ts +60 -0
- package/src/auth/hooks/useAuthValidation.ts +77 -0
- package/src/auth/hooks/useGithubAuth.ts +26 -5
- package/src/auth/hooks/useTwoFactor.ts +239 -0
- package/src/auth/hooks/useTwoFactorSetup.ts +213 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/types/form.ts +194 -0
- package/src/auth/types/index.ts +28 -0
- package/src/clients.ts +10 -0
- package/src/generated/cfg_accounts/_utils/schemas/OAuthTokenResponse.schema.ts +26 -3
- package/src/generated/cfg_accounts/_utils/schemas/OTPVerifyResponse.schema.ts +26 -3
- package/src/generated/cfg_accounts/accounts/client.ts +4 -1
- package/src/generated/cfg_accounts/accounts/models.ts +15 -6
- package/src/generated/cfg_accounts/accounts__oauth/models.ts +16 -7
- package/src/generated/cfg_accounts/client.ts +5 -2
- package/src/generated/cfg_accounts/http.ts +8 -2
- package/src/generated/cfg_accounts/schema.json +47 -19
- package/src/generated/cfg_centrifugo/client.ts +5 -2
- package/src/generated/cfg_centrifugo/http.ts +8 -2
- package/src/generated/cfg_totp/CLAUDE.md +12 -12
- package/src/generated/cfg_totp/_utils/fetchers/index.ts +3 -3
- package/src/generated/cfg_totp/_utils/fetchers/{totp__2fa_management.ts → totp__totp_management.ts} +3 -3
- package/src/generated/cfg_totp/_utils/fetchers/{totp__2fa_setup.ts → totp__totp_setup.ts} +3 -3
- package/src/generated/cfg_totp/_utils/fetchers/{totp__2fa_verification.ts → totp__totp_verification.ts} +3 -3
- package/src/generated/cfg_totp/_utils/hooks/index.ts +3 -3
- package/src/generated/cfg_totp/_utils/hooks/{totp__2fa_management.ts → totp__totp_management.ts} +2 -2
- package/src/generated/cfg_totp/_utils/hooks/{totp__2fa_setup.ts → totp__totp_setup.ts} +2 -2
- package/src/generated/cfg_totp/_utils/hooks/{totp__2fa_verification.ts → totp__totp_verification.ts} +2 -2
- package/src/generated/cfg_totp/_utils/schemas/DeviceList.schema.ts +1 -1
- package/src/generated/cfg_totp/client.ts +14 -11
- package/src/generated/cfg_totp/http.ts +8 -2
- package/src/generated/cfg_totp/index.ts +16 -16
- package/src/generated/cfg_totp/schema.json +8 -7
- package/src/generated/cfg_totp/{totp__2fa_management → totp__totp_management}/client.ts +2 -2
- package/src/generated/cfg_totp/{totp__2fa_management → totp__totp_management}/models.ts +1 -1
- package/src/generated/cfg_totp/{totp__2fa_setup → totp__totp_setup}/client.ts +4 -4
- package/src/generated/cfg_totp/{totp__2fa_verification → totp__totp_verification}/client.ts +2 -2
- package/src/generated/cfg_webpush/client.ts +5 -2
- package/src/generated/cfg_webpush/http.ts +8 -2
- /package/src/generated/cfg_totp/{totp__2fa_management → totp__totp_management}/index.ts +0 -0
- /package/src/generated/cfg_totp/{totp__2fa_setup → totp__totp_setup}/index.ts +0 -0
- /package/src/generated/cfg_totp/{totp__2fa_setup → totp__totp_setup}/models.ts +0 -0
- /package/src/generated/cfg_totp/{totp__2fa_verification → totp__totp_verification}/index.ts +0 -0
- /package/src/generated/cfg_totp/{totp__2fa_verification → totp__totp_verification}/models.ts +0 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { AuthChannel, AuthFormState, AuthFormStateHandlers, AuthStep } from '../types';
|
|
6
|
+
|
|
7
|
+
export interface UseAuthFormStateReturn extends AuthFormState, AuthFormStateHandlers {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hook for auth form state management.
|
|
11
|
+
* Pure state - no side effects, no API calls.
|
|
12
|
+
*/
|
|
13
|
+
export const useAuthFormState = (
|
|
14
|
+
initialIdentifier = '',
|
|
15
|
+
initialChannel: AuthChannel = 'email'
|
|
16
|
+
): UseAuthFormStateReturn => {
|
|
17
|
+
const [identifier, setIdentifier] = useState(initialIdentifier);
|
|
18
|
+
const [channel, setChannel] = useState<AuthChannel>(initialChannel);
|
|
19
|
+
const [otp, setOtp] = useState('');
|
|
20
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
21
|
+
const [acceptedTerms, setAcceptedTerms] = useState(false);
|
|
22
|
+
const [step, setStep] = useState<AuthStep>('identifier');
|
|
23
|
+
const [error, setError] = useState('');
|
|
24
|
+
|
|
25
|
+
// 2FA state
|
|
26
|
+
const [twoFactorSessionId, setTwoFactorSessionId] = useState<string | null>(null);
|
|
27
|
+
const [shouldPrompt2FA, setShouldPrompt2FA] = useState(false);
|
|
28
|
+
const [twoFactorCode, setTwoFactorCode] = useState('');
|
|
29
|
+
const [useBackupCode, setUseBackupCode] = useState(false);
|
|
30
|
+
|
|
31
|
+
const clearError = useCallback(() => setError(''), []);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
// State
|
|
35
|
+
identifier,
|
|
36
|
+
channel,
|
|
37
|
+
otp,
|
|
38
|
+
isLoading,
|
|
39
|
+
acceptedTerms,
|
|
40
|
+
step,
|
|
41
|
+
error,
|
|
42
|
+
twoFactorSessionId,
|
|
43
|
+
shouldPrompt2FA,
|
|
44
|
+
twoFactorCode,
|
|
45
|
+
useBackupCode,
|
|
46
|
+
// Handlers
|
|
47
|
+
setIdentifier,
|
|
48
|
+
setChannel,
|
|
49
|
+
setOtp,
|
|
50
|
+
setAcceptedTerms,
|
|
51
|
+
setError,
|
|
52
|
+
clearError,
|
|
53
|
+
setStep,
|
|
54
|
+
setIsLoading,
|
|
55
|
+
setTwoFactorSessionId,
|
|
56
|
+
setShouldPrompt2FA,
|
|
57
|
+
setTwoFactorCode,
|
|
58
|
+
setUseBackupCode,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { AuthChannel, AuthFormValidation } from '../types';
|
|
6
|
+
|
|
7
|
+
// Email regex - RFC 5322 simplified
|
|
8
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
9
|
+
|
|
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
|
+
export const useAuthValidation = (): AuthFormValidation => {
|
|
18
|
+
/**
|
|
19
|
+
* Detect channel type from identifier string.
|
|
20
|
+
* Returns 'email' if contains @, 'phone' if starts with + and matches E.164.
|
|
21
|
+
*/
|
|
22
|
+
const detectChannelFromIdentifier = useCallback((id: string): AuthChannel | null => {
|
|
23
|
+
if (!id) return null;
|
|
24
|
+
|
|
25
|
+
// Email detection - contains @
|
|
26
|
+
if (id.includes('@')) {
|
|
27
|
+
return 'email';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Phone detection - starts with + and matches E.164 format
|
|
31
|
+
if (id.startsWith('+') && PHONE_REGEX.test(id)) {
|
|
32
|
+
return 'phone';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
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
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Export standalone functions for use outside React
|
|
64
|
+
export const detectChannelFromIdentifier = (id: string): AuthChannel | null => {
|
|
65
|
+
if (!id) return null;
|
|
66
|
+
if (id.includes('@')) return 'email';
|
|
67
|
+
if (id.startsWith('+') && PHONE_REGEX.test(id)) return 'phone';
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const validateIdentifier = (id: string, channelType?: AuthChannel): boolean => {
|
|
72
|
+
if (!id) return false;
|
|
73
|
+
const channel = channelType || detectChannelFromIdentifier(id);
|
|
74
|
+
if (channel === 'email') return EMAIL_REGEX.test(id);
|
|
75
|
+
if (channel === 'phone') return PHONE_REGEX.test(id);
|
|
76
|
+
return false;
|
|
77
|
+
};
|
|
@@ -12,7 +12,11 @@ export interface UseGithubAuthOptions {
|
|
|
12
12
|
sourceUrl?: string;
|
|
13
13
|
onSuccess?: (user: any, isNewUser: boolean) => void;
|
|
14
14
|
onError?: (error: string) => void;
|
|
15
|
+
/** Callback when 2FA is required */
|
|
16
|
+
onRequires2FA?: (sessionId: string, shouldPrompt2FA: boolean) => void;
|
|
15
17
|
redirectUrl?: string;
|
|
18
|
+
/** Skip automatic redirect after success (caller handles navigation) */
|
|
19
|
+
skipRedirect?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface UseGithubAuthReturn {
|
|
@@ -42,7 +46,7 @@ export interface UseGithubAuthReturn {
|
|
|
42
46
|
* ```
|
|
43
47
|
*/
|
|
44
48
|
export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuthReturn => {
|
|
45
|
-
const { sourceUrl, onSuccess, onError, redirectUrl } = options;
|
|
49
|
+
const { sourceUrl, onSuccess, onError, onRequires2FA, redirectUrl, skipRedirect = false } = options;
|
|
46
50
|
const router = useCfgRouter();
|
|
47
51
|
|
|
48
52
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -132,6 +136,21 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
|
|
|
132
136
|
state,
|
|
133
137
|
});
|
|
134
138
|
|
|
139
|
+
// Check if 2FA is required
|
|
140
|
+
if (response.requires_2fa && response.session_id) {
|
|
141
|
+
authLogger.info('GitHub OAuth requires 2FA, session:', response.session_id);
|
|
142
|
+
|
|
143
|
+
// Track 2FA requirement
|
|
144
|
+
Analytics.event(AnalyticsEvent.AUTH_OAUTH_START, {
|
|
145
|
+
category: AnalyticsCategory.AUTH,
|
|
146
|
+
label: 'github-2fa-required',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Call 2FA callback
|
|
150
|
+
onRequires2FA?.(response.session_id, response.should_prompt_2fa || false);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
135
154
|
if (!response.access || !response.refresh) {
|
|
136
155
|
throw new Error('Invalid response from OAuth callback');
|
|
137
156
|
}
|
|
@@ -155,10 +174,12 @@ export const useGithubAuth = (options: UseGithubAuthOptions = {}): UseGithubAuth
|
|
|
155
174
|
// Call success callback
|
|
156
175
|
onSuccess?.(response.user, response.is_new_user || false);
|
|
157
176
|
|
|
158
|
-
// Redirect
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
177
|
+
// Redirect (unless skipRedirect is true)
|
|
178
|
+
if (!skipRedirect) {
|
|
179
|
+
// Use hardPush for full page reload - ensures all React contexts reinitialize
|
|
180
|
+
const finalRedirectUrl = redirectUrl || '/dashboard';
|
|
181
|
+
router.hardPush(finalRedirectUrl);
|
|
182
|
+
}
|
|
162
183
|
|
|
163
184
|
} catch (err) {
|
|
164
185
|
const errorMessage = err instanceof Error ? err.message : 'GitHub authentication failed';
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useCfgRouter } from '@djangocfg/ui-nextjs/hooks';
|
|
6
|
+
|
|
7
|
+
import { apiAccounts, apiTotp } from '../../clients';
|
|
8
|
+
import { Analytics, AnalyticsCategory, AnalyticsEvent } from '../utils/analytics';
|
|
9
|
+
import { authLogger } from '../utils/logger';
|
|
10
|
+
|
|
11
|
+
export interface UseTwoFactorOptions {
|
|
12
|
+
/** Callback on successful 2FA verification */
|
|
13
|
+
onSuccess?: (user: any) => void;
|
|
14
|
+
/** Callback on error */
|
|
15
|
+
onError?: (error: string) => void;
|
|
16
|
+
/** URL to redirect after successful verification */
|
|
17
|
+
redirectUrl?: string;
|
|
18
|
+
/** Skip automatic redirect after success (caller handles navigation) */
|
|
19
|
+
skipRedirect?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseTwoFactorReturn {
|
|
23
|
+
/** Loading state */
|
|
24
|
+
isLoading: boolean;
|
|
25
|
+
/** Error message */
|
|
26
|
+
error: string | null;
|
|
27
|
+
/** Warning message (e.g., low backup codes) */
|
|
28
|
+
warning: string | null;
|
|
29
|
+
/** Remaining backup codes (if backup code was used) */
|
|
30
|
+
remainingBackupCodes: number | null;
|
|
31
|
+
/** Verify TOTP code */
|
|
32
|
+
verifyTOTP: (sessionId: string, code: string) => Promise<boolean>;
|
|
33
|
+
/** Verify backup code */
|
|
34
|
+
verifyBackupCode: (sessionId: string, backupCode: string) => Promise<boolean>;
|
|
35
|
+
/** Clear error */
|
|
36
|
+
clearError: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Hook for 2FA verification during login.
|
|
41
|
+
*
|
|
42
|
+
* Usage:
|
|
43
|
+
* 1. After OTP/OAuth verification returns requires_2fa=true and session_id
|
|
44
|
+
* 2. Show 2FA form and collect TOTP code from user
|
|
45
|
+
* 3. Call verifyTOTP(sessionId, code) to complete authentication
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* const { isLoading, error, verifyTOTP, verifyBackupCode } = useTwoFactor({
|
|
50
|
+
* onSuccess: (user) => console.log('Logged in:', user),
|
|
51
|
+
* onError: (error) => console.error(error),
|
|
52
|
+
* redirectUrl: '/dashboard',
|
|
53
|
+
* });
|
|
54
|
+
*
|
|
55
|
+
* const handleSubmit = async (code: string) => {
|
|
56
|
+
* if (useBackupCode) {
|
|
57
|
+
* await verifyBackupCode(sessionId, code);
|
|
58
|
+
* } else {
|
|
59
|
+
* await verifyTOTP(sessionId, code);
|
|
60
|
+
* }
|
|
61
|
+
* };
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export const useTwoFactor = (options: UseTwoFactorOptions = {}): UseTwoFactorReturn => {
|
|
65
|
+
const { onSuccess, onError, redirectUrl, skipRedirect = false } = options;
|
|
66
|
+
const router = useCfgRouter();
|
|
67
|
+
|
|
68
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
const [warning, setWarning] = useState<string | null>(null);
|
|
71
|
+
const [remainingBackupCodes, setRemainingBackupCodes] = useState<number | null>(null);
|
|
72
|
+
|
|
73
|
+
const clearError = useCallback(() => {
|
|
74
|
+
setError(null);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handle successful 2FA verification
|
|
79
|
+
*/
|
|
80
|
+
const handleSuccess = useCallback((response: {
|
|
81
|
+
access_token: string;
|
|
82
|
+
refresh_token: string;
|
|
83
|
+
user: any;
|
|
84
|
+
warning?: string;
|
|
85
|
+
remaining_backup_codes?: number;
|
|
86
|
+
}) => {
|
|
87
|
+
// Save tokens
|
|
88
|
+
apiAccounts.setToken(response.access_token, response.refresh_token);
|
|
89
|
+
|
|
90
|
+
// Set warning if any
|
|
91
|
+
if (response.warning) {
|
|
92
|
+
setWarning(response.warning);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Set remaining backup codes if provided
|
|
96
|
+
if (response.remaining_backup_codes !== undefined) {
|
|
97
|
+
setRemainingBackupCodes(response.remaining_backup_codes);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Track successful 2FA
|
|
101
|
+
Analytics.event(AnalyticsEvent.AUTH_LOGIN_SUCCESS, {
|
|
102
|
+
category: AnalyticsCategory.AUTH,
|
|
103
|
+
label: '2fa',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Set user ID for tracking
|
|
107
|
+
if (response.user?.id) {
|
|
108
|
+
Analytics.setUser(String(response.user.id));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Call success callback
|
|
112
|
+
onSuccess?.(response.user);
|
|
113
|
+
|
|
114
|
+
// Redirect (unless skipRedirect is true)
|
|
115
|
+
if (!skipRedirect) {
|
|
116
|
+
const finalRedirectUrl = redirectUrl || '/dashboard';
|
|
117
|
+
authLogger.info('2FA successful, redirecting to:', finalRedirectUrl);
|
|
118
|
+
router.hardPush(finalRedirectUrl);
|
|
119
|
+
}
|
|
120
|
+
}, [onSuccess, redirectUrl, router, skipRedirect]);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Verify TOTP code from authenticator app
|
|
124
|
+
*/
|
|
125
|
+
const verifyTOTP = useCallback(async (sessionId: string, code: string): Promise<boolean> => {
|
|
126
|
+
if (!sessionId) {
|
|
127
|
+
const msg = 'Missing 2FA session ID';
|
|
128
|
+
setError(msg);
|
|
129
|
+
onError?.(msg);
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!code || code.length !== 6) {
|
|
134
|
+
const msg = 'Please enter a 6-digit code';
|
|
135
|
+
setError(msg);
|
|
136
|
+
onError?.(msg);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setIsLoading(true);
|
|
141
|
+
setError(null);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
authLogger.info('Verifying TOTP code...');
|
|
145
|
+
|
|
146
|
+
const response = await apiTotp.totp_verification.totpVerifyCreate({
|
|
147
|
+
session_id: sessionId,
|
|
148
|
+
code,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (!response.access_token || !response.refresh_token) {
|
|
152
|
+
throw new Error('Invalid response from 2FA verification');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
handleSuccess(response);
|
|
156
|
+
return true;
|
|
157
|
+
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const errorMessage = err instanceof Error ? err.message : 'Invalid verification code';
|
|
160
|
+
authLogger.error('2FA TOTP verification error:', err);
|
|
161
|
+
setError(errorMessage);
|
|
162
|
+
onError?.(errorMessage);
|
|
163
|
+
|
|
164
|
+
// Track failed 2FA
|
|
165
|
+
Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
|
|
166
|
+
category: AnalyticsCategory.AUTH,
|
|
167
|
+
label: '2fa-totp',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return false;
|
|
171
|
+
} finally {
|
|
172
|
+
setIsLoading(false);
|
|
173
|
+
}
|
|
174
|
+
}, [handleSuccess, onError]);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Verify backup recovery code
|
|
178
|
+
*/
|
|
179
|
+
const verifyBackupCode = useCallback(async (sessionId: string, backupCode: string): Promise<boolean> => {
|
|
180
|
+
if (!sessionId) {
|
|
181
|
+
const msg = 'Missing 2FA session ID';
|
|
182
|
+
setError(msg);
|
|
183
|
+
onError?.(msg);
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!backupCode || backupCode.length < 8) {
|
|
188
|
+
const msg = 'Please enter your backup code';
|
|
189
|
+
setError(msg);
|
|
190
|
+
onError?.(msg);
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setIsLoading(true);
|
|
195
|
+
setError(null);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
authLogger.info('Verifying backup code...');
|
|
199
|
+
|
|
200
|
+
const response = await apiTotp.totp_verification.totpVerifyBackupCreate({
|
|
201
|
+
session_id: sessionId,
|
|
202
|
+
backup_code: backupCode.replace(/\s+/g, ''), // Remove spaces
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (!response.access_token || !response.refresh_token) {
|
|
206
|
+
throw new Error('Invalid response from backup code verification');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
handleSuccess(response);
|
|
210
|
+
return true;
|
|
211
|
+
|
|
212
|
+
} catch (err) {
|
|
213
|
+
const errorMessage = err instanceof Error ? err.message : 'Invalid backup code';
|
|
214
|
+
authLogger.error('2FA backup code verification error:', err);
|
|
215
|
+
setError(errorMessage);
|
|
216
|
+
onError?.(errorMessage);
|
|
217
|
+
|
|
218
|
+
// Track failed 2FA
|
|
219
|
+
Analytics.event(AnalyticsEvent.AUTH_OTP_VERIFY_FAIL, {
|
|
220
|
+
category: AnalyticsCategory.AUTH,
|
|
221
|
+
label: '2fa-backup',
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return false;
|
|
225
|
+
} finally {
|
|
226
|
+
setIsLoading(false);
|
|
227
|
+
}
|
|
228
|
+
}, [handleSuccess, onError]);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
isLoading,
|
|
232
|
+
error,
|
|
233
|
+
warning,
|
|
234
|
+
remainingBackupCodes,
|
|
235
|
+
verifyTOTP,
|
|
236
|
+
verifyBackupCode,
|
|
237
|
+
clearError,
|
|
238
|
+
};
|
|
239
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { apiTotp } from '../../clients';
|
|
6
|
+
import { authLogger } from '../utils/logger';
|
|
7
|
+
|
|
8
|
+
export interface TwoFactorSetupData {
|
|
9
|
+
/** Device ID to use for confirmation */
|
|
10
|
+
deviceId: string;
|
|
11
|
+
/** Base32-encoded TOTP secret (for manual entry) */
|
|
12
|
+
secret: string;
|
|
13
|
+
/** otpauth:// URI for QR code generation */
|
|
14
|
+
provisioningUri: string;
|
|
15
|
+
/** Base64-encoded QR code image (data URI) */
|
|
16
|
+
qrCodeBase64: string;
|
|
17
|
+
/** Seconds until setup expires */
|
|
18
|
+
expiresIn: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface UseTwoFactorSetupOptions {
|
|
22
|
+
/** Callback when setup is confirmed and backup codes are generated */
|
|
23
|
+
onComplete?: (backupCodes: string[]) => void;
|
|
24
|
+
/** Callback on error */
|
|
25
|
+
onError?: (error: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseTwoFactorSetupReturn {
|
|
29
|
+
/** Loading state */
|
|
30
|
+
isLoading: boolean;
|
|
31
|
+
/** Error message */
|
|
32
|
+
error: string | null;
|
|
33
|
+
/** Setup data (QR code, secret, etc.) */
|
|
34
|
+
setupData: TwoFactorSetupData | null;
|
|
35
|
+
/** Backup codes after confirmation */
|
|
36
|
+
backupCodes: string[] | null;
|
|
37
|
+
/** Warning message about backup codes */
|
|
38
|
+
backupCodesWarning: string | null;
|
|
39
|
+
/** Current setup step */
|
|
40
|
+
setupStep: 'idle' | 'scanning' | 'confirming' | 'complete';
|
|
41
|
+
/** Start 2FA setup - returns QR code data */
|
|
42
|
+
startSetup: (deviceName?: string) => Promise<TwoFactorSetupData | null>;
|
|
43
|
+
/** Confirm setup with TOTP code - returns backup codes */
|
|
44
|
+
confirmSetup: (code: string) => Promise<string[] | null>;
|
|
45
|
+
/** Reset setup state */
|
|
46
|
+
resetSetup: () => void;
|
|
47
|
+
/** Clear error */
|
|
48
|
+
clearError: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Hook for 2FA setup (enabling TOTP authentication).
|
|
53
|
+
*
|
|
54
|
+
* Flow:
|
|
55
|
+
* 1. Call startSetup() to get QR code and provisioning URI
|
|
56
|
+
* 2. User scans QR code with authenticator app
|
|
57
|
+
* 3. Call confirmSetup(code) with the 6-digit code from app
|
|
58
|
+
* 4. Show backup codes to user (they must save these!)
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```tsx
|
|
62
|
+
* const {
|
|
63
|
+
* isLoading,
|
|
64
|
+
* error,
|
|
65
|
+
* setupData,
|
|
66
|
+
* backupCodes,
|
|
67
|
+
* setupStep,
|
|
68
|
+
* startSetup,
|
|
69
|
+
* confirmSetup,
|
|
70
|
+
* } = useTwoFactorSetup({
|
|
71
|
+
* onComplete: (codes) => console.log('Backup codes:', codes),
|
|
72
|
+
* onError: (error) => console.error(error),
|
|
73
|
+
* });
|
|
74
|
+
*
|
|
75
|
+
* // Start setup
|
|
76
|
+
* const data = await startSetup('My iPhone');
|
|
77
|
+
*
|
|
78
|
+
* // Show QR code
|
|
79
|
+
* <QRCodeSVG value={data.provisioningUri} />
|
|
80
|
+
*
|
|
81
|
+
* // Confirm with code from app
|
|
82
|
+
* const codes = await confirmSetup('123456');
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export const useTwoFactorSetup = (options: UseTwoFactorSetupOptions = {}): UseTwoFactorSetupReturn => {
|
|
86
|
+
const { onComplete, onError } = options;
|
|
87
|
+
|
|
88
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [setupData, setSetupData] = useState<TwoFactorSetupData | null>(null);
|
|
91
|
+
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
|
|
92
|
+
const [backupCodesWarning, setBackupCodesWarning] = useState<string | null>(null);
|
|
93
|
+
const [setupStep, setSetupStep] = useState<'idle' | 'scanning' | 'confirming' | 'complete'>('idle');
|
|
94
|
+
|
|
95
|
+
const clearError = useCallback(() => {
|
|
96
|
+
setError(null);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const resetSetup = useCallback(() => {
|
|
100
|
+
setSetupData(null);
|
|
101
|
+
setBackupCodes(null);
|
|
102
|
+
setBackupCodesWarning(null);
|
|
103
|
+
setSetupStep('idle');
|
|
104
|
+
setError(null);
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Start 2FA setup - generates QR code and secret
|
|
109
|
+
*/
|
|
110
|
+
const startSetup = useCallback(async (deviceName?: string): Promise<TwoFactorSetupData | null> => {
|
|
111
|
+
setIsLoading(true);
|
|
112
|
+
setError(null);
|
|
113
|
+
setSetupStep('scanning');
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
authLogger.info('Starting 2FA setup...');
|
|
117
|
+
|
|
118
|
+
const response = await apiTotp.totp_setup.create({
|
|
119
|
+
device_name: deviceName,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const data: TwoFactorSetupData = {
|
|
123
|
+
deviceId: response.device_id,
|
|
124
|
+
secret: response.secret,
|
|
125
|
+
provisioningUri: response.provisioning_uri,
|
|
126
|
+
qrCodeBase64: response.qr_code_base64,
|
|
127
|
+
expiresIn: response.expires_in,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
setSetupData(data);
|
|
131
|
+
authLogger.info('2FA setup initiated, expires in:', data.expiresIn, 'seconds');
|
|
132
|
+
|
|
133
|
+
return data;
|
|
134
|
+
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to start 2FA setup';
|
|
137
|
+
authLogger.error('2FA setup error:', err);
|
|
138
|
+
setError(errorMessage);
|
|
139
|
+
setSetupStep('idle');
|
|
140
|
+
onError?.(errorMessage);
|
|
141
|
+
return null;
|
|
142
|
+
} finally {
|
|
143
|
+
setIsLoading(false);
|
|
144
|
+
}
|
|
145
|
+
}, [onError]);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Confirm 2FA setup with TOTP code from authenticator app
|
|
149
|
+
*/
|
|
150
|
+
const confirmSetup = useCallback(async (code: string): Promise<string[] | null> => {
|
|
151
|
+
if (!setupData) {
|
|
152
|
+
const msg = 'Setup not started. Call startSetup() first.';
|
|
153
|
+
setError(msg);
|
|
154
|
+
onError?.(msg);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!code || code.length !== 6) {
|
|
159
|
+
const msg = 'Please enter a 6-digit code';
|
|
160
|
+
setError(msg);
|
|
161
|
+
onError?.(msg);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setIsLoading(true);
|
|
166
|
+
setError(null);
|
|
167
|
+
setSetupStep('confirming');
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
authLogger.info('Confirming 2FA setup...');
|
|
171
|
+
|
|
172
|
+
const response = await apiTotp.totp_setup.confirmCreate({
|
|
173
|
+
device_id: setupData.deviceId,
|
|
174
|
+
code,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const codes = response.backup_codes;
|
|
178
|
+
setBackupCodes(codes);
|
|
179
|
+
setBackupCodesWarning(response.backup_codes_warning);
|
|
180
|
+
setSetupStep('complete');
|
|
181
|
+
|
|
182
|
+
authLogger.info('2FA setup confirmed, backup codes generated:', codes.length);
|
|
183
|
+
|
|
184
|
+
// Call completion callback
|
|
185
|
+
onComplete?.(codes);
|
|
186
|
+
|
|
187
|
+
return codes;
|
|
188
|
+
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const errorMessage = err instanceof Error ? err.message : 'Invalid code. Please try again.';
|
|
191
|
+
authLogger.error('2FA setup confirmation error:', err);
|
|
192
|
+
setError(errorMessage);
|
|
193
|
+
setSetupStep('scanning'); // Go back to scanning step
|
|
194
|
+
onError?.(errorMessage);
|
|
195
|
+
return null;
|
|
196
|
+
} finally {
|
|
197
|
+
setIsLoading(false);
|
|
198
|
+
}
|
|
199
|
+
}, [setupData, onComplete, onError]);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
isLoading,
|
|
203
|
+
error,
|
|
204
|
+
setupData,
|
|
205
|
+
backupCodes,
|
|
206
|
+
backupCodesWarning,
|
|
207
|
+
setupStep,
|
|
208
|
+
startSetup,
|
|
209
|
+
confirmSetup,
|
|
210
|
+
resetSetup,
|
|
211
|
+
clearError,
|
|
212
|
+
};
|
|
213
|
+
};
|