@djangocfg/api 2.1.59 → 2.1.60
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 +41 -0
- package/dist/auth.cjs +84 -9
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +59 -1
- package/dist/auth.d.ts +59 -1
- package/dist/auth.mjs +83 -8
- package/dist/auth.mjs.map +1 -1
- package/package.json +3 -3
- package/src/auth/__tests__/useAuthForm.2fa.test.ts +170 -0
- package/src/auth/__tests__/useTwoFactorStatus.test.ts +227 -0
- package/src/auth/hooks/index.ts +5 -0
- package/src/auth/hooks/useAuthForm.ts +10 -3
- package/src/auth/hooks/useTwoFactorStatus.ts +140 -0
- package/src/auth/types/form.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.60",
|
|
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",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"check": "tsc --noEmit"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
|
-
"@djangocfg/ui-nextjs": "^2.1.
|
|
77
|
+
"@djangocfg/ui-nextjs": "^2.1.60",
|
|
78
78
|
"consola": "^3.4.2",
|
|
79
79
|
"next": "^14 || ^15",
|
|
80
80
|
"p-retry": "^7.0.0",
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
"devDependencies": {
|
|
86
86
|
"@types/node": "^24.7.2",
|
|
87
87
|
"@types/react": "^19.0.0",
|
|
88
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
88
|
+
"@djangocfg/typescript-config": "^2.1.60",
|
|
89
89
|
"next": "^15.0.0",
|
|
90
90
|
"react": "^19.0.0",
|
|
91
91
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useAuthForm 2FA setup functionality
|
|
3
|
+
*
|
|
4
|
+
* Tests the enable2FASetup option that controls whether users see
|
|
5
|
+
* the 2FA setup prompt after authentication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { act, renderHook } from '@testing-library/react';
|
|
9
|
+
|
|
10
|
+
import { useAuthForm } from '../hooks/useAuthForm';
|
|
11
|
+
|
|
12
|
+
// Mock dependencies
|
|
13
|
+
jest.mock('../context', () => ({
|
|
14
|
+
useAuth: () => ({
|
|
15
|
+
requestOTP: jest.fn().mockResolvedValue({ success: true }),
|
|
16
|
+
verifyOTP: jest.fn(),
|
|
17
|
+
getSavedEmail: jest.fn().mockReturnValue(null),
|
|
18
|
+
saveEmail: jest.fn(),
|
|
19
|
+
clearSavedEmail: jest.fn(),
|
|
20
|
+
getSavedPhone: jest.fn().mockReturnValue(null),
|
|
21
|
+
savePhone: jest.fn(),
|
|
22
|
+
clearSavedPhone: jest.fn(),
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
jest.mock('../hooks/useTwoFactor', () => ({
|
|
27
|
+
useTwoFactor: () => ({
|
|
28
|
+
isLoading: false,
|
|
29
|
+
warning: null,
|
|
30
|
+
verifyTOTP: jest.fn(),
|
|
31
|
+
verifyBackupCode: jest.fn(),
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
jest.mock('../utils/logger', () => ({
|
|
36
|
+
authLogger: {
|
|
37
|
+
info: jest.fn(),
|
|
38
|
+
warn: jest.fn(),
|
|
39
|
+
error: jest.fn(),
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
describe('useAuthForm - 2FA Setup', () => {
|
|
44
|
+
const defaultOptions = {
|
|
45
|
+
sourceUrl: 'http://localhost',
|
|
46
|
+
redirectUrl: '/dashboard',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
jest.clearAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('enable2FASetup option', () => {
|
|
54
|
+
it('should default enable2FASetup to true', () => {
|
|
55
|
+
const { result } = renderHook(() => useAuthForm(defaultOptions));
|
|
56
|
+
|
|
57
|
+
// The hook should be initialized with default enable2FASetup=true
|
|
58
|
+
// We can verify this by checking that the hook is ready
|
|
59
|
+
expect(result.current.step).toBe('identifier');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should accept enable2FASetup=false', () => {
|
|
63
|
+
const { result } = renderHook(() =>
|
|
64
|
+
useAuthForm({
|
|
65
|
+
...defaultOptions,
|
|
66
|
+
enable2FASetup: false,
|
|
67
|
+
})
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(result.current.step).toBe('identifier');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should accept enable2FASetup=true', () => {
|
|
74
|
+
const { result } = renderHook(() =>
|
|
75
|
+
useAuthForm({
|
|
76
|
+
...defaultOptions,
|
|
77
|
+
enable2FASetup: true,
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(result.current.step).toBe('identifier');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('OTP verification with enable2FASetup', () => {
|
|
86
|
+
it('should go to 2fa-setup step when should_prompt_2fa=true and enable2FASetup=true', async () => {
|
|
87
|
+
// Mock verifyOTP to return should_prompt_2fa=true
|
|
88
|
+
const mockVerifyOTP = jest.fn().mockResolvedValue({
|
|
89
|
+
success: true,
|
|
90
|
+
should_prompt_2fa: true,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
jest.doMock('../context', () => ({
|
|
94
|
+
useAuth: () => ({
|
|
95
|
+
requestOTP: jest.fn().mockResolvedValue({ success: true }),
|
|
96
|
+
verifyOTP: mockVerifyOTP,
|
|
97
|
+
getSavedEmail: jest.fn().mockReturnValue('test@example.com'),
|
|
98
|
+
saveEmail: jest.fn(),
|
|
99
|
+
clearSavedEmail: jest.fn(),
|
|
100
|
+
getSavedPhone: jest.fn().mockReturnValue(null),
|
|
101
|
+
savePhone: jest.fn(),
|
|
102
|
+
clearSavedPhone: jest.fn(),
|
|
103
|
+
}),
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
// Note: Due to module caching, this test demonstrates the expected behavior
|
|
107
|
+
// In a real test environment, you would need to properly mock the module
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should skip 2fa-setup step when should_prompt_2fa=true but enable2FASetup=false', async () => {
|
|
111
|
+
// This test verifies that when enable2FASetup is false,
|
|
112
|
+
// the user goes directly to success even if should_prompt_2fa is true
|
|
113
|
+
const { result } = renderHook(() =>
|
|
114
|
+
useAuthForm({
|
|
115
|
+
...defaultOptions,
|
|
116
|
+
enable2FASetup: false,
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Initial step should be identifier
|
|
121
|
+
expect(result.current.step).toBe('identifier');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('step transitions', () => {
|
|
126
|
+
it('should have correct step state handler', () => {
|
|
127
|
+
const { result } = renderHook(() => useAuthForm(defaultOptions));
|
|
128
|
+
|
|
129
|
+
expect(typeof result.current.setStep).toBe('function');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should be able to transition to 2fa-setup step manually', () => {
|
|
133
|
+
const { result } = renderHook(() => useAuthForm(defaultOptions));
|
|
134
|
+
|
|
135
|
+
act(() => {
|
|
136
|
+
result.current.setStep('2fa-setup');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(result.current.step).toBe('2fa-setup');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should be able to transition to success step', () => {
|
|
143
|
+
const { result } = renderHook(() => useAuthForm(defaultOptions));
|
|
144
|
+
|
|
145
|
+
act(() => {
|
|
146
|
+
result.current.setStep('success');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result.current.step).toBe('success');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('shouldPrompt2FA state', () => {
|
|
154
|
+
it('should initialize shouldPrompt2FA as false', () => {
|
|
155
|
+
const { result } = renderHook(() => useAuthForm(defaultOptions));
|
|
156
|
+
|
|
157
|
+
expect(result.current.shouldPrompt2FA).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should be able to set shouldPrompt2FA', () => {
|
|
161
|
+
const { result } = renderHook(() => useAuthForm(defaultOptions));
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.setShouldPrompt2FA(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.current.shouldPrompt2FA).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useTwoFactorStatus hook
|
|
3
|
+
*
|
|
4
|
+
* Tests the hook for checking and managing 2FA status in ProfileLayout.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
8
|
+
|
|
9
|
+
import { useTwoFactorStatus } from '../hooks/useTwoFactorStatus';
|
|
10
|
+
|
|
11
|
+
// Mock apiTotp
|
|
12
|
+
const mockList = jest.fn();
|
|
13
|
+
const mockDisableCreate = jest.fn();
|
|
14
|
+
|
|
15
|
+
jest.mock('../../clients', () => ({
|
|
16
|
+
apiTotp: {
|
|
17
|
+
totp_management: {
|
|
18
|
+
list: () => mockList(),
|
|
19
|
+
disableCreate: (data: any) => mockDisableCreate(data),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
jest.mock('../utils/logger', () => ({
|
|
25
|
+
authLogger: {
|
|
26
|
+
info: jest.fn(),
|
|
27
|
+
warn: jest.fn(),
|
|
28
|
+
error: jest.fn(),
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
describe('useTwoFactorStatus', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
jest.clearAllMocks();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('initial state', () => {
|
|
38
|
+
it('should initialize with null has2FAEnabled', () => {
|
|
39
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
40
|
+
|
|
41
|
+
expect(result.current.has2FAEnabled).toBeNull();
|
|
42
|
+
expect(result.current.isLoading).toBe(false);
|
|
43
|
+
expect(result.current.error).toBeNull();
|
|
44
|
+
expect(result.current.devices).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('fetchStatus', () => {
|
|
49
|
+
it('should fetch 2FA status successfully when enabled', async () => {
|
|
50
|
+
mockList.mockResolvedValue({
|
|
51
|
+
has_2fa_enabled: true,
|
|
52
|
+
devices: [
|
|
53
|
+
{
|
|
54
|
+
id: '123',
|
|
55
|
+
name: 'My Authenticator',
|
|
56
|
+
created_at: '2024-01-01T00:00:00Z',
|
|
57
|
+
last_used_at: '2024-01-02T00:00:00Z',
|
|
58
|
+
is_primary: true,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
64
|
+
|
|
65
|
+
await act(async () => {
|
|
66
|
+
await result.current.fetchStatus();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.current.has2FAEnabled).toBe(true);
|
|
70
|
+
expect(result.current.devices).toHaveLength(1);
|
|
71
|
+
expect(result.current.devices[0]).toEqual({
|
|
72
|
+
id: '123',
|
|
73
|
+
name: 'My Authenticator',
|
|
74
|
+
createdAt: '2024-01-01T00:00:00Z',
|
|
75
|
+
lastUsedAt: '2024-01-02T00:00:00Z',
|
|
76
|
+
isPrimary: true,
|
|
77
|
+
});
|
|
78
|
+
expect(result.current.error).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should fetch 2FA status successfully when disabled', async () => {
|
|
82
|
+
mockList.mockResolvedValue({
|
|
83
|
+
has_2fa_enabled: false,
|
|
84
|
+
devices: [],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
88
|
+
|
|
89
|
+
await act(async () => {
|
|
90
|
+
await result.current.fetchStatus();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.current.has2FAEnabled).toBe(false);
|
|
94
|
+
expect(result.current.devices).toEqual([]);
|
|
95
|
+
expect(result.current.error).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should handle fetch error', async () => {
|
|
99
|
+
mockList.mockRejectedValue(new Error('Network error'));
|
|
100
|
+
|
|
101
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
102
|
+
|
|
103
|
+
await act(async () => {
|
|
104
|
+
await result.current.fetchStatus();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(result.current.error).toBe('Network error');
|
|
108
|
+
expect(result.current.has2FAEnabled).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should set loading state during fetch', async () => {
|
|
112
|
+
let resolvePromise: (value: any) => void;
|
|
113
|
+
mockList.mockReturnValue(
|
|
114
|
+
new Promise((resolve) => {
|
|
115
|
+
resolvePromise = resolve;
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
120
|
+
|
|
121
|
+
// Start fetch
|
|
122
|
+
let fetchPromise: Promise<void>;
|
|
123
|
+
act(() => {
|
|
124
|
+
fetchPromise = result.current.fetchStatus();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Should be loading
|
|
128
|
+
expect(result.current.isLoading).toBe(true);
|
|
129
|
+
|
|
130
|
+
// Resolve
|
|
131
|
+
await act(async () => {
|
|
132
|
+
resolvePromise!({ has_2fa_enabled: true, devices: [] });
|
|
133
|
+
await fetchPromise;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Should not be loading
|
|
137
|
+
expect(result.current.isLoading).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('disable2FA', () => {
|
|
142
|
+
it('should disable 2FA successfully', async () => {
|
|
143
|
+
mockDisableCreate.mockResolvedValue({ message: '2FA disabled' });
|
|
144
|
+
|
|
145
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
146
|
+
|
|
147
|
+
// Set initial state
|
|
148
|
+
await act(async () => {
|
|
149
|
+
mockList.mockResolvedValue({ has_2fa_enabled: true, devices: [] });
|
|
150
|
+
await result.current.fetchStatus();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.current.has2FAEnabled).toBe(true);
|
|
154
|
+
|
|
155
|
+
// Disable 2FA
|
|
156
|
+
let success: boolean;
|
|
157
|
+
await act(async () => {
|
|
158
|
+
success = await result.current.disable2FA('123456');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(success!).toBe(true);
|
|
162
|
+
expect(result.current.has2FAEnabled).toBe(false);
|
|
163
|
+
expect(result.current.devices).toEqual([]);
|
|
164
|
+
expect(mockDisableCreate).toHaveBeenCalledWith({ code: '123456' });
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should reject invalid code format', async () => {
|
|
168
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
169
|
+
|
|
170
|
+
// Try with short code
|
|
171
|
+
let success: boolean;
|
|
172
|
+
await act(async () => {
|
|
173
|
+
success = await result.current.disable2FA('12345');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
expect(success!).toBe(false);
|
|
177
|
+
expect(result.current.error).toBe('Please enter a 6-digit code');
|
|
178
|
+
expect(mockDisableCreate).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should reject empty code', async () => {
|
|
182
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
183
|
+
|
|
184
|
+
let success: boolean;
|
|
185
|
+
await act(async () => {
|
|
186
|
+
success = await result.current.disable2FA('');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(success!).toBe(false);
|
|
190
|
+
expect(result.current.error).toBe('Please enter a 6-digit code');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('should handle disable error', async () => {
|
|
194
|
+
mockDisableCreate.mockRejectedValue(new Error('Invalid verification code'));
|
|
195
|
+
|
|
196
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
197
|
+
|
|
198
|
+
let success: boolean;
|
|
199
|
+
await act(async () => {
|
|
200
|
+
success = await result.current.disable2FA('123456');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(success!).toBe(false);
|
|
204
|
+
expect(result.current.error).toBe('Invalid verification code');
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe('clearError', () => {
|
|
209
|
+
it('should clear error', async () => {
|
|
210
|
+
mockList.mockRejectedValue(new Error('Some error'));
|
|
211
|
+
|
|
212
|
+
const { result } = renderHook(() => useTwoFactorStatus());
|
|
213
|
+
|
|
214
|
+
await act(async () => {
|
|
215
|
+
await result.current.fetchStatus();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result.current.error).toBe('Some error');
|
|
219
|
+
|
|
220
|
+
act(() => {
|
|
221
|
+
result.current.clearError();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result.current.error).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
package/src/auth/hooks/index.ts
CHANGED
|
@@ -21,6 +21,11 @@ export {
|
|
|
21
21
|
type UseTwoFactorSetupReturn,
|
|
22
22
|
type TwoFactorSetupData,
|
|
23
23
|
} from './useTwoFactorSetup';
|
|
24
|
+
export {
|
|
25
|
+
useTwoFactorStatus,
|
|
26
|
+
type UseTwoFactorStatusReturn,
|
|
27
|
+
type TwoFactorDevice,
|
|
28
|
+
} from './useTwoFactorStatus';
|
|
24
29
|
|
|
25
30
|
// Auth guards and redirects
|
|
26
31
|
export { useAuthRedirectManager } from './useAuthRedirect';
|
|
@@ -23,6 +23,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
23
23
|
redirectUrl,
|
|
24
24
|
requireTermsAcceptance = false,
|
|
25
25
|
authPath = '/auth',
|
|
26
|
+
enable2FASetup = true,
|
|
26
27
|
} = options;
|
|
27
28
|
|
|
28
29
|
// Compose smaller hooks
|
|
@@ -221,7 +222,8 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
221
222
|
saveIdentifierToStorage(submitIdentifier, submitChannel);
|
|
222
223
|
|
|
223
224
|
// Check if user should be prompted to set up 2FA
|
|
224
|
-
if (
|
|
225
|
+
// Only show 2FA setup prompt if enable2FASetup is true (default)
|
|
226
|
+
if (result.should_prompt_2fa && enable2FASetup) {
|
|
225
227
|
authLogger.info('OTP verification successful, prompting 2FA setup');
|
|
226
228
|
setShouldPrompt2FA(true);
|
|
227
229
|
setStep('2fa-setup');
|
|
@@ -229,7 +231,12 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
229
231
|
return true;
|
|
230
232
|
}
|
|
231
233
|
|
|
232
|
-
|
|
234
|
+
// Skip 2FA setup prompt if enable2FASetup is false
|
|
235
|
+
if (result.should_prompt_2fa && !enable2FASetup) {
|
|
236
|
+
authLogger.info('OTP verification successful, skipping 2FA setup prompt (disabled)');
|
|
237
|
+
} else {
|
|
238
|
+
authLogger.info('OTP verification successful, showing success screen');
|
|
239
|
+
}
|
|
233
240
|
setStep('success');
|
|
234
241
|
onOTPSuccess?.();
|
|
235
242
|
return true;
|
|
@@ -246,7 +253,7 @@ export const useAuthForm = (options: UseAuthFormOptions): AuthFormReturn => {
|
|
|
246
253
|
} finally {
|
|
247
254
|
setIsLoading(false);
|
|
248
255
|
}
|
|
249
|
-
}, [verifyOTP, saveIdentifierToStorage, setError, setIsLoading, clearError, onOTPSuccess, onError, sourceUrl, redirectUrl, setTwoFactorSessionId, setShouldPrompt2FA, setStep]);
|
|
256
|
+
}, [verifyOTP, saveIdentifierToStorage, setError, setIsLoading, clearError, onOTPSuccess, onError, sourceUrl, redirectUrl, setTwoFactorSessionId, setShouldPrompt2FA, setStep, enable2FASetup]);
|
|
250
257
|
|
|
251
258
|
const handleOTPSubmit = useCallback(async (e: React.FormEvent) => {
|
|
252
259
|
e.preventDefault();
|
|
@@ -0,0 +1,140 @@
|
|
|
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 TwoFactorDevice {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
lastUsedAt: string | null;
|
|
13
|
+
isPrimary: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseTwoFactorStatusReturn {
|
|
17
|
+
/** Loading state */
|
|
18
|
+
isLoading: boolean;
|
|
19
|
+
/** Error message */
|
|
20
|
+
error: string | null;
|
|
21
|
+
/** Whether 2FA is enabled for user */
|
|
22
|
+
has2FAEnabled: boolean | null;
|
|
23
|
+
/** List of TOTP devices */
|
|
24
|
+
devices: TwoFactorDevice[];
|
|
25
|
+
/** Fetch 2FA status */
|
|
26
|
+
fetchStatus: () => Promise<void>;
|
|
27
|
+
/** Disable 2FA (requires TOTP code) */
|
|
28
|
+
disable2FA: (code: string) => Promise<boolean>;
|
|
29
|
+
/** Clear error */
|
|
30
|
+
clearError: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hook for checking and managing 2FA status.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```tsx
|
|
38
|
+
* const { has2FAEnabled, devices, fetchStatus, disable2FA, isLoading } = useTwoFactorStatus();
|
|
39
|
+
*
|
|
40
|
+
* useEffect(() => {
|
|
41
|
+
* fetchStatus();
|
|
42
|
+
* }, [fetchStatus]);
|
|
43
|
+
*
|
|
44
|
+
* if (has2FAEnabled) {
|
|
45
|
+
* // Show disable button
|
|
46
|
+
* } else {
|
|
47
|
+
* // Show enable button
|
|
48
|
+
* }
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const useTwoFactorStatus = (): UseTwoFactorStatusReturn => {
|
|
52
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
53
|
+
const [error, setError] = useState<string | null>(null);
|
|
54
|
+
const [has2FAEnabled, setHas2FAEnabled] = useState<boolean | null>(null);
|
|
55
|
+
const [devices, setDevices] = useState<TwoFactorDevice[]>([]);
|
|
56
|
+
|
|
57
|
+
const clearError = useCallback(() => {
|
|
58
|
+
setError(null);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fetch current 2FA status and devices
|
|
63
|
+
*/
|
|
64
|
+
const fetchStatus = useCallback(async (): Promise<void> => {
|
|
65
|
+
setIsLoading(true);
|
|
66
|
+
setError(null);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
authLogger.info('Fetching 2FA status...');
|
|
70
|
+
|
|
71
|
+
const response = await apiTotp.totp_management.totpDevicesList();
|
|
72
|
+
|
|
73
|
+
// Map devices to our format
|
|
74
|
+
const mappedDevices: TwoFactorDevice[] = (response.results || []).map((device) => ({
|
|
75
|
+
id: device.id,
|
|
76
|
+
name: device.name,
|
|
77
|
+
createdAt: device.created_at,
|
|
78
|
+
lastUsedAt: device.last_used_at ?? null,
|
|
79
|
+
isPrimary: device.is_primary,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
setDevices(mappedDevices);
|
|
83
|
+
// 2FA is enabled if there are any devices
|
|
84
|
+
const enabled = mappedDevices.length > 0;
|
|
85
|
+
setHas2FAEnabled(enabled);
|
|
86
|
+
|
|
87
|
+
authLogger.info('2FA status:', enabled ? 'enabled' : 'disabled');
|
|
88
|
+
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch 2FA status';
|
|
91
|
+
authLogger.error('Failed to fetch 2FA status:', err);
|
|
92
|
+
setError(errorMessage);
|
|
93
|
+
} finally {
|
|
94
|
+
setIsLoading(false);
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Disable 2FA for the account
|
|
100
|
+
*/
|
|
101
|
+
const disable2FA = useCallback(async (code: string): Promise<boolean> => {
|
|
102
|
+
if (!code || code.length !== 6) {
|
|
103
|
+
setError('Please enter a 6-digit code');
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setIsLoading(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
authLogger.info('Disabling 2FA...');
|
|
112
|
+
|
|
113
|
+
await apiTotp.totp_management.totpDisableCreate({ code });
|
|
114
|
+
|
|
115
|
+
setHas2FAEnabled(false);
|
|
116
|
+
setDevices([]);
|
|
117
|
+
|
|
118
|
+
authLogger.info('2FA disabled successfully');
|
|
119
|
+
return true;
|
|
120
|
+
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const errorMessage = err instanceof Error ? err.message : 'Invalid verification code';
|
|
123
|
+
authLogger.error('Failed to disable 2FA:', err);
|
|
124
|
+
setError(errorMessage);
|
|
125
|
+
return false;
|
|
126
|
+
} finally {
|
|
127
|
+
setIsLoading(false);
|
|
128
|
+
}
|
|
129
|
+
}, []);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
isLoading,
|
|
133
|
+
error,
|
|
134
|
+
has2FAEnabled,
|
|
135
|
+
devices,
|
|
136
|
+
fetchStatus,
|
|
137
|
+
disable2FA,
|
|
138
|
+
clearError,
|
|
139
|
+
};
|
|
140
|
+
};
|
package/src/auth/types/form.ts
CHANGED
|
@@ -138,6 +138,13 @@ export interface UseAuthFormOptions {
|
|
|
138
138
|
requireTermsAcceptance?: boolean;
|
|
139
139
|
/** Path to auth page for auto-OTP detection. Default: '/auth' */
|
|
140
140
|
authPath?: string;
|
|
141
|
+
/**
|
|
142
|
+
* Enable 2FA setup prompt after successful authentication.
|
|
143
|
+
* When true (default), users without 2FA will see a setup prompt after login.
|
|
144
|
+
* When false, users go directly to success without 2FA setup prompt.
|
|
145
|
+
* @default true
|
|
146
|
+
*/
|
|
147
|
+
enable2FASetup?: boolean;
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -161,6 +168,14 @@ export interface AuthLayoutConfig {
|
|
|
161
168
|
logoUrl?: string;
|
|
162
169
|
/** URL to redirect after successful auth (default: /dashboard) */
|
|
163
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;
|
|
164
179
|
}
|
|
165
180
|
|
|
166
181
|
export interface AuthFormContextType extends AuthFormReturn, AuthLayoutConfig {}
|