diff --git a/app/me/layout.tsx b/app/me/layout.tsx index a2ad6e87f..0560658fa 100644 --- a/app/me/layout.tsx +++ b/app/me/layout.tsx @@ -4,7 +4,7 @@ import { AppSidebar } from '@/components/app-sidebar'; import { SiteHeader } from '@/components/site-header'; import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; import { useAuthStatus } from '@/hooks/use-auth'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef, useEffect } from 'react'; import LoadingSpinner from '@/components/LoadingSpinner'; interface MeLayoutProps { @@ -33,6 +33,13 @@ const getId = (item: ProfileItemWithId): string | undefined => const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { const { user, isLoading } = useAuthStatus(); + // Track whether we've completed the very first load. + // This prevents children from unmounting during background session refetches + // (e.g. on window focus), which would destroy component state like 2FA steps. + const hasLoadedOnce = useRef(false); + useEffect(() => { + if (!isLoading) hasLoadedOnce.current = true; + }, [isLoading]); const { name = '', email = '', profile, image: userImage = '' } = user || {}; const typedProfile = profile as MeLayoutProfile | null | undefined; @@ -62,7 +69,8 @@ const MeLayout = ({ children }: MeLayoutProps): React.ReactElement => { }).length; }, [typedProfile]); - if (isLoading) { + // Only show full-screen spinner on first load, not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (
diff --git a/app/me/settings/SettingsContent.tsx b/app/me/settings/SettingsContent.tsx index 80ade6288..0f24738de 100644 --- a/app/me/settings/SettingsContent.tsx +++ b/app/me/settings/SettingsContent.tsx @@ -8,15 +8,20 @@ import { getMe } from '@/lib/api/auth'; import { GetMeResponse } from '@/lib/api/types'; import { Skeleton } from '@/components/ui/skeleton'; import Settings from '@/components/profile/update/Settings'; -import { SecuritySettingsTab } from '@/components/profile/update/SecuritySettingsTab'; +import TwoFactorTab from '@/components/profile/update/TwoFactorTab'; +import SecurityTab from '@/components/profile/update/SecurityTab'; import { IdentityVerificationSection } from '@/components/didit/IdentityVerificationSection'; import { invalidateAuthProfileCache } from '@/hooks/use-auth'; +import { useRef } from 'react'; +import { Loader2 } from 'lucide-react'; const SettingsContent = () => { const searchParams = useSearchParams(); const fromVerification = searchParams.get('verification') === 'complete'; const [userData, setUserData] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Prevent unmounting tabs on background refetches (e.g. after 2FA enable) + const hasLoadedOnce = useRef(false); const fetchUserData = useCallback(async () => { try { @@ -26,11 +31,15 @@ const SettingsContent = () => { setUserData(null); } finally { setIsLoading(false); + hasLoadedOnce.current = true; } }, []); useEffect(() => { - setIsLoading(true); + // Only set isLoading true on the very first fetch + if (!hasLoadedOnce.current) { + setIsLoading(true); + } fetchUserData(); }, [fetchUserData]); @@ -39,7 +48,8 @@ const SettingsContent = () => { invalidateAuthProfileCache(); }, [fetchUserData]); - if (isLoading) { + // Only show skeleton on first load — not on background refetches + if (isLoading && !hasLoadedOnce.current) { return (
@@ -113,7 +123,14 @@ const SettingsContent = () => { - + {userData?.user ? ( + + ) : ( +
+ + Loading profile... +
+ )}
@@ -127,8 +144,30 @@ const SettingsContent = () => { - - + + {userData?.user ? ( + + ) : ( +
+ + + Loading security settings... + +
+ )} +
+ + {userData?.user ? ( + + ) : ( +
+ + Loading 2FA settings... +
+ )}
{ const [showPassword, setShowPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); const [lastMethod, setLastMethod] = useState(null); + const [twoFactorRequired, setTwoFactorRequired] = useState(false); const callbackUrl = searchParams.get('callbackUrl') ? decodeURIComponent(searchParams.get('callbackUrl')!) @@ -42,16 +44,6 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setLastMethod(method); }, []); - useEffect(() => { - const method = authClient.getLastUsedLoginMethod(); - setLastMethod(method); - }, []); - - useEffect(() => { - const method = authClient.getLastUsedLoginMethod(); - setLastMethod(method); - }, []); - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -89,6 +81,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { const errorStatus = error.status; const errorCode = error.code; + // Check for 2FA requirement + if ( + errorStatus === 403 && + (errorMessage === 'two_factor_required' || + errorMessage?.includes('two_factor') || + errorCode === 'TWO_FACTOR_REQUIRED') + ) { + setTwoFactorRequired(true); + return; + } + if (errorStatus === 403 || errorCode === 'FORBIDDEN') { const message = 'Please verify your email before signing in. Check your inbox for a verification link.'; @@ -171,8 +174,36 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); + const syncSession = async () => { + const session = await authClient.getSession(); + if (session && typeof session === 'object' && 'user' in session) { + const sessionUser = session.user as + | { + id: string; + email: string; + name?: string | null; + image?: string | null; + } + | null + | undefined; + + if (sessionUser && sessionUser.id && sessionUser.email) { + const authStore = useAuthStore.getState(); + await authStore.syncWithSession({ + id: sessionUser.id, + email: sessionUser.email, + name: sessionUser.name || undefined, + image: sessionUser.image || undefined, + role: 'USER', + username: undefined, + accessToken: undefined, + }); + } + } + }; + try { - const { error } = await authClient.signIn.email( + const { data, error } = await authClient.signIn.email( { email: values.email, password: values.password, @@ -184,42 +215,26 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { setIsLoading(true); setLoadingState(true); }, - onSuccess: async () => { - await new Promise(resolve => setTimeout(resolve, 200)); + onSuccess: async ctx => { + const resData = ctx?.data as + | { + twoFactorRequired?: boolean; + twoFactorRedirect?: boolean; + } + | undefined; - const session = await authClient.getSession(); - - if (session && typeof session === 'object' && 'user' in session) { - const sessionUser = session.user as - | { - id: string; - email: string; - name?: string | null; - image?: string | null; - } - | null - | undefined; - - if (sessionUser && sessionUser.id && sessionUser.email) { - const authStore = useAuthStore.getState(); - await authStore.syncWithSession({ - id: sessionUser.id, - email: sessionUser.email, - name: sessionUser.name || undefined, - image: sessionUser.image || undefined, - role: 'USER', - username: undefined, - accessToken: undefined, - }); - } + if (resData?.twoFactorRequired || resData?.twoFactorRedirect) { + setTwoFactorRequired(true); + setIsLoading(false); + setLoadingState(false); + return; } - // Keep loading state active during redirect - // The page will unmount when redirecting, so no need to set false + await new Promise(resolve => setTimeout(resolve, 200)); + await syncSession(); window.location.href = callbackUrl; }, onError: ctx => { - // Handle error from Better Auth callback const errorObj = ctx.error || ctx; handleAuthError( typeof errorObj === 'object' @@ -233,14 +248,21 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { } ); - // Handle error from return value if (error) { handleAuthError(error, values); setIsLoading(false); setLoadingState(false); + } else if ( + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRequired || + (data as { twoFactorRequired?: boolean; twoFactorRedirect?: boolean }) + ?.twoFactorRedirect + ) { + setTwoFactorRequired(true); + setIsLoading(false); + setLoadingState(false); } } catch (error) { - // Handle unexpected errors const errorObj = error instanceof Error ? { message: error.message, status: undefined, code: undefined } @@ -254,6 +276,17 @@ const LoginWrapper = ({ setLoadingState }: LoginWrapperProps) => { [handleAuthError, setLoadingState, callbackUrl] ); + if (twoFactorRequired) { + return ( + { + window.location.href = callbackUrl; + }} + onCancel={() => setTwoFactorRequired(false)} + /> + ); + } + return ( Promise; + onCancel: () => void; +} + +const TwoFactorVerify = ({ onSuccess, onCancel }: TwoFactorVerifyProps) => { + const [code, setCode] = useState(''); + const [backupCode, setBackupCode] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isBackupMode, setIsBackupMode] = useState(false); + + const handleVerify = async (codeValue: string) => { + if (codeValue.length < 6) return; + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyTotp({ + code: codeValue, + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setCode(''); // Clear on error to let user try again + return; + } + + if (data) { + toast.success('Verification successful'); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + } finally { + setIsLoading(false); + } + }; + + const handleVerifyBackupCode = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!backupCode) { + toast.error('Please enter a backup code'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.verifyBackupCode({ + code: backupCode.trim(), + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setBackupCode(''); + return; + } + + if (data) { + toast.success('Recovery successful'); + setBackupCode(''); + await onSuccess(); + } + } catch (err) { + toast.error('An unexpected error occurred during verification'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+

+ {isBackupMode ? 'Account Recovery' : 'Two-Factor Authentication'} +

+

+ {isBackupMode + ? 'Enter a one-time backup code to access your account.' + : 'Enter the 6-digit verification code from your authenticator app.'} +

+
+ +
+ {!isBackupMode ? ( +
+
+ { + setCode(val); + if (val.length === 6) { + handleVerify(val); + } + }} + disabled={isLoading} + autoFocus + > + + {[...Array(6)].map((_, i) => ( + + ))} + + +
+ + +
+ ) : ( +
+
+
+ setBackupCode(e.target.value)} + className='h-14 w-full rounded-lg border border-zinc-800 bg-zinc-900/50 text-center text-xl tracking-widest text-white focus:border-[#a7f950]/50 focus:outline-none' + autoFocus + /> +
+ +
+ + +
+ )} + +
+ +
+
+
+ ); +}; + +export default TwoFactorVerify; diff --git a/components/profile/update/SecurityTab.tsx b/components/profile/update/SecurityTab.tsx new file mode 100644 index 000000000..4bdb58785 --- /dev/null +++ b/components/profile/update/SecurityTab.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { BoundlessButton } from '@/components/buttons'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ShieldCheck, LockIcon } from 'lucide-react'; +import { User } from '@/types/user'; + +const passwordSchema = z + .object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z + .string() + .min(8, 'New password must be at least 8 characters'), + confirmPassword: z.string().min(1, 'Please confirm your new password'), + }) + .refine(data => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); + +type PasswordFormValues = z.infer; + +interface SecurityTabProps { + user: User; +} + +const SecurityTab = ({ user }: SecurityTabProps) => { + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordSchema), + }); + + const onSubmit = async (data: PasswordFormValues) => { + setIsLoading(true); + try { + const { error } = await authClient.changePassword({ + currentPassword: data.currentPassword, + newPassword: data.newPassword, + }); + + if (error) { + toast.error(error.message || 'Failed to update password'); + } else { + toast.success('Password updated successfully'); + reset(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+
+ +
+
+

+ Change Password +

+

+ Update your account password +

+
+
+ +
+
+ + + {errors.currentPassword && ( +

+ {errors.currentPassword.message} +

+ )} +
+ +
+ + + {errors.newPassword && ( +

+ {errors.newPassword.message} +

+ )} +
+ +
+ + + {errors.confirmPassword && ( +

+ {errors.confirmPassword.message} +

+ )} +
+ + + Update Password + +
+
+ +
+
+
+
+ +
+
+

+ Two-Factor Authentication +

+

+ Add an extra layer of security to your account. You can manage + this in the dedicated 2FA tab. +

+
+
+
+
+ + {user.twoFactorEnabled ? 'Enabled' : 'Disabled'} + +
+
+
+
+ ); +}; + +export default SecurityTab; diff --git a/components/profile/update/Settings.tsx b/components/profile/update/Settings.tsx index 010c08947..e1c4ec0c6 100644 --- a/components/profile/update/Settings.tsx +++ b/components/profile/update/Settings.tsx @@ -63,25 +63,16 @@ const settingsSchema = z.object({ type SettingsFormData = z.infer; -export type SettingsSection = - | 'notifications' - | 'privacy' - | 'appearance' - | 'preferences'; - interface SettingsProps { - /** When set, only these sections are rendered (for use in separate tabs). */ - visibleSections?: SettingsSection[]; + visibleSections?: ( + | 'notifications' + | 'privacy' + | 'appearance' + | 'preferences' + )[]; } const Settings = ({ visibleSections }: SettingsProps) => { - const sections: SettingsSection[] = visibleSections ?? [ - 'notifications', - 'privacy', - 'appearance', - 'preferences', - ]; - const showAllSections = !visibleSections || visibleSections.length === 0; const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [settings, setSettings] = useState({ @@ -251,7 +242,8 @@ const Settings = ({ visibleSections }: SettingsProps) => {
- {sections.includes('notifications') && ( + {/* Notifications */} + {(!visibleSections || visibleSections.includes('notifications')) && (
@@ -326,7 +318,8 @@ const Settings = ({ visibleSections }: SettingsProps) => { )} - {sections.includes('privacy') && ( + {/* Privacy */} + {(!visibleSections || visibleSections.includes('privacy')) && (
diff --git a/components/profile/update/TwoFactorTab.tsx b/components/profile/update/TwoFactorTab.tsx new file mode 100644 index 000000000..37c3bc1aa --- /dev/null +++ b/components/profile/update/TwoFactorTab.tsx @@ -0,0 +1,514 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; +import { BoundlessButton } from '@/components/buttons'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { QRCodeSVG } from 'qrcode.react'; +import { User } from '@/types/user'; +import { + Loader2, + Copy, + ShieldCheck, + ShieldAlert, + KeyRound, + Smartphone, +} from 'lucide-react'; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from '@/components/ui/input-otp'; + +interface TwoFactorTabProps { + user: User; + onStatusChange: () => void; +} + +const TwoFactorTab = ({ user, onStatusChange }: TwoFactorTabProps) => { + const [step, setStep] = useState<'status' | 'setup' | 'verify' | 'backup'>( + 'status' + ); + const [password, setPassword] = useState(''); + const [totpUri, setTotpUri] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [backupCodes, setBackupCodes] = useState([]); + const [secretKey, setSecretKey] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [showCodes, setShowCodes] = useState(false); + + const handleStartSetup = async () => { + if (!password) { + toast.error('Please enter your password to enable 2FA'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.enable({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to start 2FA setup'); + } else if (data) { + setTotpUri(data.totpURI); + try { + const url = new URL(data.totpURI); + const secret = url.searchParams.get('secret'); + setSecretKey(secret || ''); + } catch (e) { + // Fallback to simple split if URL parsing fails + const secret = data.totpURI.split('secret=')[1]?.split('&')[0]; + setSecretKey(secret || ''); + } + setBackupCodes(data.backupCodes); + setStep('setup'); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleVerifySetup = async (codeValue: string) => { + if (codeValue.length !== 6) return; + + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.verifyTotp({ + code: codeValue, + }); + + if (error) { + toast.error(error.message || 'Verification failed'); + setVerificationCode(''); // Clear on error + } else { + toast.success('Two-factor authentication enabled successfully!'); + setStep('backup'); + onStatusChange(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleRegenerateCodes = async () => { + if (!password) { + toast.error('Please enter your password to regenerate codes'); + return; + } + + setIsLoading(true); + try { + const { data, error } = await authClient.twoFactor.generateBackupCodes({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to regenerate backup codes'); + } else if (data) { + setBackupCodes(data.backupCodes); + setShowCodes(true); + setPassword(''); + toast.success('New backup codes generated'); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const handleDisable = async () => { + if (!password) { + toast.error('Please enter your password to disable 2FA'); + return; + } + + setIsLoading(true); + try { + const { error } = await authClient.twoFactor.disable({ + password, + }); + + if (error) { + toast.error(error.message || 'Failed to disable 2FA'); + } else { + toast.success('Two-factor authentication disabled'); + setPassword(''); + setStep('status'); + onStatusChange(); + } + } catch (err) { + toast.error('An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const copyBackupCodes = async (codes: string[]) => { + const text = codes.join('\n'); + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + toast.success('Backup codes copied to clipboard'); + } else { + throw new Error('Clipboard API unavailable'); + } + } catch (err) { + // Fallback for older browsers or non-secure contexts + try { + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.style.position = 'fixed'; + textArea.style.left = '-9999px'; + textArea.style.top = '0'; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + if (successful) { + toast.success('Backup codes copied to clipboard'); + } else { + throw new Error('Fallback copy failed'); + } + } catch (fallbackErr) { + toast.error('Unable to copy backup codes'); + } + } + }; + + if (user.twoFactorEnabled && step === 'status') { + return ( +
+
+
+ +
+
+

2FA is Enabled

+

+ Your account is protected with an extra layer of security. You + will be prompted for a verification code when signing in. +

+
+
+ +
+
+
+ +

Backup Codes

+
+

+ Backup codes can be used to access your account if you lose your + authentication device. Each code can only be used once. +

+ + {!showCodes ? ( +
+
+ + setPassword(e.target.value)} + className='h-11 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+
+ + Regenerate Codes + + + Disable 2FA + +
+
+ ) : ( +
+
+ {backupCodes.map((code, i) => ( +
+ {code} +
+ ))} +
+
+ copyBackupCodes(backupCodes)} + > + Copy All + + setShowCodes(false)} + > + Hide Codes + +
+
+ )} +
+
+
+ ); + } + + if (step === 'status') { + return ( +
+
+
+ +
+
+

+ 2FA is Not Enabled +

+

+ We recommend enabling two-factor authentication to keep your + account secure. You'll need an authenticator app like Google + Authenticator or Authy. +

+
+
+ +
+
+ + setPassword(e.target.value)} + className='h-11 border-zinc-800 bg-zinc-900/50 text-white placeholder:text-zinc-600' + /> +
+ + Setup 2FA + +
+
+ ); + } + + if (step === 'setup') { + return ( +
+
+
+ 1 +
+

Scan QR Code

+
+ +
+
+ +
+
+
+ +

+ Scan this code with your authenticator app. +

+
+
+ +

+ If you can't scan, you can manually enter the secret key. +

+
+ + {secretKey && ( +
+
+ + Secret Key + + + {secretKey} + +
+ +
+ )} + +
+ setStep('verify')} + > + Next: Verify Code + +
+
+
+
+ ); + } + + if (step === 'verify') { + return ( +
+
+
+ 2 +
+

Verify Setup

+
+ +
+
+

+ Enter the 6-digit code from your app to confirm everything is + working. +

+
+
+ { + setVerificationCode(val); + if (val.length === 6) { + handleVerifySetup(val); + } + }} + disabled={isLoading} + autoFocus + > + + + + + + + + + +
+
+ setStep('setup')} + disabled={isLoading} + > + Back + +
+
+
+ ); + } + + if (step === 'backup') { + return ( +
+
+
+ +

2FA Enabled Successfully

+
+

+ Please save these backup codes in a safe place. You can use them to + access your account if you lose your phone. +

+
+ +
+
+ {backupCodes.map((code, i) => ( +
+ {code} +
+ ))} +
+
+ copyBackupCodes(backupCodes)} + > + Copy Codes + + { + setStep('status'); + setPassword(''); + setShowCodes(false); + }} + > + Done + +
+
+
+ ); + } + + return null; +}; + +export default TwoFactorTab; diff --git a/lib/api/auth.ts b/lib/api/auth.ts index ceaeb1fc2..b0abd4721 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -315,3 +315,126 @@ export const updateUserAvatar = async ( message: axiosRes.data.message, }; }; +/** + * Two-factor authentication interfaces + */ +export interface TwoFactorStatusResponse { + twoFactorEnabled: boolean; +} + +export interface GetTotpUriResponse { + totpURI: string; +} + +export interface VerifyTotpResponse { + status: boolean; +} + +export interface EnableTwoFactorResponse { + totpURI: string; + backupCodes: string[]; +} + +export interface GenerateBackupCodesResponse { + status: boolean; + backupCodes: string[]; +} + +/** + * AXIOS-BASED 2FA HELPERS + * + * Note: These functions use direct axios-based API calls instead of the Better Auth client plugin. + * They are preserved for use in internal tools, CLI scripts, or specific out-of-UI contexts + * where the standard authClient plugins are not appropriate. + * + * For standard UI components, prefer using `authClient.twoFactor.*`. + */ + +/** + * Get TOTP URI for setup + const res = await api.post( + '/auth/two-factor/get-totp-uri', + { password } + ); + return res.data.totpURI; +}; + +export const verifyTotp = async ( + code: string, + trustDevice: boolean | null = null +): Promise => { + const res = await api.post( + '/auth/two-factor/verify-totp', + { code, trustDevice } + ); + return res.data.status; +}; + +export const sendTwoFactorOtp = async (): Promise => { + const res = await api.post<{ status: boolean }>('/auth/two-factor/send-otp'); + return res.data.status; +}; + +export const verifyTwoFactorOtp = async ( + code: string, + trustDevice: boolean | null = null +): Promise<{ token: string; user: User }> => { + const res = await api.post<{ token: string; user: User }>( + '/auth/two-factor/verify-otp', + { code, trustDevice } + ); + return res.data; +}; + +/** + * Verify backup code + */ +export const verifyBackupCode = async ( + code: string, + trustDevice: boolean | null = null, + disableSession: boolean | null = null +): Promise<{ + user: User; + session: { id: string; userId: string; token: string; expiresAt: Date }; +}> => { + const res = await api.post<{ + user: User; + session: { id: string; userId: string; token: string; expiresAt: Date }; + }>('/auth/two-factor/verify-backup-code', { + code, + trustDevice, + disableSession, + }); + return res.data; +}; + +export const generateBackupCodes = async ( + password: string +): Promise => { + const res = await api.post( + '/auth/two-factor/generate-backup-codes', + { password } + ); + return res.data.backupCodes; +}; + +export const enableTwoFactor = async ( + password: string, + issuer: string | null = 'Boundless' +): Promise => { + const res = await api.post( + '/auth/two-factor/enable', + { + password, + issuer: issuer || 'Boundless', + } + ); + return res.data; +}; + +export const disableTwoFactor = async (password: string): Promise => { + const res = await api.post<{ status: boolean }>('/auth/two-factor/disable', { + password, + }); + return res.data.status; +};