feat(2fa): implement phone verification feature with MFA support

- Added phone verification endpoints for TOTP-based multi-factor authentication in the backend.
- Created frontend components for phone input and OTP verification.
- Integrated phone verification guard to ensure users complete verification before accessing the app.
- Updated API routes and React Query hooks for managing phone verification status and actions.

This feature enhances account security by requiring users to verify their phone numbers.
This commit is contained in:
sharath 2025-07-19 15:14:55 +00:00
parent 91b9cff776
commit a332cf9a31
No known key found for this signature in database
11 changed files with 979 additions and 1 deletions

View File

@ -187,6 +187,9 @@ api_router.include_router(workflows_router, prefix="/workflows")
from pipedream import api as pipedream_api
api_router.include_router(pipedream_api.router)
from auth import phone_verification_supabase_mfa
api_router.include_router(phone_verification_supabase_mfa.router)
@api_router.get("/health")
async def health_check():
logger.info("Health check endpoint called")

1
backend/auth/__init__.py Normal file
View File

@ -0,0 +1 @@
# Auth Module

View File

@ -0,0 +1,290 @@
"""
Auth MFA endpoints for Supabase TOTP-based Multi-Factor Authentication (MFA).
Currently, only TOTP is supported as a second factor. Users can enroll up to 10 TOTP factors.
No recovery codes are supported, but users can enroll multiple TOTP factors for backup.
This API provides endpoints to:
- Enroll a TOTP factor
- Create a challenge for a factor
- Verify a challenge
- Create and verify a challenge in a single step
- List enrolled factors
- Unenroll a factor
- Get Authenticator Assurance Level (AAL)
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from typing import List, Optional
import jwt
from supabase import create_client, Client
from utils.auth_utils import get_current_user_id_from_jwt
from utils.config import config
router = APIRouter(prefix="/mfa", tags=["MFA"])
# Initialize Supabase client with anon key for user operations
supabase_url = config.SUPABASE_URL
supabase_anon_key = config.SUPABASE_ANON_KEY
def get_authenticated_client(request: Request) -> Client:
"""
Create a Supabase client authenticated with the user's JWT token.
This approach uses the JWT token directly for server-side authentication.
"""
# Extract the JWT token from the Authorization header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
token = auth_header.split(" ")[1]
# Create a new Supabase client with the anon key
client = create_client(supabase_url, supabase_anon_key)
# Set the session with the JWT token
# For server-side operations, we can use the token directly
try:
# Verify the token is valid by getting the user
user_response = client.auth.get_user(token)
if not user_response.user:
raise HTTPException(status_code=401, detail="Invalid token")
# Set the session with the token
client.auth.set_session(token, None)
return client
except Exception as e:
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
# Request/Response Models
class EnrollFactorRequest(BaseModel):
factor_type: str = Field(default="totp", description="Type of factor to enroll")
friendly_name: str = Field(..., description="User-friendly name for the factor")
class EnrollFactorResponse(BaseModel):
id: str
friendly_name: str
factor_type: str
status: str
created_at: str
updated_at: str
class ChallengeRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to challenge")
class ChallengeResponse(BaseModel):
id: str
factor_type: str
created_at: str
expires_at: str
class VerifyRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to verify")
challenge_id: str = Field(..., description="ID of the challenge to verify")
code: str = Field(..., description="TOTP code to verify")
class ChallengeAndVerifyRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to challenge and verify")
code: str = Field(..., description="TOTP code to verify")
class FactorInfo(BaseModel):
id: str
friendly_name: str
factor_type: str
status: str
created_at: str
updated_at: str
class ListFactorsResponse(BaseModel):
factors: List[FactorInfo]
class UnenrollRequest(BaseModel):
factor_id: str = Field(..., description="ID of the factor to unenroll")
class AALResponse(BaseModel):
current_level: str
next_level: str
current_authentication_methods: List[str]
@router.post("/enroll", response_model=EnrollFactorResponse)
async def enroll_factor(
request_data: EnrollFactorRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Enroll a new TOTP factor for the authenticated user.
Currently only supports 'totp' factor type.
Users can enroll up to 10 TOTP factors.
"""
try:
response = client.auth.mfa.enroll({
"factor_type": request_data.factor_type,
"friendly_name": request_data.friendly_name
})
return EnrollFactorResponse(
id=response.id,
friendly_name=response.friendly_name,
factor_type=response.type,
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to enroll factor: {str(e)}")
@router.post("/challenge", response_model=ChallengeResponse)
async def create_challenge(
request_data: ChallengeRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Create a challenge for an enrolled factor.
The challenge must be verified within the time limit.
"""
try:
response = client.auth.mfa.challenge({
"factor_id": request_data.factor_id,
})
return ChallengeResponse(
id=response.id,
factor_type=response.factor_type,
expires_at=response.expires_at
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to create challenge: {str(e)}")
@router.post("/verify")
async def verify_challenge(
request_data: VerifyRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Verify a challenge with a TOTP code.
The challenge must be active and the code must be valid.
"""
try:
response = client.auth.mfa.verify({
"factor_id": request_data.factor_id,
"challenge_id": request_data.challenge_id,
"code": request_data.code
})
return {
"success": True,
"message": "Challenge verified successfully",
"session": response,
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to verify challenge: {str(e)}")
@router.post("/challenge-and-verify")
async def challenge_and_verify(
request_data: ChallengeAndVerifyRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Create a challenge and verify it in a single step.
This is a convenience method that combines challenge creation and verification.
"""
try:
response = client.auth.mfa.challenge_and_verify({
"factor_id": request_data.factor_id,
"code": request_data.code
})
return {
"success": True,
"message": "Challenge created and verified successfully",
"session": response,
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to challenge and verify: {str(e)}")
@router.get("/factors", response_model=ListFactorsResponse)
async def list_factors(
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
List all enrolled factors for the authenticated user.
"""
try:
# Get the current session to access user's factors
session = client.auth.get_session()
if not session:
raise HTTPException(status_code=401, detail="No active session")
# Get user info which includes factors
user_response = client.auth.get_user()
if not user_response.user:
raise HTTPException(status_code=401, detail="User not found")
# Extract factors from user data
factors = []
if hasattr(user_response.user, 'factors') and user_response.user.factors:
for factor in user_response.user.factors:
factors.append(FactorInfo(
id=factor.id,
friendly_name=factor.friendly_name,
factor_type=factor.factor_type,
status=factor.status,
created_at=factor.created_at,
updated_at=factor.updated_at
))
return ListFactorsResponse(factors=factors)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to list factors: {str(e)}")
@router.delete("/unenroll")
async def unenroll_factor(
request_data: UnenrollRequest,
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Unenroll a factor for the authenticated user.
This will remove the factor and invalidate any active sessions if the factor was verified.
"""
try:
response = client.auth.mfa.unenroll({
"factor_id": request_data.factor_id
})
return {
"success": True,
"message": "Factor unenrolled successfully"
}
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to unenroll factor: {str(e)}")
@router.get("/aal", response_model=AALResponse)
async def get_authenticator_assurance_level(
client: Client = Depends(get_authenticated_client),
user_id: str = Depends(get_current_user_id_from_jwt)
):
"""
Get the Authenticator Assurance Level (AAL) for the current session.
AAL1: First factor only (email/password, OAuth)
AAL2: Second factor required (TOTP)
"""
try:
response = client.auth.mfa.get_authenticator_assurance_level()
return AALResponse(
current_level=response.current_level,
next_level=response.next_level,
current_authentication_methods=response.current_authentication_methods
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Failed to get AAL: {str(e)}")

View File

@ -0,0 +1,5 @@
import { PhoneVerificationPage } from "@/components/auth/phone-verification/phone-verification-page";
export default function PhoneVerificationRoute() {
return <PhoneVerificationPage />;
}

View File

@ -5,6 +5,7 @@ import { useState, createContext, useEffect } from 'react';
import { AuthProvider } from '@/components/AuthProvider';
import { ReactQueryProvider } from '@/providers/react-query-provider';
import { dehydrate, QueryClient } from '@tanstack/react-query';
import { PhoneVerificationGuard } from '@/components/auth/phone-verification/phone-verification-guard';
export interface ParsedTag {
tagName: string;
@ -45,7 +46,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
<ToolCallsContext.Provider value={{ toolCalls, setToolCalls }}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ReactQueryProvider dehydratedState={dehydratedState}>
<PhoneVerificationGuard>
{children}
</PhoneVerificationGuard>
</ReactQueryProvider>
</ThemeProvider>
</ToolCallsContext.Provider>

View File

@ -0,0 +1,197 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
interface OtpVerificationProps {
phoneNumber: string;
onVerify: (otp: string) => Promise<void>;
onResend: () => Promise<void>;
isLoading?: boolean;
error?: string | null;
}
export function OtpVerification({
phoneNumber,
onVerify,
onResend,
isLoading = false,
error = null
}: OtpVerificationProps) {
const [otp, setOtp] = useState(["", "", "", "", "", ""]);
const [localError, setLocalError] = useState<string | null>(null);
const [canResend, setCanResend] = useState(false);
const [countdown, setCountdown] = useState(30);
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
// Focus first input on mount
inputRefs.current[0]?.focus();
// Start countdown timer
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
setCanResend(true);
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, []);
const handleOtpChange = (index: number, value: string) => {
setLocalError(null);
// Only allow single digit
if (value.length > 1) {
value = value.slice(-1);
}
// Only allow digits
if (value && !/^\d$/.test(value)) {
return;
}
const newOtp = [...otp];
newOtp[index] = value;
setOtp(newOtp);
// Auto-focus next input
if (value && index < 5) {
inputRefs.current[index + 1]?.focus();
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent) => {
if (e.key === "Backspace" && !otp[index] && index > 0) {
// Move to previous input on backspace if current is empty
inputRefs.current[index - 1]?.focus();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData("text");
const digits = pastedData.replace(/\D/g, "").slice(0, 6);
if (digits.length === 6) {
const newOtp = digits.split("");
setOtp(newOtp);
inputRefs.current[5]?.focus();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
const otpCode = otp.join("");
if (otpCode.length !== 6) {
setLocalError("Please enter a 6-digit code");
return;
}
await onVerify(otpCode);
};
const handleResend = async () => {
setCanResend(false);
setCountdown(30);
setOtp(["", "", "", "", "", ""]);
setLocalError(null);
await onResend();
// Restart countdown
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
setCanResend(true);
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Enter Verification Code</CardTitle>
<CardDescription>
We've sent a 6-digit code to {phoneNumber}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="otp">Verification Code</Label>
<div className="flex gap-2 justify-center">
{otp.map((digit, index) => (
<Input
key={index}
ref={(el) => {
inputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleOtpChange(index, e.target.value)}
onKeyDown={(e) => handleKeyDown(index, e)}
onPaste={handlePaste}
className="w-12 h-12 text-center text-lg font-bold"
disabled={isLoading}
/>
))}
</div>
</div>
{(error || localError) && (
<Alert variant="destructive">
<AlertDescription>{error || localError}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading || otp.join("").length !== 6}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
"Verify Code"
)}
</Button>
<div className="text-center">
<Button
type="button"
variant="link"
onClick={handleResend}
disabled={!canResend || isLoading}
className="text-sm"
>
{canResend ? "Resend code" : `Resend in ${countdown}s`}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
interface PhoneInputProps {
onSubmit: (phoneNumber: string) => Promise<void>;
isLoading?: boolean;
error?: string | null;
}
export function PhoneInput({ onSubmit, isLoading = false, error = null }: PhoneInputProps) {
const [phoneNumber, setPhoneNumber] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
// Basic validation
if (!phoneNumber.trim()) {
setLocalError("Please enter a phone number");
return;
}
// Simple phone number validation (international format)
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
if (!phoneRegex.test(phoneNumber.replace(/\s/g, ""))) {
setLocalError("Please enter a valid phone number");
return;
}
await onSubmit(phoneNumber);
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>Phone Verification</CardTitle>
<CardDescription>
Enter your phone number to receive a verification code via SMS
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
id="phone"
type="tel"
placeholder="+1234567890"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
disabled={isLoading}
className="text-lg"
/>
<p className="text-sm text-muted-foreground">
Include country code (e.g., +1 for US)
</p>
</div>
{(error || localError) && (
<Alert variant="destructive">
<AlertDescription>{error || localError}</AlertDescription>
</Alert>
)}
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending code...
</>
) : (
"Send Verification Code"
)}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,99 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { usePhoneVerificationStatus } from '@/hooks/react-query/phone-verification';
import { Loader2 } from 'lucide-react';
interface PhoneVerificationGuardProps {
children: React.ReactNode;
}
export function PhoneVerificationGuard({ children }: PhoneVerificationGuardProps) {
const router = useRouter();
const { data: status, isLoading, error, refetch } = usePhoneVerificationStatus();
useEffect(() => {
if (status && status.verification_required && !status.is_verified) {
router.push('/auth/phone-verification');
}
}, [status, router]);
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Checking verification status...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4 p-8 text-center">
<h2 className="text-lg font-semibold">Verification Check Failed</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<button
onClick={() => refetch()}
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Retry
</button>
</div>
</div>
);
}
// Only show children if user has verified phone or verification is not required
if (status?.is_verified || !status?.verification_required) {
return <>{children}</>;
}
// Show full-screen lock for users requiring phone verification
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-6 p-8 text-center max-w-md">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-primary"
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>
<div className="space-y-2">
<h1 className="text-2xl font-bold tracking-tight">
Phone Verification Required
</h1>
<p className="text-muted-foreground">
To ensure the security of your account, we need to verify your phone number before you can access the application.
</p>
</div>
<div className="w-full space-y-4">
<button
onClick={() => router.push('/auth/phone-verification')}
className="w-full px-4 py-3 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 font-medium"
>
Verify Phone Number
</button>
<p className="text-xs text-muted-foreground">
This is a one-time verification process for account security.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,166 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { PhoneInput } from "./phone-input";
import { OtpVerification } from "./otp-verification";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
interface PhoneVerificationPageProps {
onSuccess?: () => void;
}
export function PhoneVerificationPage({ onSuccess }: PhoneVerificationPageProps) {
const [step, setStep] = useState<"phone" | "otp">("phone");
const [phoneNumber, setPhoneNumber] = useState("");
const [factorId, setFactorId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const router = useRouter();
const handlePhoneSubmit = async (phone: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/phone/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ phone_number: phone }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to send verification code");
}
setPhoneNumber(phone);
setFactorId(data.factor_id);
setStep("otp");
setSuccess("Verification code sent to your phone");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send verification code");
} finally {
setIsLoading(false);
}
};
const handleOtpVerify = async (otp: string) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/phone/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
phone_number: phoneNumber,
otp_code: otp,
factor_id: factorId
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Invalid verification code");
}
setSuccess("Phone number verified successfully!");
// Redirect to dashboard after 1 second
setTimeout(() => {
if (onSuccess) {
onSuccess();
} else {
router.push("/dashboard");
}
}, 1000);
} catch (err) {
setError(err instanceof Error ? err.message : "Invalid verification code");
} finally {
setIsLoading(false);
}
};
const handleResendCode = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/phone/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ phone_number: phoneNumber }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Failed to resend verification code");
}
setSuccess("New verification code sent");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resend verification code");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Phone Verification
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
{step === "phone"
? "Enter your phone number to receive a verification code"
: "Enter the 6-digit code sent to your phone"
}
</p>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert>
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
{step === "phone" ? (
<PhoneInput
onSubmit={handlePhoneSubmit}
isLoading={isLoading}
error={error}
/>
) : (
<OtpVerification
phoneNumber={phoneNumber}
onVerify={handleOtpVerify}
onResend={handleResendCode}
isLoading={isLoading}
error={error}
/>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { phoneVerificationService } from '@/lib/api/phone-verification';
interface PhoneSubmitData {
phone_number: string;
country_code: string;
}
interface PhoneVerifyData {
phone_number: string;
otp_code: string;
}
interface PhoneStatusResponse {
is_verified: boolean;
phone_number?: string;
verification_required: boolean;
}
// React Query hooks
export const usePhoneVerificationStatus = () => {
return useQuery({
queryKey: ['phone-verification-status'],
queryFn: phoneVerificationService.getStatus,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 2,
});
};
export const useSubmitPhone = () => {
return useMutation({
mutationFn: phoneVerificationService.submitPhoneNumber,
});
};
export const useVerifyOtp = () => {
return useMutation({
mutationFn: phoneVerificationService.verifyOTP,
});
};
export const useResendOtp = () => {
return useMutation({
mutationFn: phoneVerificationService.resendOTP,
});
};
export const useRemovePhoneVerification = () => {
return useMutation({
mutationFn: phoneVerificationService.removePhoneVerification,
});
};

View File

@ -0,0 +1,71 @@
import { backendApi } from '@/lib/api-client';
export interface PhoneVerificationStatus {
is_verified: boolean;
phone_number?: string;
verification_required: boolean;
}
export interface PhoneVerificationSubmit {
phone_number: string;
country_code: string;
}
export interface PhoneVerificationVerify {
phone_number: string;
otp_code: string;
}
export interface PhoneVerificationResponse {
success: boolean;
message?: string;
requires_otp?: boolean;
}
export interface OTPVerificationResponse {
success: boolean;
message?: string;
is_verified?: boolean;
}
export const phoneVerificationService = {
/**
* Get phone verification status for the current user
*/
async getStatus(): Promise<PhoneVerificationStatus> {
const response = await backendApi.get('/mfa/phone/status');
return response.data;
},
/**
* Submit phone number for verification
*/
async submitPhoneNumber(data: PhoneVerificationSubmit): Promise<PhoneVerificationResponse> {
const response = await backendApi.post('/mfa/phone/submit', data);
return response.data;
},
/**
* Verify OTP code for phone verification
*/
async verifyOTP(data: PhoneVerificationVerify): Promise<OTPVerificationResponse> {
const response = await backendApi.post('/mfa/phone/verify', data);
return response.data;
},
/**
* Resend OTP code to phone number
*/
async resendOTP(phoneNumber: string): Promise<PhoneVerificationResponse> {
const response = await backendApi.post('/mfa/phone/resend', { phone_number: phoneNumber });
return response.data;
},
/**
* Remove phone verification from account
*/
async removePhoneVerification(): Promise<PhoneVerificationResponse> {
const response = await backendApi.delete('/mfa/phone/remove');
return response.data;
}
};