feat: Implement a comprehensive two-factor authentication system including setup, verification, backup codes, and integration into the login flow.
This commit is contained in:
4
public/assets/icons/settings-shield-icon.svg
Normal file
4
public/assets/icons/settings-shield-icon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" stroke="#E58625" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M9 12L11 14L15 10" stroke="#E58625" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 364 B |
@@ -35,6 +35,17 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if 2FA is required (response is wrapped in { success, data })
|
||||||
|
if (validateData.data?.requiresTwoFactor) {
|
||||||
|
// Redirect to 2FA verification page with tempToken
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
token: validateData.data.tempToken,
|
||||||
|
email: email,
|
||||||
|
});
|
||||||
|
router.push(`/login/verify-2fa?${params.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If validation passed, use NextAuth to create session
|
// If validation passed, use NextAuth to create session
|
||||||
const result = await signIn('credentials', {
|
const result = await signIn('credentials', {
|
||||||
email,
|
email,
|
||||||
|
|||||||
266
src/app/(auth)/login/verify-2fa/page.tsx
Normal file
266
src/app/(auth)/login/verify-2fa/page.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
import { signIn } from 'next-auth/react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { twoFactorService } from '@/services';
|
||||||
|
|
||||||
|
export default function VerifyTwoFactorPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [code, setCode] = useState(['', '', '', '', '', '']);
|
||||||
|
const [backupCode, setBackupCode] = useState('');
|
||||||
|
const [useBackup, setUseBackup] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Get tempToken and email from URL params
|
||||||
|
const tempToken = searchParams.get('token') || '';
|
||||||
|
const userEmail = searchParams.get('email') || '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// If no token, redirect to login
|
||||||
|
if (!tempToken) {
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [tempToken, router]);
|
||||||
|
|
||||||
|
const handleCodeChange = (index: number, value: string) => {
|
||||||
|
// Only allow single digit
|
||||||
|
if (value.length > 1) {
|
||||||
|
value = value.slice(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow numbers
|
||||||
|
if (value && !/^\d$/.test(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCode = [...code];
|
||||||
|
newCode[index] = value;
|
||||||
|
setCode(newCode);
|
||||||
|
|
||||||
|
// Auto-focus next input
|
||||||
|
if (value && index < 5) {
|
||||||
|
inputRefs.current[index + 1]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-submit when all digits are entered
|
||||||
|
if (value && index === 5 && newCode.every((digit) => digit !== '')) {
|
||||||
|
handleSubmit(newCode.join(''));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Backspace' && !code[index] && index > 0) {
|
||||||
|
inputRefs.current[index - 1]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pastedData = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
||||||
|
if (pastedData.length === 6) {
|
||||||
|
const newCode = pastedData.split('');
|
||||||
|
setCode(newCode);
|
||||||
|
inputRefs.current[5]?.focus();
|
||||||
|
// Auto-submit after paste
|
||||||
|
setTimeout(() => handleSubmit(pastedData), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (codeValue?: string) => {
|
||||||
|
const verificationCode = codeValue || code.join('');
|
||||||
|
|
||||||
|
if (!useBackup && verificationCode.length !== 6) {
|
||||||
|
setError('Please enter a 6-digit code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useBackup && !backupCode.trim()) {
|
||||||
|
setError('Please enter a backup code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
if (useBackup) {
|
||||||
|
response = await twoFactorService.verifyBackupCode({
|
||||||
|
tempToken,
|
||||||
|
backupCode: backupCode.trim(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await twoFactorService.verifyLogin({
|
||||||
|
tempToken,
|
||||||
|
token: verificationCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now sign in with NextAuth using the tokens we received
|
||||||
|
// We need to call the NextAuth credentials provider with the tokens
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
email: userEmail,
|
||||||
|
accessToken: response.data.accessToken,
|
||||||
|
refreshToken: response.data.refreshToken,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError('Failed to complete login. Please try again.');
|
||||||
|
setLoading(false);
|
||||||
|
} else {
|
||||||
|
router.push('/');
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setLoading(false);
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message);
|
||||||
|
} else {
|
||||||
|
setError('Invalid code. Please try again.');
|
||||||
|
}
|
||||||
|
// Clear code on error
|
||||||
|
if (!useBackup) {
|
||||||
|
setCode(['', '', '', '', '', '']);
|
||||||
|
inputRefs.current[0]?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tempToken) {
|
||||||
|
return null; // Will redirect in useEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Heading */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 bg-[#f0f5fc] rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 text-[#e58625]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-[30px] font-normal text-[#00293d] mb-3">Two-Factor Authentication</h2>
|
||||||
|
<p className="text-sm text-[#00293d] px-4 leading-relaxed">
|
||||||
|
{useBackup
|
||||||
|
? 'Enter one of your backup codes to continue'
|
||||||
|
: 'Enter the 6-digit code from your authenticator app'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form onSubmit={handleFormSubmit} className="space-y-5">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-[20px] text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!useBackup ? (
|
||||||
|
/* 6-digit code input */
|
||||||
|
<div className="flex justify-center gap-3" onPaste={handlePaste}>
|
||||||
|
{code.map((digit, index) => (
|
||||||
|
<input
|
||||||
|
key={index}
|
||||||
|
ref={(el) => {
|
||||||
|
inputRefs.current[index] = el;
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={1}
|
||||||
|
value={digit}
|
||||||
|
onChange={(e) => handleCodeChange(index, e.target.value)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||||
|
className="w-12 h-14 text-center text-xl font-bold bg-[#f0f5fc] rounded-[15px] text-[#00293d] focus:outline-none focus:ring-2 focus:ring-[#e58625] transition-all"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Backup code input */
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter backup code"
|
||||||
|
value={backupCode}
|
||||||
|
onChange={(e) => setBackupCode(e.target.value.toUpperCase())}
|
||||||
|
className="w-full px-6 py-6 bg-[#f0f5fc] rounded-[20px] text-[#00293d] text-sm font-light placeholder:text-[#00293d] focus:outline-none focus:ring-2 focus:ring-[#00293d]/20 transition-all text-center tracking-widest"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Switch between code and backup */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setUseBackup(!useBackup);
|
||||||
|
setError('');
|
||||||
|
setCode(['', '', '', '', '', '']);
|
||||||
|
setBackupCode('');
|
||||||
|
}}
|
||||||
|
className="text-[#00293d] text-sm font-light hover:underline"
|
||||||
|
>
|
||||||
|
{useBackup ? 'Use authenticator code instead' : 'Use a backup code'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verify Button */}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-6 bg-[#e58625] hover:bg-[#d47a1f] text-[#00293d] font-bold text-sm rounded-[20px] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? 'Verifying...' : 'Verify'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Back to Login */}
|
||||||
|
<div className="text-center mt-8">
|
||||||
|
<Link href="/login" className="text-[#00293d] text-sm font-light hover:underline">
|
||||||
|
← Back to Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<div className="mt-6 p-4 bg-[#f0f5fc] rounded-[15px]">
|
||||||
|
<p className="text-xs text-[#00293d]/70 text-center">
|
||||||
|
{useBackup ? (
|
||||||
|
<>
|
||||||
|
Backup codes are single-use. After using one, it will be invalidated. If you've
|
||||||
|
used all your backup codes, you'll need to generate new ones from your account
|
||||||
|
settings.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Open your authenticator app (Google Authenticator, Authy, etc.) and enter the 6-digit
|
||||||
|
code shown for Re-Quest.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/auth.ts
41
src/auth.ts
@@ -30,8 +30,42 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
email: { label: "Email", type: "email" },
|
||||||
password: { label: "Password", type: "password" },
|
password: { label: "Password", type: "password" },
|
||||||
|
accessToken: { label: "Access Token", type: "text" },
|
||||||
|
refreshToken: { label: "Refresh Token", type: "text" },
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
|
// Handle 2FA completion - tokens are passed directly
|
||||||
|
if (credentials?.accessToken && credentials?.refreshToken) {
|
||||||
|
// Verify the tokens by fetching current user
|
||||||
|
const verifyRes = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL}/auth/me`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": `Bearer ${credentials.accessToken}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (verifyRes.ok) {
|
||||||
|
const userData = await verifyRes.json();
|
||||||
|
if (userData.success) {
|
||||||
|
return {
|
||||||
|
id: userData.data.id,
|
||||||
|
email: userData.data.email,
|
||||||
|
name: `${userData.data.firstName || ""} ${userData.data.lastName || ""}`.trim(),
|
||||||
|
role: userData.data.role,
|
||||||
|
accessToken: credentials.accessToken,
|
||||||
|
refreshToken: credentials.refreshToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Invalid authentication tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard login flow
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/auth/login`,
|
`${process.env.NEXT_PUBLIC_API_URL}/auth/login`,
|
||||||
{
|
{
|
||||||
@@ -46,6 +80,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Check if 2FA is required
|
||||||
|
if (res.ok && data.data?.requiresTwoFactor) {
|
||||||
|
// Return null to indicate 2FA is required
|
||||||
|
// The frontend will handle the redirect
|
||||||
|
throw new Error("2FA_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
return {
|
return {
|
||||||
id: data.data.user.id,
|
id: data.data.user.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { TwoFactorSettings } from './TwoFactorSettings';
|
||||||
|
|
||||||
interface PasswordSecurityFormProps {
|
interface PasswordSecurityFormProps {
|
||||||
onSave?: (data: { currentPassword: string; newPassword: string }) => void;
|
onSave?: (data: { currentPassword: string; newPassword: string }) => void;
|
||||||
@@ -139,20 +140,8 @@ export function PasswordSecurityForm({ onSave }: PasswordSecurityFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Two-Factor Authentication Section */}
|
{/* Two-Factor Authentication Section */}
|
||||||
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6 mt-4">
|
<div className="mt-4">
|
||||||
<div className="flex items-center justify-between">
|
<TwoFactorSettings />
|
||||||
<div>
|
|
||||||
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
|
|
||||||
Two-Factor Authentication
|
|
||||||
</h2>
|
|
||||||
<p className="font-serif text-[12px] text-[#00293D]/60">
|
|
||||||
Add an extra layer of security to your account
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button className="px-6 py-2 border border-[#e58625] text-[#e58625] rounded-[15px] font-serif text-[12px] hover:bg-[#e58625]/10 transition-colors">
|
|
||||||
Enable 2FA
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
458
src/components/settings/TwoFactorSettings.tsx
Normal file
458
src/components/settings/TwoFactorSettings.tsx
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { twoFactorService, TwoFactorSetupResponse } from '@/services';
|
||||||
|
|
||||||
|
type Step = 'status' | 'setup' | 'verify' | 'backup-codes' | 'disable';
|
||||||
|
|
||||||
|
interface TwoFactorSettingsProps {
|
||||||
|
onStatusChange?: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TwoFactorSettings({ onStatusChange }: TwoFactorSettingsProps) {
|
||||||
|
const [step, setStep] = useState<Step>('status');
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Setup data
|
||||||
|
const [setupData, setSetupData] = useState<TwoFactorSetupResponse | null>(null);
|
||||||
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
|
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||||
|
const [backupCodesCopied, setBackupCodesCopied] = useState(false);
|
||||||
|
|
||||||
|
// Disable form
|
||||||
|
const [disablePassword, setDisablePassword] = useState('');
|
||||||
|
const [showDisablePassword, setShowDisablePassword] = useState(false);
|
||||||
|
|
||||||
|
// Fetch 2FA status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStatus = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await twoFactorService.getStatus();
|
||||||
|
setIsEnabled(response.data.enabled);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch 2FA status:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetup = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await twoFactorService.setup();
|
||||||
|
setSetupData(response.data);
|
||||||
|
setStep('setup');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to setup 2FA';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = async () => {
|
||||||
|
if (verificationCode.length !== 6) {
|
||||||
|
setError('Please enter a 6-digit verification code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await twoFactorService.enable(verificationCode);
|
||||||
|
setBackupCodes(response.data.backupCodes);
|
||||||
|
setStep('backup-codes');
|
||||||
|
setIsEnabled(true);
|
||||||
|
onStatusChange?.(true);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Invalid verification code';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisable = async () => {
|
||||||
|
if (!disablePassword) {
|
||||||
|
setError('Please enter your password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
await twoFactorService.disable(disablePassword);
|
||||||
|
setIsEnabled(false);
|
||||||
|
setStep('status');
|
||||||
|
setDisablePassword('');
|
||||||
|
setSuccess('Two-factor authentication has been disabled');
|
||||||
|
onStatusChange?.(false);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to disable 2FA';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegenerateBackupCodes = async () => {
|
||||||
|
const password = prompt('Enter your password to regenerate backup codes:');
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await twoFactorService.regenerateBackupCodes(password);
|
||||||
|
setBackupCodes(response.data.backupCodes);
|
||||||
|
setStep('backup-codes');
|
||||||
|
setSuccess('New backup codes generated successfully');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Failed to regenerate backup codes';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyBackupCodes = () => {
|
||||||
|
const codesText = backupCodes.join('\n');
|
||||||
|
navigator.clipboard.writeText(codesText);
|
||||||
|
setBackupCodesCopied(true);
|
||||||
|
setTimeout(() => setBackupCodesCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadBackupCodes = () => {
|
||||||
|
const codesText = `Re-Quest 2FA Backup Codes\n${'='.repeat(30)}\n\nKeep these codes safe. Each code can only be used once.\n\n${backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n')}\n\nGenerated: ${new Date().toISOString()}`;
|
||||||
|
const blob = new Blob([codesText], { type: 'text/plain' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 're-quest-backup-codes.txt';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear messages after timeout
|
||||||
|
useEffect(() => {
|
||||||
|
if (success) {
|
||||||
|
const timer = setTimeout(() => setSuccess(null), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [success]);
|
||||||
|
|
||||||
|
if (isLoading && step === 'status') {
|
||||||
|
return (
|
||||||
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-5 bg-gray-200 rounded w-48 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-64"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status view - show current 2FA status
|
||||||
|
if (step === 'status') {
|
||||||
|
return (
|
||||||
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-700 text-[13px] font-serif">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-[13px] font-serif">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 bg-[#e58625]/10 rounded-[10px] flex items-center justify-center flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src="/assets/icons/settings-shield-icon.svg"
|
||||||
|
alt="Security"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-1">
|
||||||
|
Two-Factor Authentication
|
||||||
|
</h2>
|
||||||
|
<p className="font-serif text-[12px] text-[#00293D]/60 mb-2">
|
||||||
|
Add an extra layer of security to your account using an authenticator app
|
||||||
|
</p>
|
||||||
|
<div className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-[11px] font-medium ${
|
||||||
|
isEnabled
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${isEnabled ? 'bg-green-500' : 'bg-gray-400'}`}></span>
|
||||||
|
{isEnabled ? 'Enabled' : 'Not Enabled'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEnabled ? (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRegenerateBackupCodes}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Backup Codes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('disable')}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 border border-red-300 text-red-600 rounded-[15px] font-serif text-[12px] hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleSetup}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-6 py-2 bg-[#e58625] text-white rounded-[15px] font-serif text-[12px] hover:bg-[#d47920] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Enable 2FA'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup view - show QR code
|
||||||
|
if (step === 'setup' && setupData) {
|
||||||
|
return (
|
||||||
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
|
||||||
|
Setup Two-Factor Authentication
|
||||||
|
</h2>
|
||||||
|
<p className="font-serif text-[12px] text-[#00293D]/60">
|
||||||
|
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row gap-8">
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="p-4 bg-white border border-gray-200 rounded-[15px] mb-4">
|
||||||
|
<img
|
||||||
|
src={setupData.qrCode}
|
||||||
|
alt="2FA QR Code"
|
||||||
|
className="w-48 h-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-[#00293D]/50 font-serif text-center max-w-[200px]">
|
||||||
|
Can't scan? Enter this code manually:
|
||||||
|
</p>
|
||||||
|
<code className="mt-2 px-3 py-2 bg-gray-100 rounded-[8px] text-[12px] font-mono text-[#00293D] select-all">
|
||||||
|
{setupData.manualEntry}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Verification */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="font-fractul font-semibold text-[14px] text-[#00293D] mb-2">
|
||||||
|
Verify Setup
|
||||||
|
</h3>
|
||||||
|
<p className="font-serif text-[12px] text-[#00293D]/60 mb-4">
|
||||||
|
Enter the 6-digit code from your authenticator app to verify setup
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-700 text-[13px] font-serif">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={verificationCode}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value.replace(/\D/g, '').slice(0, 6);
|
||||||
|
setVerificationCode(value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="000000"
|
||||||
|
maxLength={6}
|
||||||
|
className="w-32 h-[40px] px-4 border border-[#00293D]/20 rounded-[15px] text-[18px] font-mono text-center text-[#00293D] placeholder:text-[#00293D]/30 focus:outline-none focus:border-[#E58625] tracking-[0.3em]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStep('status');
|
||||||
|
setSetupData(null);
|
||||||
|
setVerificationCode('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="px-6 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleVerify}
|
||||||
|
disabled={isLoading || verificationCode.length !== 6}
|
||||||
|
className="px-6 py-2 bg-[#e58625] text-white rounded-[15px] font-serif text-[12px] hover:bg-[#d47920] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Verifying...' : 'Verify & Enable'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup codes view
|
||||||
|
if (step === 'backup-codes') {
|
||||||
|
return (
|
||||||
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-green-500 text-xl">✓</span>
|
||||||
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D]">
|
||||||
|
Two-Factor Authentication Enabled
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="font-serif text-[12px] text-[#00293D]/60">
|
||||||
|
Save these backup codes in a secure place. You can use them to access your account if you lose your phone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-[15px] p-4 mb-6">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{backupCodes.map((code, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-2 bg-white border border-gray-200 rounded-[8px] text-center font-mono text-[13px] text-[#00293D]"
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<p className="text-[11px] text-red-600 font-serif">
|
||||||
|
Each code can only be used once. Store them safely!
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyBackupCodes}
|
||||||
|
className="px-4 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
{backupCodesCopied ? 'Copied!' : 'Copy All'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={downloadBackupCodes}
|
||||||
|
className="px-4 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStep('status');
|
||||||
|
setBackupCodes([]);
|
||||||
|
setSetupData(null);
|
||||||
|
setVerificationCode('');
|
||||||
|
}}
|
||||||
|
className="px-6 py-2 bg-[#e58625] text-white rounded-[15px] font-serif text-[12px] hover:bg-[#d47920] transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable view
|
||||||
|
if (step === 'disable') {
|
||||||
|
return (
|
||||||
|
<div className="border border-[#00293d]/10 rounded-[15px] bg-white p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="font-fractul font-bold text-[16px] text-[#00293D] mb-2">
|
||||||
|
Disable Two-Factor Authentication
|
||||||
|
</h2>
|
||||||
|
<p className="font-serif text-[12px] text-[#00293D]/60">
|
||||||
|
Enter your password to confirm you want to disable 2FA. This will make your account less secure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-[10px] text-red-700 text-[13px] font-serif">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-w-md mb-6">
|
||||||
|
<label className="block text-[12px] font-medium text-[#00293D]/70 font-serif mb-2">
|
||||||
|
Your Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showDisablePassword ? 'text' : 'password'}
|
||||||
|
value={disablePassword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisablePassword(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Enter your password"
|
||||||
|
className="w-full h-[40px] px-4 pr-12 border border-[#00293D]/20 rounded-[15px] text-[14px] font-serif text-[#00293D] placeholder:text-[#00293D]/40 focus:outline-none focus:border-[#E58625]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDisablePassword(!showDisablePassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-[#00293D]/50 hover:text-[#00293D] cursor-pointer"
|
||||||
|
>
|
||||||
|
{showDisablePassword ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStep('status');
|
||||||
|
setDisablePassword('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="px-6 py-2 border border-[#00293D]/20 text-[#00293D] rounded-[15px] font-serif text-[12px] hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDisable}
|
||||||
|
disabled={isLoading || !disablePassword}
|
||||||
|
className="px-6 py-2 bg-red-600 text-white rounded-[15px] font-serif text-[12px] hover:bg-red-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Disabling...' : 'Disable 2FA'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ export { ProfileSettingsForm } from './ProfileSettingsForm';
|
|||||||
export { PasswordSecurityForm } from './PasswordSecurityForm';
|
export { PasswordSecurityForm } from './PasswordSecurityForm';
|
||||||
export { NotificationsForm } from './NotificationsForm';
|
export { NotificationsForm } from './NotificationsForm';
|
||||||
export { PrivacyForm } from './PrivacyForm';
|
export { PrivacyForm } from './PrivacyForm';
|
||||||
|
export { TwoFactorSettings } from './TwoFactorSettings';
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ const publicEndpoints = [
|
|||||||
'/auth/forgot-password',
|
'/auth/forgot-password',
|
||||||
'/auth/reset-password',
|
'/auth/reset-password',
|
||||||
'/auth/verify-email',
|
'/auth/verify-email',
|
||||||
|
'/auth/2fa/verify',
|
||||||
|
'/auth/2fa/verify-backup',
|
||||||
'/agent-types',
|
'/agent-types',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,15 @@ export type { AgentProfile, AgentType } from './agents.service';
|
|||||||
// Upload Service
|
// Upload Service
|
||||||
export { uploadService } from './upload.service';
|
export { uploadService } from './upload.service';
|
||||||
export type { UploadedFile, UploadProgress } from './upload.service';
|
export type { UploadedFile, UploadProgress } from './upload.service';
|
||||||
|
|
||||||
|
// Two-Factor Authentication Service
|
||||||
|
export { twoFactorService, TwoFactorService } from './two-factor.service';
|
||||||
|
export type {
|
||||||
|
TwoFactorSetupResponse,
|
||||||
|
TwoFactorEnableResponse,
|
||||||
|
TwoFactorStatusResponse,
|
||||||
|
TwoFactorLoginRequest,
|
||||||
|
TwoFactorBackupCodeRequest,
|
||||||
|
TwoFactorLoginResponse,
|
||||||
|
TwoFactorRequiredResponse,
|
||||||
|
} from './two-factor.service';
|
||||||
|
|||||||
148
src/services/two-factor.service.ts
Normal file
148
src/services/two-factor.service.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import api, { ApiResponse } from './api';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface TwoFactorSetupResponse {
|
||||||
|
secret: string;
|
||||||
|
qrCode: string;
|
||||||
|
manualEntry: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorEnableResponse {
|
||||||
|
backupCodes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorStatusResponse {
|
||||||
|
enabled: boolean;
|
||||||
|
verifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorLoginRequest {
|
||||||
|
tempToken: string;
|
||||||
|
token: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorBackupCodeRequest {
|
||||||
|
tempToken: string;
|
||||||
|
backupCode: string;
|
||||||
|
userAgent?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorLoginResponse {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwoFactorRequiredResponse {
|
||||||
|
requiresTwoFactor: true;
|
||||||
|
tempToken: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-Factor Service
|
||||||
|
class TwoFactorService {
|
||||||
|
private basePath = '/auth/2fa';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup 2FA - generates secret and QR code
|
||||||
|
*/
|
||||||
|
async setup(): Promise<ApiResponse<TwoFactorSetupResponse>> {
|
||||||
|
const response = await api.post<ApiResponse<TwoFactorSetupResponse>>(
|
||||||
|
`${this.basePath}/setup`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable 2FA - verify token and enable
|
||||||
|
*/
|
||||||
|
async enable(token: string): Promise<ApiResponse<TwoFactorEnableResponse>> {
|
||||||
|
const response = await api.post<ApiResponse<TwoFactorEnableResponse>>(
|
||||||
|
`${this.basePath}/enable`,
|
||||||
|
{ token }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable 2FA - requires password confirmation
|
||||||
|
*/
|
||||||
|
async disable(password: string): Promise<ApiResponse<{ message: string }>> {
|
||||||
|
const response = await api.post<ApiResponse<{ message: string }>>(
|
||||||
|
`${this.basePath}/disable`,
|
||||||
|
{ password }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify 2FA token during login
|
||||||
|
*/
|
||||||
|
async verifyLogin(data: TwoFactorLoginRequest): Promise<ApiResponse<TwoFactorLoginResponse>> {
|
||||||
|
const response = await api.post<ApiResponse<TwoFactorLoginResponse>>(
|
||||||
|
`${this.basePath}/verify`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify backup code during login
|
||||||
|
*/
|
||||||
|
async verifyBackupCode(data: TwoFactorBackupCodeRequest): Promise<ApiResponse<TwoFactorLoginResponse>> {
|
||||||
|
const response = await api.post<ApiResponse<TwoFactorLoginResponse>>(
|
||||||
|
`${this.basePath}/verify-backup`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get 2FA status
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<ApiResponse<TwoFactorStatusResponse>> {
|
||||||
|
const response = await api.get<ApiResponse<TwoFactorStatusResponse>>(
|
||||||
|
`${this.basePath}/status`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate backup codes - requires password confirmation
|
||||||
|
*/
|
||||||
|
async regenerateBackupCodes(password: string): Promise<ApiResponse<TwoFactorEnableResponse>> {
|
||||||
|
const response = await api.post<ApiResponse<TwoFactorEnableResponse>>(
|
||||||
|
`${this.basePath}/backup-codes`,
|
||||||
|
{ password }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if response indicates 2FA is required
|
||||||
|
*/
|
||||||
|
static isTwoFactorRequired(response: unknown): response is TwoFactorRequiredResponse {
|
||||||
|
return (
|
||||||
|
typeof response === 'object' &&
|
||||||
|
response !== null &&
|
||||||
|
'requiresTwoFactor' in response &&
|
||||||
|
(response as TwoFactorRequiredResponse).requiresTwoFactor === true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const twoFactorService = new TwoFactorService();
|
||||||
|
export { TwoFactorService };
|
||||||
Reference in New Issue
Block a user