mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' of github.com:escapade-mckv/suna into extend-agent-builder
This commit is contained in:
commit
40a145552f
|
@ -15,7 +15,7 @@ COPY . .
|
|||
|
||||
# Calculate optimal worker count based on 16 vCPUs
|
||||
# Using (2*CPU)+1 formula for CPU-bound applications
|
||||
ENV WORKERS=33
|
||||
ENV WORKERS=7
|
||||
ENV THREADS=2
|
||||
ENV WORKER_CONNECTIONS=2000
|
||||
|
||||
|
|
|
@ -146,6 +146,9 @@ class ResponseProcessor:
|
|||
prompt_messages: List[Dict[str, Any]],
|
||||
llm_model: str,
|
||||
config: ProcessorConfig = ProcessorConfig(),
|
||||
can_auto_continue: bool = False,
|
||||
auto_continue_count: int = 0,
|
||||
continuous_state: Optional[Dict[str, Any]] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""Process a streaming LLM response, handling tool calls and execution.
|
||||
|
||||
|
@ -155,19 +158,25 @@ class ResponseProcessor:
|
|||
prompt_messages: List of messages sent to the LLM (the prompt)
|
||||
llm_model: The name of the LLM model used
|
||||
config: Configuration for parsing and execution
|
||||
can_auto_continue: Whether auto-continue is enabled
|
||||
auto_continue_count: Number of auto-continue cycles
|
||||
continuous_state: Previous state of the conversation
|
||||
|
||||
Yields:
|
||||
Complete message objects matching the DB schema, except for content chunks.
|
||||
"""
|
||||
accumulated_content = ""
|
||||
# Initialize from continuous state if provided (for auto-continue)
|
||||
continuous_state = continuous_state or {}
|
||||
accumulated_content = continuous_state.get('accumulated_content', "")
|
||||
tool_calls_buffer = {}
|
||||
current_xml_content = ""
|
||||
current_xml_content = accumulated_content # equal to accumulated_content if auto-continuing, else blank
|
||||
xml_chunks_buffer = []
|
||||
pending_tool_executions = []
|
||||
yielded_tool_indices = set() # Stores indices of tools whose *status* has been yielded
|
||||
tool_index = 0
|
||||
xml_tool_call_count = 0
|
||||
finish_reason = None
|
||||
should_auto_continue = False
|
||||
last_assistant_message_object = None # Store the final saved assistant message object
|
||||
tool_result_message_objects = {} # tool_index -> full saved message object
|
||||
has_printed_thinking_prefix = False # Flag for printing thinking prefix only once
|
||||
|
@ -191,10 +200,13 @@ class ResponseProcessor:
|
|||
logger.info(f"Streaming Config: XML={config.xml_tool_calling}, Native={config.native_tool_calling}, "
|
||||
f"Execute on stream={config.execute_on_stream}, Strategy={config.tool_execution_strategy}")
|
||||
|
||||
thread_run_id = str(uuid.uuid4())
|
||||
# Reuse thread_run_id for auto-continue or create new one
|
||||
thread_run_id = continuous_state.get('thread_run_id') or str(uuid.uuid4())
|
||||
continuous_state['thread_run_id'] = thread_run_id
|
||||
|
||||
try:
|
||||
# --- Save and Yield Start Events ---
|
||||
# --- Save and Yield Start Events (only if not auto-continuing) ---
|
||||
if auto_continue_count == 0:
|
||||
start_content = {"status_type": "thread_run_start", "thread_run_id": thread_run_id}
|
||||
start_msg_obj = await self.add_message(
|
||||
thread_id=thread_id, type="status", content=start_content,
|
||||
|
@ -210,7 +222,7 @@ class ResponseProcessor:
|
|||
if assist_start_msg_obj: yield format_for_yield(assist_start_msg_obj)
|
||||
# --- End Start Events ---
|
||||
|
||||
__sequence = 0
|
||||
__sequence = continuous_state.get('sequence', 0) # get the sequence from the previous auto-continue cycle
|
||||
|
||||
async for chunk in llm_response:
|
||||
# Extract streaming metadata from chunks
|
||||
|
@ -492,8 +504,12 @@ class ResponseProcessor:
|
|||
logger.info(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls")
|
||||
self.trace.event(name="stream_finished_with_reason_xml_tool_limit_reached_after_xml_tool_calls", level="DEFAULT", status_message=(f"Stream finished with reason: xml_tool_limit_reached after {xml_tool_call_count} XML tool calls"))
|
||||
|
||||
# Calculate if auto-continue is needed if the finish reason is length
|
||||
should_auto_continue = (can_auto_continue and finish_reason == 'length')
|
||||
|
||||
# --- SAVE and YIELD Final Assistant Message ---
|
||||
if accumulated_content:
|
||||
# Only save assistant message if NOT auto-continuing due to length to avoid duplicate messages
|
||||
if accumulated_content and not should_auto_continue:
|
||||
# ... (Truncate accumulated_content logic) ...
|
||||
if config.max_xml_tool_calls > 0 and xml_tool_call_count >= config.max_xml_tool_calls and xml_chunks_buffer:
|
||||
last_xml_chunk = xml_chunks_buffer[-1]
|
||||
|
@ -746,6 +762,8 @@ class ResponseProcessor:
|
|||
return
|
||||
|
||||
# --- Save and Yield assistant_response_end ---
|
||||
# Only save assistant_response_end if not auto-continuing (response is actually complete)
|
||||
if not should_auto_continue:
|
||||
if last_assistant_message_object: # Only save if assistant message was saved
|
||||
try:
|
||||
# Calculate response time if we have timing data
|
||||
|
@ -815,7 +833,14 @@ class ResponseProcessor:
|
|||
raise # Use bare 'raise' to preserve the original exception with its traceback
|
||||
|
||||
finally:
|
||||
# Save and Yield the final thread_run_end status
|
||||
# Update continuous state for potential auto-continue
|
||||
if should_auto_continue:
|
||||
continuous_state['accumulated_content'] = accumulated_content
|
||||
continuous_state['sequence'] = __sequence
|
||||
|
||||
logger.info(f"Updated continuous state for auto-continue with {len(accumulated_content)} chars")
|
||||
else:
|
||||
# Save and Yield the final thread_run_end status (only if not auto-continuing and finish_reason is not 'length')
|
||||
try:
|
||||
end_content = {"status_type": "thread_run_end"}
|
||||
end_msg_obj = await self.add_message(
|
||||
|
|
|
@ -298,6 +298,12 @@ Here are the XML tools available with examples:
|
|||
auto_continue = True
|
||||
auto_continue_count = 0
|
||||
|
||||
# Shared state for continuous streaming across auto-continues
|
||||
continuous_state = {
|
||||
'accumulated_content': '',
|
||||
'thread_run_id': None
|
||||
}
|
||||
|
||||
# Define inner function to handle a single run
|
||||
async def _run_once(temp_msg=None):
|
||||
try:
|
||||
|
@ -342,6 +348,18 @@ Here are the XML tools available with examples:
|
|||
prepared_messages.append(temp_msg)
|
||||
logger.debug("Added temporary message to the end of prepared messages")
|
||||
|
||||
# Add partial assistant content for auto-continue context (without saving to DB)
|
||||
if auto_continue_count > 0 and continuous_state.get('accumulated_content'):
|
||||
partial_content = continuous_state.get('accumulated_content', '')
|
||||
|
||||
# Create temporary assistant message with just the text content
|
||||
temporary_assistant_message = {
|
||||
"role": "assistant",
|
||||
"content": partial_content
|
||||
}
|
||||
prepared_messages.append(temporary_assistant_message)
|
||||
logger.info(f"Added temporary assistant message with {len(partial_content)} chars for auto-continue context")
|
||||
|
||||
# 4. Prepare tools for LLM call
|
||||
openapi_tool_schemas = None
|
||||
if config.native_tool_calling:
|
||||
|
@ -395,6 +413,9 @@ Here are the XML tools available with examples:
|
|||
config=config,
|
||||
prompt_messages=prepared_messages,
|
||||
llm_model=llm_model,
|
||||
can_auto_continue=(native_max_auto_continues > 0),
|
||||
auto_continue_count=auto_continue_count,
|
||||
continuous_state=continuous_state
|
||||
)
|
||||
else:
|
||||
# Fallback to non-streaming if response is not iterable
|
||||
|
@ -467,6 +488,14 @@ Here are the XML tools available with examples:
|
|||
auto_continue = False
|
||||
# Still yield the chunk to inform the client
|
||||
|
||||
elif chunk.get('type') == 'status':
|
||||
# if the finish reason is length, auto-continue
|
||||
content = json.loads(chunk.get('content'))
|
||||
if content.get('finish_reason') == 'length':
|
||||
logger.info(f"Detected finish_reason='length', auto-continuing ({auto_continue_count + 1}/{native_max_auto_continues})")
|
||||
auto_continue = True
|
||||
auto_continue_count += 1
|
||||
continue
|
||||
# Otherwise just yield the chunk normally
|
||||
yield chunk
|
||||
else:
|
||||
|
@ -480,7 +509,9 @@ Here are the XML tools available with examples:
|
|||
if ("AnthropicException - Overloaded" in str(e)):
|
||||
logger.error(f"AnthropicException - Overloaded detected - Falling back to OpenRouter: {str(e)}", exc_info=True)
|
||||
nonlocal llm_model
|
||||
llm_model = f"openrouter/{llm_model}"
|
||||
# Remove "-20250514" from the model name if present
|
||||
model_name_cleaned = llm_model.replace("-20250514", "")
|
||||
llm_model = f"openrouter/{model_name_cleaned}"
|
||||
auto_continue = True
|
||||
continue # Continue the loop
|
||||
else:
|
||||
|
|
|
@ -187,8 +187,10 @@ 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)
|
||||
# MFA functionality moved to frontend
|
||||
|
||||
from local_env_manager import api as local_env_manager_api
|
||||
api_router.include_router(local_env_manager_api.router)
|
||||
|
||||
@api_router.get("/health")
|
||||
async def health_check():
|
||||
|
|
|
@ -1,591 +0,0 @@
|
|||
"""
|
||||
Auth MFA endpoints for Supabase Phone-based Multi-Factor Authentication (MFA).
|
||||
|
||||
Currently, only SMS is supported as a second factor. Users can enroll their phone number for SMS-based 2FA.
|
||||
No recovery codes are supported, but users can update their phone number for backup.
|
||||
|
||||
This API provides endpoints to:
|
||||
- Enroll a phone number for SMS 2FA
|
||||
- Create a challenge for a phone factor (sends SMS)
|
||||
- Verify a challenge with SMS code
|
||||
- Create and verify a challenge in a single step
|
||||
- List enrolled factors
|
||||
- Unenroll a factor
|
||||
- Get Authenticator Assurance Level (AAL)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
import jwt
|
||||
from datetime import datetime, timezone
|
||||
from supabase import create_client, Client
|
||||
from utils.auth_utils import get_current_user_id_from_jwt
|
||||
from utils.config import config
|
||||
from utils.logger import logger, structlog
|
||||
|
||||
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
|
||||
|
||||
# Cutoff date for new user phone verification requirement
|
||||
# Users created after this date will be required to have phone verification
|
||||
# Users created before this date are grandfathered in and not required to verify
|
||||
PHONE_VERIFICATION_CUTOFF_DATE = datetime(2025, 7, 21, 0, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def is_phone_verification_mandatory() -> bool:
|
||||
"""Check if phone verification is mandatory based on environment variable."""
|
||||
env_val = os.getenv("PHONE_NUMBER_MANDATORY")
|
||||
if env_val is None:
|
||||
return False
|
||||
return env_val.lower() in ('true', 't', 'yes', 'y', '1')
|
||||
|
||||
|
||||
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]
|
||||
|
||||
# Extract the refresh token from the custom header
|
||||
refresh_token = request.headers.get("X-Refresh-Token")
|
||||
|
||||
# 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 both access and refresh tokens
|
||||
client.auth.set_session(token, refresh_token)
|
||||
return client
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=401, detail=f"Authentication failed: {str(e)}")
|
||||
|
||||
|
||||
# Request/Response Models
|
||||
class EnrollFactorRequest(BaseModel):
|
||||
friendly_name: str = Field(..., description="User-friendly name for the factor")
|
||||
phone_number: str = Field(
|
||||
..., description="Phone number in E.164 format (e.g., +1234567890)"
|
||||
)
|
||||
|
||||
|
||||
class EnrollFactorResponse(BaseModel):
|
||||
id: str
|
||||
friendly_name: str
|
||||
phone_number: str
|
||||
# Note: Supabase response may not include status, created_at, updated_at
|
||||
qr_code: Optional[str] = None
|
||||
secret: Optional[str] = None
|
||||
|
||||
|
||||
class ChallengeRequest(BaseModel):
|
||||
factor_id: str = Field(..., description="ID of the factor to challenge")
|
||||
|
||||
|
||||
class ChallengeResponse(BaseModel):
|
||||
id: str
|
||||
expires_at: Optional[str] = None
|
||||
# Note: Supabase response may not include factor_type, created_at
|
||||
|
||||
|
||||
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="SMS code received on phone")
|
||||
|
||||
|
||||
class ChallengeAndVerifyRequest(BaseModel):
|
||||
factor_id: str = Field(..., description="ID of the factor to challenge and verify")
|
||||
code: str = Field(..., description="SMS code received on phone")
|
||||
|
||||
|
||||
class FactorInfo(BaseModel):
|
||||
id: str
|
||||
friendly_name: Optional[str] = None
|
||||
factor_type: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
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: Optional[str] = None
|
||||
next_level: Optional[str] = None
|
||||
current_authentication_methods: Optional[List[str]] = None
|
||||
# Add action guidance based on AAL status
|
||||
action_required: Optional[str] = None
|
||||
message: Optional[str] = None
|
||||
# Phone verification requirement fields
|
||||
phone_verification_required: Optional[bool] = None
|
||||
user_created_at: Optional[str] = None
|
||||
cutoff_date: Optional[str] = None
|
||||
# Computed verification status fields
|
||||
verification_required: Optional[bool] = None
|
||||
is_verified: Optional[bool] = None
|
||||
factors: Optional[List[dict]] = None
|
||||
|
||||
|
||||
@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 phone number for SMS-based 2FA.
|
||||
|
||||
Currently only supports 'phone' factor type.
|
||||
Phone number must be in E.164 format (e.g., +1234567890).
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_enroll",
|
||||
phone_number=request_data.phone_number,
|
||||
friendly_name=request_data.friendly_name
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.auth.mfa.enroll(
|
||||
{
|
||||
"factor_type": "phone",
|
||||
"friendly_name": request_data.friendly_name,
|
||||
"phone": request_data.phone_number,
|
||||
}
|
||||
)
|
||||
|
||||
# Build response with defensive field access
|
||||
enroll_response = EnrollFactorResponse(
|
||||
id=response.id,
|
||||
friendly_name=request_data.friendly_name, # Use request data as fallback
|
||||
phone_number=request_data.phone_number,
|
||||
)
|
||||
|
||||
# Add optional fields if they exist
|
||||
if hasattr(response, "qr_code"):
|
||||
enroll_response.qr_code = response.qr_code
|
||||
if hasattr(response, "secret"):
|
||||
enroll_response.secret = response.secret
|
||||
|
||||
return enroll_response
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to enroll phone 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 phone factor.
|
||||
|
||||
This will send an SMS code to the registered phone number.
|
||||
The challenge must be verified within the time limit.
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_challenge",
|
||||
factor_id=request_data.factor_id
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.auth.mfa.challenge(
|
||||
{
|
||||
"factor_id": request_data.factor_id,
|
||||
}
|
||||
)
|
||||
|
||||
# Build response with defensive field access
|
||||
challenge_response = ChallengeResponse(id=response.id)
|
||||
|
||||
# Add optional fields if they exist
|
||||
if hasattr(response, "expires_at"):
|
||||
challenge_response.expires_at = response.expires_at
|
||||
|
||||
return challenge_response
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to create SMS 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 an SMS code.
|
||||
|
||||
The challenge must be active and the SMS code must be valid.
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_verify",
|
||||
factor_id=request_data.factor_id,
|
||||
challenge_id=request_data.challenge_id
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"🔵 Starting MFA verification for user {user_id}: "
|
||||
f"factor_id={request_data.factor_id}, "
|
||||
f"challenge_id={request_data.challenge_id}")
|
||||
|
||||
# Check AAL BEFORE verification
|
||||
try:
|
||||
aal_before = client.auth.mfa.get_authenticator_assurance_level()
|
||||
logger.info(f"📊 AAL BEFORE verification: "
|
||||
f"current={aal_before.current_level}, "
|
||||
f"next={aal_before.next_level}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get AAL before verification: {e}")
|
||||
|
||||
# Verify the challenge
|
||||
response = client.auth.mfa.verify(
|
||||
{
|
||||
"factor_id": request_data.factor_id,
|
||||
"challenge_id": request_data.challenge_id,
|
||||
"code": request_data.code,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"✅ MFA verification successful for user {user_id}")
|
||||
logger.info(f"Verification response type: {type(response)}")
|
||||
logger.info(f"Verification response attributes: {dir(response)}")
|
||||
|
||||
# Check if response has session info
|
||||
if hasattr(response, 'session') and response.session:
|
||||
logger.info(f"New session info: access_token present: {bool(getattr(response.session, 'access_token', None))}")
|
||||
logger.info(f"New session user: {getattr(response.session, 'user', None)}")
|
||||
|
||||
# Check AAL AFTER verification
|
||||
try:
|
||||
aal_after = client.auth.mfa.get_authenticator_assurance_level()
|
||||
logger.info(f"📊 AAL AFTER verification: "
|
||||
f"current={aal_after.current_level}, "
|
||||
f"next={aal_after.next_level}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get AAL after verification: {e}")
|
||||
|
||||
# Check factor status AFTER verification
|
||||
try:
|
||||
user_response = client.auth.get_user()
|
||||
if user_response.user and hasattr(user_response.user, "factors"):
|
||||
for factor in user_response.user.factors:
|
||||
if factor.id == request_data.factor_id:
|
||||
logger.info(f"Factor {request_data.factor_id} status after verification: {getattr(factor, 'status', 'unknown')}")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check factor status after verification: {e}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "SMS code verified successfully",
|
||||
"session": response,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"❌ MFA verification failed for user {user_id}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to verify SMS code: {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 will send an SMS code and verify it immediately when provided.
|
||||
This is a convenience method that combines challenge creation and verification.
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_challenge_and_verify",
|
||||
factor_id=request_data.factor_id
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.auth.mfa.challenge_and_verify(
|
||||
{"factor_id": request_data.factor_id, "code": request_data.code}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "SMS challenge created and verified successfully",
|
||||
"session": response,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to challenge and verify SMS: {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.
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_list_factors"
|
||||
)
|
||||
|
||||
try:
|
||||
# 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 with defensive access
|
||||
factors = []
|
||||
if hasattr(user_response.user, "factors") and user_response.user.factors:
|
||||
for factor in user_response.user.factors:
|
||||
# Convert datetime objects to strings for Pydantic validation
|
||||
created_at = getattr(factor, "created_at", None)
|
||||
if created_at and hasattr(created_at, "isoformat"):
|
||||
created_at = created_at.isoformat()
|
||||
|
||||
updated_at = getattr(factor, "updated_at", None)
|
||||
if updated_at and hasattr(updated_at, "isoformat"):
|
||||
updated_at = updated_at.isoformat()
|
||||
|
||||
factor_info = FactorInfo(
|
||||
id=factor.id if hasattr(factor, "id") else str(factor),
|
||||
friendly_name=getattr(factor, "friendly_name", None),
|
||||
factor_type=getattr(factor, "factor_type", None),
|
||||
status=getattr(factor, "status", None),
|
||||
phone=getattr(factor, "phone", None),
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
factors.append(factor_info)
|
||||
|
||||
return ListFactorsResponse(factors=factors)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to list factors: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/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 phone factor for the authenticated user.
|
||||
|
||||
This will remove the phone number and invalidate any active sessions if the factor was verified.
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_unenroll",
|
||||
factor_id=request_data.factor_id
|
||||
)
|
||||
|
||||
try:
|
||||
response = client.auth.mfa.unenroll({"factor_id": request_data.factor_id})
|
||||
|
||||
return {"success": True, "message": "Phone factor unenrolled successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Failed to unenroll phone 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.
|
||||
|
||||
This endpoint combines AAL status with phone verification requirements:
|
||||
- aal1 -> aal1: User does not have MFA enrolled
|
||||
- aal1 -> aal2: User has MFA enrolled but not verified (requires verification)
|
||||
- aal2 -> aal2: User has verified their MFA factor
|
||||
- aal2 -> aal1: User has disabled MFA (stale JWT, requires reauthentication)
|
||||
|
||||
Also includes phone verification requirement based on account creation date.
|
||||
"""
|
||||
structlog.contextvars.bind_contextvars(
|
||||
user_id=user_id,
|
||||
action="mfa_get_aal"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the current AAL from Supabase
|
||||
response = client.auth.mfa.get_authenticator_assurance_level()
|
||||
|
||||
# Extract AAL levels from response first
|
||||
current = response.current_level
|
||||
next_level = response.next_level
|
||||
|
||||
# Get user creation date and factors for phone verification requirement
|
||||
user_response = client.auth.get_user()
|
||||
if not user_response.user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
|
||||
user_created_at = None
|
||||
if hasattr(user_response.user, 'created_at') and user_response.user.created_at:
|
||||
try:
|
||||
# Handle different possible formats for created_at
|
||||
created_at_value = user_response.user.created_at
|
||||
if isinstance(created_at_value, str):
|
||||
# Parse ISO format string
|
||||
user_created_at = datetime.fromisoformat(created_at_value.replace('Z', '+00:00'))
|
||||
elif hasattr(created_at_value, 'isoformat'):
|
||||
# Already a datetime object
|
||||
user_created_at = created_at_value
|
||||
if user_created_at.tzinfo is None:
|
||||
user_created_at = user_created_at.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
logger.warning(f"Unexpected created_at type: {type(created_at_value)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse user created_at: {e}")
|
||||
# Fall back to treating as new user for safety
|
||||
user_created_at = datetime.now(timezone.utc)
|
||||
|
||||
# Determine if this is a new user who needs phone verification
|
||||
is_new_user = (
|
||||
user_created_at is not None and
|
||||
user_created_at >= PHONE_VERIFICATION_CUTOFF_DATE
|
||||
)
|
||||
|
||||
# Get factors and compute phone verification status
|
||||
factors = []
|
||||
phone_factors = []
|
||||
has_verified_phone = False
|
||||
|
||||
if hasattr(user_response.user, "factors") and user_response.user.factors:
|
||||
for factor in user_response.user.factors:
|
||||
# Convert datetime objects to strings for JSON serialization
|
||||
created_at = getattr(factor, "created_at", None)
|
||||
if created_at and hasattr(created_at, "isoformat"):
|
||||
created_at = created_at.isoformat()
|
||||
|
||||
updated_at = getattr(factor, "updated_at", None)
|
||||
if updated_at and hasattr(updated_at, "isoformat"):
|
||||
updated_at = updated_at.isoformat()
|
||||
|
||||
factor_dict = {
|
||||
"id": factor.id if hasattr(factor, "id") else str(factor),
|
||||
"friendly_name": getattr(factor, "friendly_name", None),
|
||||
"factor_type": getattr(factor, "factor_type", None),
|
||||
"status": getattr(factor, "status", None),
|
||||
"phone": getattr(factor, "phone", None),
|
||||
"created_at": created_at,
|
||||
"updated_at": updated_at,
|
||||
}
|
||||
factors.append(factor_dict)
|
||||
|
||||
# Track phone factors
|
||||
if factor_dict.get("factor_type") == "phone":
|
||||
phone_factors.append(factor_dict)
|
||||
if factor_dict.get("status") == "verified":
|
||||
has_verified_phone = True
|
||||
|
||||
# Determine action required based on AAL combination
|
||||
action_required = None
|
||||
message = None
|
||||
|
||||
if current == "aal1" and next_level == "aal1":
|
||||
# User does not have MFA enrolled
|
||||
action_required = "none"
|
||||
message = "MFA is not enrolled for this account"
|
||||
elif current == "aal1" and next_level == "aal2":
|
||||
# User has MFA enrolled but needs to verify it
|
||||
action_required = "verify_mfa"
|
||||
message = "MFA verification required to access full features"
|
||||
elif current == "aal2" and next_level == "aal2":
|
||||
# User has verified their MFA factor
|
||||
action_required = "none"
|
||||
message = "MFA is verified and active"
|
||||
elif current == "aal2" and next_level == "aal1":
|
||||
# User has disabled MFA or has stale JWT
|
||||
action_required = "reauthenticate"
|
||||
message = "Session needs refresh due to MFA changes"
|
||||
else:
|
||||
# Unknown combination
|
||||
action_required = "unknown"
|
||||
message = f"Unknown AAL combination: {current} -> {next_level}"
|
||||
|
||||
# Determine verification_required based on AAL status AND grandfathering logic
|
||||
verification_required = False
|
||||
if is_new_user:
|
||||
# New users (created after cutoff date) must have phone verification
|
||||
if current == 'aal1' and next_level == 'aal1':
|
||||
# No MFA enrolled - new users must enroll
|
||||
verification_required = True
|
||||
elif action_required == 'verify_mfa':
|
||||
# MFA enrolled but needs verification
|
||||
verification_required = True
|
||||
else:
|
||||
# Existing users (grandfathered) - only require verification if AAL demands it
|
||||
verification_required = action_required == 'verify_mfa'
|
||||
|
||||
phone_verification_required = False and is_new_user and is_phone_verification_mandatory()
|
||||
verification_required = False and is_new_user and verification_required and is_phone_verification_mandatory()
|
||||
|
||||
logger.info(f"AAL check for user {user_id}: "
|
||||
f"current_level={current}, "
|
||||
f"next_level={next_level}, "
|
||||
f"action_required={action_required}, "
|
||||
f"phone_verification_required={phone_verification_required}, "
|
||||
f"verification_required={verification_required}, "
|
||||
f"is_verified={has_verified_phone}")
|
||||
|
||||
return AALResponse(
|
||||
current_level=current,
|
||||
next_level=next_level,
|
||||
current_authentication_methods=[x.method for x in response.current_authentication_methods],
|
||||
action_required=action_required,
|
||||
message=message,
|
||||
phone_verification_required=phone_verification_required,
|
||||
user_created_at=user_created_at.isoformat() if user_created_at else None,
|
||||
cutoff_date=PHONE_VERIFICATION_CUTOFF_DATE.isoformat(),
|
||||
verification_required=verification_required,
|
||||
is_verified=has_verified_phone,
|
||||
factors=factors,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Failed to get AAL: {str(e)}")
|
|
@ -0,0 +1,44 @@
|
|||
from fastapi import APIRouter
|
||||
from utils.config import config, EnvMode
|
||||
from fastapi import HTTPException
|
||||
from typing import Dict
|
||||
from dotenv import load_dotenv, set_key, find_dotenv, dotenv_values
|
||||
from utils.logger import logger
|
||||
|
||||
router = APIRouter(tags=["local-env-manager"])
|
||||
|
||||
@router.get("/env-vars")
|
||||
def get_env_vars() -> Dict[str, str]:
|
||||
if config.ENV_MODE != EnvMode.LOCAL:
|
||||
raise HTTPException(status_code=403, detail="Env vars management only available in local mode")
|
||||
|
||||
try:
|
||||
env_path = find_dotenv()
|
||||
if not env_path:
|
||||
logger.error("Could not find .env file")
|
||||
return {}
|
||||
|
||||
return dotenv_values(env_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get env vars: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get env variables: {e}")
|
||||
|
||||
@router.post("/env-vars")
|
||||
def save_env_vars(request: Dict[str, str]) -> Dict[str, str]:
|
||||
if config.ENV_MODE != EnvMode.LOCAL:
|
||||
raise HTTPException(status_code=403, detail="Env vars management only available in local mode")
|
||||
|
||||
try:
|
||||
env_path = find_dotenv()
|
||||
if not env_path:
|
||||
raise HTTPException(status_code=500, detail="Could not find .env file")
|
||||
|
||||
for key, value in request.items():
|
||||
set_key(env_path, key, value)
|
||||
|
||||
load_dotenv(override=True)
|
||||
logger.info(f"Env variables saved successfully: {request}")
|
||||
return {"message": "Env variables saved successfully"}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save env variables: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save env variables: {e}")
|
|
@ -497,6 +497,7 @@ async def check_billing_status(client, user_id: str) -> Tuple[bool, str, Optiona
|
|||
# Calculate current month's usage
|
||||
current_usage = await calculate_monthly_usage(client, user_id)
|
||||
|
||||
# TODO: also do user's AAL check
|
||||
# Check if within limits
|
||||
if current_usage >= tier_info['cost']:
|
||||
return False, f"Monthly limit of {tier_info['cost']} dollars reached. Please upgrade your plan or wait until next month.", subscription
|
||||
|
|
|
@ -37,7 +37,7 @@ services:
|
|||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./backend/.env:/app/.env:ro
|
||||
- ./backend/.env:/app/.env
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { isLocalMode } from "@/lib/config";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Shield } from "lucide-react";
|
||||
import { LocalEnvManager } from "@/components/env-manager/local-env-manager";
|
||||
|
||||
export default function LocalEnvManagerPage() {
|
||||
|
||||
return <LocalEnvManager />
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
import { Separator } from '@/components/ui/separator';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
|
||||
export default function PersonalAccountSettingsPage({
|
||||
children,
|
||||
|
@ -15,6 +16,7 @@ export default function PersonalAccountSettingsPage({
|
|||
// { name: "Teams", href: "/settings/teams" },
|
||||
{ name: 'Billing', href: '/settings/billing' },
|
||||
{ name: 'Usage Logs', href: '/settings/usage-logs' },
|
||||
...(isLocalMode() ? [{ name: 'Local .Env Manager', href: '/settings/env-manager' }] : []),
|
||||
];
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -42,11 +42,25 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
const { data: authListener } = supabase.auth.onAuthStateChange(
|
||||
async (event, newSession) => {
|
||||
console.log('🔵 Auth state change:', { event, session: !!newSession, user: !!newSession?.user });
|
||||
|
||||
setSession(newSession);
|
||||
|
||||
// Only update user state on actual auth events, not token refresh
|
||||
if (event === 'SIGNED_IN' || event === 'SIGNED_OUT') {
|
||||
setUser(newSession?.user ?? null);
|
||||
}
|
||||
// For TOKEN_REFRESHED events, keep the existing user state
|
||||
|
||||
if (isLoading) setIsLoading(false);
|
||||
|
||||
if (event === 'SIGNED_IN' && newSession?.user) {
|
||||
await checkAndInstallSunaAgent(newSession.user.id, newSession.user.created_at);
|
||||
} else if (event === 'MFA_CHALLENGE_VERIFIED') {
|
||||
console.log('✅ MFA challenge verified, session updated');
|
||||
// Session is automatically updated by Supabase, just log for debugging
|
||||
} else if (event === 'TOKEN_REFRESHED') {
|
||||
console.log('🔄 Token refreshed, session updated');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
@ -1,197 +1,48 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Script from 'next/script';
|
||||
import { useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuthMethodTracking } from '@/lib/stores/auth-tracking';
|
||||
import { toast } from 'sonner';
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize: (config: any) => void;
|
||||
renderButton: (element: HTMLElement, config: any) => void;
|
||||
prompt: (notification?: (notification: any) => void) => void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GoogleSignInProps {
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export default function GoogleSignIn({ returnUrl }: GoogleSignInProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false);
|
||||
const { wasLastMethod, markAsUsed } = useAuthMethodTracking('google');
|
||||
const supabase = createClient();
|
||||
|
||||
const handleGoogleResponse = async (response: any) => {
|
||||
const handleGoogleSignIn = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
markAsUsed();
|
||||
console.log('returnUrl', returnUrl);
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithIdToken({
|
||||
provider: 'google',
|
||||
token: response.credential,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const redirectTo = `${window.location.origin}/auth/callback${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''}`;
|
||||
console.log('OAuth redirect URI:', redirectTo);
|
||||
|
||||
const { error: oauthError } = await supabase.auth.signInWithOAuth({
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo,
|
||||
redirectTo: `${window.location.origin}/auth/callback${
|
||||
returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''
|
||||
}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (oauthError) {
|
||||
throw oauthError;
|
||||
}
|
||||
} else {
|
||||
window.location.href = returnUrl || '/dashboard';
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Google sign-in error:', error);
|
||||
|
||||
if (error.message?.includes('redirect_uri_mismatch')) {
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
toast.error(
|
||||
`Google OAuth configuration error. Add this exact URL to your Google Cloud Console: ${redirectUri}`,
|
||||
{ duration: 10000 }
|
||||
);
|
||||
} else {
|
||||
toast.error(error.message || 'Failed to sign in with Google');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const initializeGoogleSignIn = () => {
|
||||
if (!window.google || !process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) return;
|
||||
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
callback: handleGoogleResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: false,
|
||||
});
|
||||
|
||||
setIsGoogleLoaded(true);
|
||||
};
|
||||
|
||||
if (window.google) {
|
||||
initializeGoogleSignIn();
|
||||
}
|
||||
}, [returnUrl, markAsUsed, supabase]);
|
||||
|
||||
const handleScriptLoad = () => {
|
||||
if (window.google && process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
callback: handleGoogleResponse,
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: false,
|
||||
});
|
||||
|
||||
setIsGoogleLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = () => {
|
||||
if (!window.google || !isGoogleLoaded) {
|
||||
toast.error('Google Sign-In is still loading. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.google.accounts.id.prompt((notification: any) => {
|
||||
if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
|
||||
console.log('One Tap not displayed, using OAuth flow');
|
||||
setIsLoading(true);
|
||||
|
||||
const redirectTo = `${window.location.origin}/auth/callback${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''}`;
|
||||
console.log('OAuth redirect URI:', redirectTo);
|
||||
|
||||
supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
}).then(({ error }) => {
|
||||
if (error) {
|
||||
console.error('OAuth error:', error);
|
||||
|
||||
if (error.message?.includes('redirect_uri_mismatch')) {
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
toast.error(
|
||||
`Google OAuth configuration error. Add this exact URL to your Google Cloud Console: ${redirectUri}`,
|
||||
{ duration: 10000 }
|
||||
);
|
||||
} else {
|
||||
toast.error(error.message || 'Failed to sign in with Google');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error triggering Google sign-in:', error);
|
||||
setIsLoading(true);
|
||||
|
||||
const redirectTo = `${window.location.origin}/auth/callback${returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''}`;
|
||||
supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo,
|
||||
},
|
||||
}).then(({ error }) => {
|
||||
if (error) {
|
||||
console.error('OAuth error:', error);
|
||||
|
||||
if (error.message?.includes('redirect_uri_mismatch')) {
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
toast.error(
|
||||
`Google OAuth configuration error. Add this exact URL to your Google Cloud Console: ${redirectUri}`,
|
||||
{ duration: 10000 }
|
||||
);
|
||||
} else {
|
||||
toast.error(error.message || 'Failed to sign in with Google');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
|
||||
return (
|
||||
<div className="w-full text-center text-sm text-gray-500 py-3">
|
||||
Google Sign-In not configured
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleGoogleSignIn}
|
||||
disabled={isLoading || !isGoogleLoaded}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 flex items-center justify-center text-sm font-medium tracking-wide rounded-full bg-background text-foreground border border-border hover:bg-accent/30 transition-all duration-200 disabled:opacity-60 disabled:cursor-not-allowed font-sans"
|
||||
aria-label={isLoading ? 'Signing in with Google...' : 'Sign in with Google'}
|
||||
type="button"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
@ -203,18 +54,5 @@ export default function GoogleSignIn({ returnUrl }: GoogleSignInProps) {
|
|||
{isLoading ? 'Signing in...' : 'Continue with Google'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{wasLastMethod && (
|
||||
<div className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-background shadow-sm">
|
||||
<div className="w-full h-full bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Script
|
||||
src="https://accounts.google.com/gsi/client"
|
||||
strategy="afterInteractive"
|
||||
onLoad={handleScriptLoad}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -64,8 +64,7 @@ export function BackgroundAALChecker({
|
|||
if (current_level === "aal1" && next_level === "aal1") {
|
||||
// New user has no MFA enrolled - redirect to enrollment
|
||||
console.log('Background: New user without MFA enrolled, redirecting to phone verification');
|
||||
// Temporarily disabled
|
||||
// router.push(redirectTo);
|
||||
router.push(redirectTo);
|
||||
return;
|
||||
}
|
||||
// If new user has MFA enrolled, follow standard AAL flow below
|
||||
|
@ -76,8 +75,7 @@ export function BackgroundAALChecker({
|
|||
case 'verify_mfa':
|
||||
// User has MFA enrolled but needs to verify it
|
||||
console.log('Background: Redirecting to MFA verification');
|
||||
// Temporarily disabled
|
||||
// router.push(redirectTo);
|
||||
router.push(redirectTo);
|
||||
break;
|
||||
|
||||
case 'reauthenticate':
|
||||
|
|
|
@ -0,0 +1,224 @@
|
|||
"use client";
|
||||
|
||||
import { Eye, EyeOff, Plus, Trash } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { isLocalMode } from "@/lib/config";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { backendApi } from "@/lib/api-client";
|
||||
import { toast } from "sonner";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
interface APIKeyForm {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export function LocalEnvManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({});
|
||||
const [newApiKeys, setNewApiKeys] = useState<{key: string, value: string, id: string}[]>([]);
|
||||
|
||||
const {data: apiKeys, isLoading} = useQuery({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: async() => {
|
||||
const response = await backendApi.get('/env-vars');
|
||||
return response.data;
|
||||
},
|
||||
enabled: isLocalMode()
|
||||
});
|
||||
|
||||
const { register, handleSubmit, formState: { errors, isDirty }, reset } = useForm<APIKeyForm>({
|
||||
defaultValues: apiKeys || {}
|
||||
});
|
||||
|
||||
const handleSave = async (data: APIKeyForm) => {
|
||||
const duplicate_key = newApiKeys.find(entry => data[entry.key.trim()]);
|
||||
if (duplicate_key) {
|
||||
toast.error(`Key ${duplicate_key.key} already exists`);
|
||||
return;
|
||||
}
|
||||
const submitData = {
|
||||
...data,
|
||||
...Object.fromEntries(newApiKeys.map(entry => [entry.key.trim(), entry.value.trim()]))
|
||||
}
|
||||
|
||||
updateApiKeys.mutate(submitData);
|
||||
}
|
||||
|
||||
const handleAddNewKey = () => {
|
||||
setNewApiKeys([...newApiKeys, {key: "", value: "", id: crypto.randomUUID()}]);
|
||||
}
|
||||
|
||||
const checkKeyIsDuplicate = (key: string) => {
|
||||
const trimmedKey = key.trim();
|
||||
const keyIsDuplicate =
|
||||
trimmedKey &&
|
||||
(
|
||||
(apiKeys && Object.keys(apiKeys).includes(trimmedKey)) ||
|
||||
newApiKeys.filter(e => e.key.trim() === trimmedKey).length > 1
|
||||
);
|
||||
return keyIsDuplicate;
|
||||
}
|
||||
|
||||
const handleNewKeyChange = (id: string, field: string, value: string) => {
|
||||
setNewApiKeys(prev =>
|
||||
prev.map(entry => entry.id === id ? {...entry, [field]: value} : entry)
|
||||
);
|
||||
}
|
||||
|
||||
const handleDeleteKey = (id: string) => {
|
||||
setNewApiKeys(prev => prev.filter(entry => entry.id !== id));
|
||||
}
|
||||
|
||||
const hasEmptyKeyValues = newApiKeys.some(entry => entry.key.trim() === "" || entry.value.trim() === "");
|
||||
const hasDuplicateKeys = (): boolean => {
|
||||
const allKeys = [...Object.keys(apiKeys || {}), ...newApiKeys.map(entry => entry.key.trim())];
|
||||
const uniqueKeys = new Set(allKeys);
|
||||
return uniqueKeys.size !== allKeys.length;
|
||||
}
|
||||
|
||||
const updateApiKeys = useMutation({
|
||||
mutationFn: async (data: APIKeyForm) => {
|
||||
const response = await backendApi.post('/env-vars', data);
|
||||
await queryClient.invalidateQueries({ queryKey: ['api-keys'] });
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
setNewApiKeys([]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update API keys');
|
||||
}
|
||||
});
|
||||
|
||||
const keysArray = apiKeys ? Object.entries(apiKeys).map(([key, value]) => ({
|
||||
id: key,
|
||||
name: key,
|
||||
value: value
|
||||
})) : [];
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys) {
|
||||
reset(apiKeys);
|
||||
}
|
||||
}, [apiKeys, reset]);
|
||||
|
||||
const toggleKeyVisibility = (keyId: string) => {
|
||||
setVisibleKeys(prev => ({
|
||||
...prev,
|
||||
[keyId]: !prev[keyId]
|
||||
}));
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Local .Env Manager</CardTitle>
|
||||
<CardDescription>Loading...</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
return <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Local .Env Manager</CardTitle>
|
||||
<CardDescription>
|
||||
{isLocalMode() ? (
|
||||
<>
|
||||
Manage your local environment variables
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Local .Env Manager is only available in local mode.
|
||||
</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{isLocalMode() && (
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(handleSave)} className="space-y-4">
|
||||
{keysArray && keysArray?.map(key => (
|
||||
|
||||
<div key={key.id} className="space-y-2">
|
||||
<Label htmlFor={key.id}>{key.name}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={key.id}
|
||||
type={visibleKeys[key.id] ? 'text' : 'password'}
|
||||
placeholder={key.name}
|
||||
{...register(key.id)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={() => toggleKeyVisibility(key.id)}>
|
||||
{visibleKeys[key.id] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors[key.id] && <p className="text-red-500">{errors[key.id]?.message}</p>}
|
||||
</div>
|
||||
|
||||
))}
|
||||
|
||||
<div className="space-y-4">
|
||||
{newApiKeys.map(entry => {
|
||||
const keyIsDuplicate = checkKeyIsDuplicate(entry.key);
|
||||
return (
|
||||
|
||||
<div key={entry.id} className="space-y-2">
|
||||
<Label htmlFor={entry.id}>{entry.key || "New API Key"}</Label>
|
||||
<div className="space-x-2 flex">
|
||||
<Input
|
||||
id={`${entry.id}-key`}
|
||||
type="text"
|
||||
placeholder="KEY"
|
||||
value={entry.key}
|
||||
onChange={(e) => handleNewKeyChange(entry.id, 'key', e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id={`${entry.id}-value`}
|
||||
type="text"
|
||||
placeholder="VALUE"
|
||||
value={entry.value}
|
||||
onChange={(e) => handleNewKeyChange(entry.id, 'value', e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteKey(entry.id)}
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{keyIsDuplicate && <p className="text-red-400 font-light">Key already exists</p>}
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddNewKey}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add New Key
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={(!isDirty && newApiKeys.length === 0) || hasEmptyKeyValues || hasDuplicateKeys()}
|
||||
>Save</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
}
|
|
@ -17,6 +17,7 @@ import {
|
|||
AudioWaveform,
|
||||
Sun,
|
||||
Moon,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { useAccounts } from '@/hooks/use-accounts';
|
||||
import NewTeamForm from '@/components/basejump/new-team-form';
|
||||
|
@ -48,6 +49,7 @@ import {
|
|||
} from '@/components/ui/dialog';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
|
||||
export function NavUserWithTeams({
|
||||
user,
|
||||
|
@ -286,6 +288,12 @@ export function NavUserWithTeams({
|
|||
Billing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isLocalMode() && <DropdownMenuItem asChild>
|
||||
<Link href="/settings/env-manager">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
Local .Env Manager
|
||||
</Link>
|
||||
</DropdownMenuItem>}
|
||||
{/* <DropdownMenuItem asChild>
|
||||
<Link href="/settings">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
|
|
|
@ -14,7 +14,7 @@ import {
|
|||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash, Cpu } from 'lucide-react';
|
||||
import { Check, ChevronDown, Search, AlertTriangle, Crown, ArrowUpRight, Brain, Plus, Edit, Trash, Cpu, Key, KeyRound } from 'lucide-react';
|
||||
import {
|
||||
ModelOption,
|
||||
SubscriptionStatus,
|
||||
|
@ -32,6 +32,7 @@ import { cn } from '@/lib/utils';
|
|||
import { useRouter } from 'next/navigation';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { CustomModelDialog, CustomModelFormData } from './custom-model-dialog';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface CustomModel {
|
||||
id: string;
|
||||
|
@ -674,6 +675,22 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
<div className="px-3 py-3 flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-muted-foreground">All Models</span>
|
||||
{isLocalMode() && (
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/settings/env-manager"
|
||||
className="h-6 w-6 p-0 flex items-center justify-center"
|
||||
>
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Local .Env Manager
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
@ -694,6 +711,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{uniqueModels
|
||||
|
|
|
@ -18,7 +18,7 @@ function ScrollArea({
|
|||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&>div]:!block"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { backendApi } from '@/lib/api-client';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { supabaseMFAService } from '@/lib/supabase/mfa';
|
||||
|
||||
|
||||
|
||||
|
@ -78,95 +77,59 @@ export interface AALResponse {
|
|||
|
||||
|
||||
export const phoneVerificationService = {
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Enroll phone number for SMS-based 2FA
|
||||
*/
|
||||
async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise<EnrollFactorResponse> {
|
||||
const response = await backendApi.post<EnrollFactorResponse>('/mfa/enroll', data);
|
||||
return response.data;
|
||||
return await supabaseMFAService.enrollPhoneNumber(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a challenge for an enrolled phone factor (sends SMS)
|
||||
*/
|
||||
async createChallenge(data: PhoneVerificationChallenge): Promise<ChallengeResponse> {
|
||||
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', data);
|
||||
return response.data;
|
||||
return await supabaseMFAService.createChallenge(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify SMS code for phone verification
|
||||
*/
|
||||
async verifyChallenge(data: PhoneVerificationVerify): Promise<PhoneVerificationResponse> {
|
||||
try {
|
||||
const response = await backendApi.post('/mfa/verify', data);
|
||||
|
||||
// After successful verification, refresh the Supabase session
|
||||
// This ensures the frontend client gets the updated session with AAL2 tokens
|
||||
try {
|
||||
const supabase = createClient();
|
||||
await supabase.auth.refreshSession();
|
||||
console.log("🔄 Frontend Supabase session refreshed after verification");
|
||||
} catch (refreshError) {
|
||||
console.warn("⚠️ Failed to refresh Supabase session:", refreshError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: response.data.success || true,
|
||||
message: response.data.message || 'SMS code verified successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("❌ Verify challenge failed:", error);
|
||||
throw error;
|
||||
}
|
||||
return await supabaseMFAService.verifyChallenge(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create challenge and verify in one step
|
||||
*/
|
||||
async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise<PhoneVerificationResponse> {
|
||||
const response = await backendApi.post('/mfa/challenge-and-verify', data);
|
||||
return {
|
||||
success: response.data.success || true,
|
||||
message: response.data.message || 'SMS challenge created and verified successfully'
|
||||
};
|
||||
return await supabaseMFAService.challengeAndVerify(data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resend SMS code (create new challenge for existing factor)
|
||||
*/
|
||||
async resendSMS(factorId: string): Promise<ChallengeResponse> {
|
||||
const response = await backendApi.post<ChallengeResponse>('/mfa/challenge', { factor_id: factorId });
|
||||
return response.data;
|
||||
return await supabaseMFAService.resendSMS(factorId);
|
||||
},
|
||||
|
||||
/**
|
||||
* List all enrolled MFA factors
|
||||
*/
|
||||
async listFactors(): Promise<ListFactorsResponse> {
|
||||
const response = await backendApi.get<ListFactorsResponse>('/mfa/factors');
|
||||
return response.data;
|
||||
return await supabaseMFAService.listFactors();
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove phone verification from account
|
||||
*/
|
||||
async unenrollFactor(factorId: string): Promise<PhoneVerificationResponse> {
|
||||
const response = await backendApi.post('/mfa/unenroll', { factor_id: factorId });
|
||||
return {
|
||||
success: response.data.success || true,
|
||||
message: response.data.message || 'Phone factor unenrolled successfully'
|
||||
};
|
||||
return await supabaseMFAService.unenrollFactor(factorId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Authenticator Assurance Level
|
||||
*/
|
||||
async getAAL(): Promise<AALResponse> {
|
||||
const response = await backendApi.get<AALResponse>('/mfa/aal');
|
||||
return response.data;
|
||||
return await supabaseMFAService.getAAL();
|
||||
}
|
||||
};
|
|
@ -14,5 +14,11 @@ export const createClient = () => {
|
|||
// console.log('Supabase URL:', supabaseUrl);
|
||||
// console.log('Supabase Anon Key:', supabaseAnonKey);
|
||||
|
||||
return createBrowserClient(supabaseUrl, supabaseAnonKey);
|
||||
return createBrowserClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
autoRefreshToken: true,
|
||||
persistSession: true,
|
||||
detectSessionInUrl: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -0,0 +1,382 @@
|
|||
import { createClient } from './client';
|
||||
import type {
|
||||
FactorInfo,
|
||||
PhoneVerificationEnroll,
|
||||
PhoneVerificationChallenge,
|
||||
PhoneVerificationVerify,
|
||||
PhoneVerificationChallengeAndVerify,
|
||||
PhoneVerificationResponse,
|
||||
EnrollFactorResponse,
|
||||
ChallengeResponse,
|
||||
ListFactorsResponse,
|
||||
AALResponse,
|
||||
} from '@/lib/api/phone-verification';
|
||||
|
||||
// Cutoff date for new user phone verification requirement
|
||||
// Users created after this date will be required to have phone verification
|
||||
// Users created before this date are grandfathered in and not required to verify
|
||||
const PHONE_VERIFICATION_CUTOFF_DATE = new Date('2025-07-25T00:09:30.000Z');
|
||||
|
||||
function isPhoneVerificationMandatory(): boolean {
|
||||
const envVal = process.env.NEXT_PUBLIC_PHONE_NUMBER_MANDATORY;
|
||||
if (!envVal) return false;
|
||||
return envVal.toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
export const supabaseMFAService = {
|
||||
/**
|
||||
* Enroll phone number for SMS-based 2FA
|
||||
*/
|
||||
async enrollPhoneNumber(data: PhoneVerificationEnroll): Promise<EnrollFactorResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const response = await supabase.auth.mfa.enroll({
|
||||
factorType: 'phone',
|
||||
friendlyName: data.friendly_name,
|
||||
phone: data.phone_number,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('No data returned from enrollment');
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
friendly_name: data.friendly_name,
|
||||
phone_number: data.phone_number,
|
||||
qr_code: undefined, // Phone factors don't have QR codes
|
||||
secret: undefined, // Phone factors don't have secrets
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Enroll phone factor failed:', error);
|
||||
throw new Error(`Failed to enroll phone factor: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a challenge for an enrolled phone factor (sends SMS)
|
||||
*/
|
||||
async createChallenge(data: PhoneVerificationChallenge): Promise<ChallengeResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const response = await supabase.auth.mfa.challenge({
|
||||
factorId: data.factor_id,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('No data returned from challenge');
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
expires_at: response.data.expires_at ? new Date(response.data.expires_at * 1000).toISOString() : undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Create SMS challenge failed:', error);
|
||||
throw new Error(`Failed to create SMS challenge: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify SMS code for phone verification
|
||||
*/
|
||||
async verifyChallenge(data: PhoneVerificationVerify): Promise<PhoneVerificationResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
console.log('🔵 Starting MFA verification with Supabase client');
|
||||
|
||||
const response = await supabase.auth.mfa.verify({
|
||||
factorId: data.factor_id,
|
||||
challengeId: data.challenge_id,
|
||||
code: data.code,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
console.log('✅ MFA verification successful');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMS code verified successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Verify challenge failed:', error);
|
||||
throw new Error(`Failed to verify SMS code: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create challenge and verify in one step
|
||||
*/
|
||||
async challengeAndVerify(data: PhoneVerificationChallengeAndVerify): Promise<PhoneVerificationResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const response = await supabase.auth.mfa.challengeAndVerify({
|
||||
factorId: data.factor_id,
|
||||
code: data.code,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMS challenge created and verified successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Challenge and verify SMS failed:', error);
|
||||
throw new Error(`Failed to challenge and verify SMS: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resend SMS code (create new challenge for existing factor)
|
||||
*/
|
||||
async resendSMS(factorId: string): Promise<ChallengeResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const response = await supabase.auth.mfa.challenge({
|
||||
factorId: factorId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
if (!response.data) {
|
||||
throw new Error('No data returned from challenge');
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.data.id,
|
||||
expires_at: response.data.expires_at ? new Date(response.data.expires_at * 1000).toISOString() : undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Resend SMS failed:', error);
|
||||
throw new Error(`Failed to resend SMS: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List all enrolled MFA factors
|
||||
*/
|
||||
async listFactors(): Promise<ListFactorsResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const factors: FactorInfo[] = [];
|
||||
|
||||
if (user.factors) {
|
||||
for (const factor of user.factors) {
|
||||
factors.push({
|
||||
id: factor.id,
|
||||
friendly_name: factor.friendly_name,
|
||||
factor_type: factor.factor_type,
|
||||
status: factor.status,
|
||||
phone: (factor as any).phone, // Phone property may not be in the type definition
|
||||
created_at: factor.created_at,
|
||||
updated_at: factor.updated_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { factors };
|
||||
} catch (error: any) {
|
||||
console.error('❌ List factors failed:', error);
|
||||
throw new Error(`Failed to list factors: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove phone verification from account
|
||||
*/
|
||||
async unenrollFactor(factorId: string): Promise<PhoneVerificationResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
const response = await supabase.auth.mfa.unenroll({
|
||||
factorId: factorId,
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Phone factor unenrolled successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Unenroll factor failed:', error);
|
||||
throw new Error(`Failed to unenroll phone factor: ${error.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Authenticator Assurance Level
|
||||
*/
|
||||
async getAAL(): Promise<AALResponse> {
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
// Get the current AAL from Supabase
|
||||
const aalResponse = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
|
||||
if (aalResponse.error) {
|
||||
throw new Error(aalResponse.error.message);
|
||||
}
|
||||
|
||||
// Get user creation date and factors for phone verification requirement
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||
|
||||
if (userError) {
|
||||
throw new Error(userError.message);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
let userCreatedAt: Date | null = null;
|
||||
if (user.created_at) {
|
||||
try {
|
||||
userCreatedAt = new Date(user.created_at);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse user created_at:', e);
|
||||
// Fall back to treating as new user for safety
|
||||
userCreatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this is a new user who needs phone verification
|
||||
const isNewUser = userCreatedAt && userCreatedAt >= PHONE_VERIFICATION_CUTOFF_DATE;
|
||||
|
||||
// Get factors and compute phone verification status
|
||||
const factors: any[] = [];
|
||||
const phoneFactors: any[] = [];
|
||||
let hasVerifiedPhone = false;
|
||||
|
||||
if (user.factors) {
|
||||
for (const factor of user.factors) {
|
||||
const factorInfo = {
|
||||
id: factor.id,
|
||||
friendly_name: factor.friendly_name,
|
||||
factor_type: factor.factor_type,
|
||||
status: factor.status,
|
||||
phone: (factor as any).phone, // Phone property may not be in the type definition
|
||||
created_at: factor.created_at,
|
||||
updated_at: factor.updated_at,
|
||||
};
|
||||
factors.push(factorInfo);
|
||||
|
||||
if (factor.factor_type === 'phone') {
|
||||
phoneFactors.push(factorInfo);
|
||||
if (factor.status === 'verified') {
|
||||
hasVerifiedPhone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const current = aalResponse.data?.currentLevel;
|
||||
const nextLevel = aalResponse.data?.nextLevel;
|
||||
|
||||
// Determine action required based on AAL combination
|
||||
let actionRequired: string = 'none';
|
||||
let message: string = '';
|
||||
|
||||
if (current === 'aal1' && nextLevel === 'aal1') {
|
||||
// User does not have MFA enrolled
|
||||
actionRequired = 'none';
|
||||
message = 'MFA is not enrolled for this account';
|
||||
} else if (current === 'aal1' && nextLevel === 'aal2') {
|
||||
// User has MFA enrolled but needs to verify it
|
||||
actionRequired = 'verify_mfa';
|
||||
message = 'MFA verification required to access full features';
|
||||
} else if (current === 'aal2' && nextLevel === 'aal2') {
|
||||
// User has verified their MFA factor
|
||||
actionRequired = 'none';
|
||||
message = 'MFA is verified and active';
|
||||
} else if (current === 'aal2' && nextLevel === 'aal1') {
|
||||
// User has disabled MFA or has stale JWT
|
||||
actionRequired = 'reauthenticate';
|
||||
message = 'Session needs refresh due to MFA changes';
|
||||
} else {
|
||||
// Unknown combination
|
||||
actionRequired = 'unknown';
|
||||
message = `Unknown AAL combination: ${current} -> ${nextLevel}`;
|
||||
}
|
||||
|
||||
// Determine verification_required based on AAL status AND grandfathering logic
|
||||
let verificationRequired = false;
|
||||
if (isNewUser) {
|
||||
// New users (created after cutoff date) must have phone verification
|
||||
if (current === 'aal1' && nextLevel === 'aal1') {
|
||||
// No MFA enrolled - new users must enroll
|
||||
verificationRequired = true;
|
||||
} else if (actionRequired === 'verify_mfa') {
|
||||
// MFA enrolled but needs verification
|
||||
verificationRequired = true;
|
||||
}
|
||||
} else {
|
||||
// Existing users (grandfathered) - only require verification if AAL demands it
|
||||
verificationRequired = actionRequired === 'verify_mfa';
|
||||
}
|
||||
|
||||
const phoneVerificationRequired = isNewUser && isPhoneVerificationMandatory();
|
||||
verificationRequired = isNewUser && verificationRequired && isPhoneVerificationMandatory();
|
||||
|
||||
console.log('AAL check: ', {
|
||||
current_level: current,
|
||||
next_level: nextLevel,
|
||||
action_required: actionRequired,
|
||||
phone_verification_required: phoneVerificationRequired,
|
||||
verification_required: verificationRequired,
|
||||
is_verified: hasVerifiedPhone,
|
||||
});
|
||||
|
||||
return {
|
||||
current_level: current,
|
||||
next_level: nextLevel,
|
||||
current_authentication_methods: aalResponse.data?.currentAuthenticationMethods?.map(m => m.method) || [],
|
||||
action_required: actionRequired,
|
||||
message: message,
|
||||
phone_verification_required: phoneVerificationRequired,
|
||||
user_created_at: userCreatedAt?.toISOString(),
|
||||
cutoff_date: PHONE_VERIFICATION_CUTOFF_DATE.toISOString(),
|
||||
verification_required: verificationRequired,
|
||||
is_verified: hasVerifiedPhone,
|
||||
factors: factors,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('❌ Get AAL failed:', error);
|
||||
throw new Error(`Failed to get AAL: ${error.message}`);
|
||||
}
|
||||
},
|
||||
};
|
Loading…
Reference in New Issue