mirror of https://github.com/kortix-ai/suna.git
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:
parent
91b9cff776
commit
a332cf9a31
|
@ -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")
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
# Auth Module
|
|
@ -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)}")
|
|
@ -0,0 +1,5 @@
|
|||
import { PhoneVerificationPage } from "@/components/auth/phone-verification/phone-verification-page";
|
||||
|
||||
export default function PhoneVerificationRoute() {
|
||||
return <PhoneVerificationPage />;
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
};
|
Loading…
Reference in New Issue