mirror of https://github.com/kortix-ai/suna.git
Merge branch 'main' into fix-ux-issues
This commit is contained in:
commit
8188a83a6d
|
@ -576,7 +576,7 @@ For casual conversation and social interactions:
|
|||
* Attach all relevant files with the **'ask'** tool when asking a question related to them, or when delivering final results before completion.
|
||||
* Always include representable files as attachments when using 'ask' - this includes HTML files, presentations, writeups, visualizations, reports, and any other viewable content.
|
||||
* For any created files that can be viewed or presented (such as index.html, slides, documents, charts, etc.), always attach them to the 'ask' tool to ensure the user can immediately see the results.
|
||||
* Share results and deliverables before entering complete state (use 'ask' with attachments as appropriate).
|
||||
* Always share results and deliverables using 'ask' tool with attachments before entering complete state, or include them directly with the 'complete' tool. Do not use 'complete' tool directly before using 'ask' tool unless you're including all necessary attachments.
|
||||
* Ensure users have access to all necessary resources.
|
||||
|
||||
- Communication Tools Summary:
|
||||
|
|
|
@ -217,31 +217,62 @@ If you encounter any issues or need to take additional steps, please let me know
|
|||
"type": "function",
|
||||
"function": {
|
||||
"name": "complete",
|
||||
"description": "A special tool to indicate you have completed all tasks and are about to enter complete state. Use ONLY when: 1) All tasks in todo.md are marked complete [x], 2) The user's original request has been fully addressed, 3) There are no pending actions or follow-ups required, 4) You've delivered all final outputs and results to the user. IMPORTANT: This is the ONLY way to properly terminate execution. Never use this tool unless ALL tasks are complete and verified. Always ensure you've provided all necessary outputs and references before using this tool.",
|
||||
"description": "A special tool to indicate you have completed all tasks and are about to enter complete state. Use ONLY when: 1) All tasks in todo.md are marked complete [x], 2) The user's original request has been fully addressed, 3) There are no pending actions or follow-ups required, 4) You've delivered all final outputs and results to the user. IMPORTANT: This is the ONLY way to properly terminate execution. Never use this tool unless ALL tasks are complete and verified. Always ensure you've provided all necessary outputs and references before using this tool. Include relevant attachments when the completion relates to specific files or resources.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Completion message or summary to present to user - should provide clear indication of what was accomplished. Include: 1) Summary of completed tasks, 2) Key deliverables or outputs, 3) Any important notes or next steps, 4) Impact or benefits achieved."
|
||||
},
|
||||
"attachments": {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"items": {"type": "string"}, "type": "array"}
|
||||
],
|
||||
"description": "(Optional) List of files or URLs to attach to the completion message. Include when: 1) Completion relates to specific files or configurations, 2) User needs to review final outputs, 3) Deliverables are documented in files, 4) Supporting evidence or context is needed. Always use relative paths to /workspace directory."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
})
|
||||
@xml_schema(
|
||||
tag_name="complete",
|
||||
mappings=[],
|
||||
mappings=[
|
||||
{"param_name": "text", "node_type": "content", "path": ".", "required": False},
|
||||
{"param_name": "attachments", "node_type": "attribute", "path": ".", "required": False}
|
||||
],
|
||||
example='''
|
||||
<function_calls>
|
||||
<invoke name="complete">
|
||||
<parameter name="text">I have successfully completed all tasks for your project. Here's what was accomplished:
|
||||
1. Created the web application with modern UI components
|
||||
2. Implemented user authentication and database integration
|
||||
3. Deployed the application to production
|
||||
4. Created comprehensive documentation
|
||||
|
||||
All deliverables are attached for your review.</parameter>
|
||||
<parameter name="attachments">app/src/main.js,docs/README.md,deployment-config.yaml</parameter>
|
||||
</invoke>
|
||||
</function_calls>
|
||||
'''
|
||||
)
|
||||
async def complete(self) -> ToolResult:
|
||||
async def complete(self, text: Optional[str] = None, attachments: Optional[Union[str, List[str]]] = None) -> ToolResult:
|
||||
"""Indicate that the agent has completed all tasks and is entering complete state.
|
||||
|
||||
Args:
|
||||
text: Optional completion message or summary to present to the user
|
||||
attachments: Optional file paths or URLs to attach to the completion message
|
||||
|
||||
Returns:
|
||||
ToolResult indicating successful transition to complete state
|
||||
"""
|
||||
try:
|
||||
# Convert single attachment to list for consistent handling
|
||||
if attachments and isinstance(attachments, str):
|
||||
attachments = [attachments]
|
||||
|
||||
return self.success_response({"status": "complete"})
|
||||
except Exception as e:
|
||||
return self.fail_response(f"Error entering complete state: {str(e)}")
|
||||
|
|
|
@ -155,7 +155,7 @@ app.add_middleware(
|
|||
allow_origin_regex=allow_origin_regex,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers"],
|
||||
allow_headers=["Content-Type", "Authorization", "X-Project-Id", "X-MCP-URL", "X-MCP-Type", "X-MCP-Headers", "X-Refresh-Token"],
|
||||
)
|
||||
|
||||
# Create a main API router
|
||||
|
@ -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,591 @@
|
|||
"""
|
||||
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 = is_new_user and is_phone_verification_mandatory()
|
||||
verification_required = 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)}")
|
|
@ -28,7 +28,7 @@ dependencies = [
|
|||
"python-multipart==0.0.20",
|
||||
"redis==5.2.1",
|
||||
"upstash-redis==1.3.0",
|
||||
"supabase==2.15.0",
|
||||
"supabase==2.17.0",
|
||||
"pyjwt==2.10.1",
|
||||
"exa-py==1.9.1",
|
||||
"e2b-code-interpreter==1.2.0",
|
||||
|
|
|
@ -24,13 +24,14 @@ You can modify the sandbox environment for development or to add new capabilitie
|
|||
```
|
||||
3. Test your changes locally using docker-compose
|
||||
|
||||
## Using a Custom Image
|
||||
## Using a Custom Snapshot
|
||||
|
||||
To use your custom sandbox image:
|
||||
To use your custom sandbox snapshot:
|
||||
|
||||
1. Change the `image` parameter in `docker-compose.yml` (that defines the image name `kortix/suna:___`)
|
||||
2. Update the same image name in `backend/sandbox/sandbox.py` in the `create_sandbox` function
|
||||
3. If using Daytona for deployment, update the image reference there as well
|
||||
2. Build and create a snapshot in Daytona with the same name
|
||||
3. Update the snapshot name in `backend/sandbox/sandbox.py` in the `create_sandbox` function
|
||||
4. If using Daytona for deployment, update the snapshot reference there as well
|
||||
|
||||
## Publishing New Versions
|
||||
|
||||
|
@ -39,7 +40,8 @@ When publishing a new version of the sandbox:
|
|||
1. Update the version number in `docker-compose.yml` (e.g., from `0.1.2` to `0.1.3`)
|
||||
2. Build the new image: `docker compose build`
|
||||
3. Push the new version: `docker push kortix/suna:0.1.3`
|
||||
4. Update all references to the image version in:
|
||||
4. Create a new snapshot in Daytona with the same name
|
||||
5. Update all references to the snapshot version in:
|
||||
- `backend/utils/config.py`
|
||||
- Daytona images
|
||||
- Any other services using this image
|
||||
- Daytona snapshots
|
||||
- Any other services using this snapshot
|
|
@ -1,4 +1,4 @@
|
|||
from daytona_sdk import AsyncDaytona, DaytonaConfig, CreateSandboxFromImageParams, AsyncSandbox, SessionExecuteRequest, Resources, SandboxState
|
||||
from daytona_sdk import AsyncDaytona, DaytonaConfig, CreateSandboxFromSnapshotParams, AsyncSandbox, SessionExecuteRequest, Resources, SandboxState
|
||||
from dotenv import load_dotenv
|
||||
from utils.logger import logger
|
||||
from utils.config import config
|
||||
|
@ -82,15 +82,15 @@ async def create_sandbox(password: str, project_id: str = None) -> AsyncSandbox:
|
|||
"""Create a new sandbox with all required services configured and running."""
|
||||
|
||||
logger.debug("Creating new Daytona sandbox environment")
|
||||
logger.debug("Configuring sandbox with browser-use image and environment variables")
|
||||
logger.debug("Configuring sandbox with snapshot and environment variables")
|
||||
|
||||
labels = None
|
||||
if project_id:
|
||||
logger.debug(f"Using sandbox_id as label: {project_id}")
|
||||
labels = {'id': project_id}
|
||||
|
||||
params = CreateSandboxFromImageParams(
|
||||
image=Configuration.SANDBOX_IMAGE_NAME,
|
||||
params = CreateSandboxFromSnapshotParams(
|
||||
snapshot=Configuration.SANDBOX_SNAPSHOT_NAME,
|
||||
public=True,
|
||||
labels=labels,
|
||||
env_vars={
|
||||
|
@ -112,7 +112,7 @@ async def create_sandbox(password: str, project_id: str = None) -> AsyncSandbox:
|
|||
disk=5,
|
||||
),
|
||||
auto_stop_interval=15,
|
||||
auto_archive_interval=24 * 60,
|
||||
auto_archive_interval=2 * 60,
|
||||
)
|
||||
|
||||
# Create the sandbox
|
||||
|
|
|
@ -236,7 +236,7 @@ def prepare_params(
|
|||
|
||||
if is_kimi_k2:
|
||||
params["provider"] = {
|
||||
"order": ["groq", "together/fp8"]
|
||||
"order": ["baseten/fp8", "together/fp8", "novita/fp8", "moonshotai", "groq"]
|
||||
}
|
||||
|
||||
if is_anthropic and use_thinking:
|
||||
|
|
|
@ -222,6 +222,7 @@ class Configuration:
|
|||
|
||||
# Sandbox configuration
|
||||
SANDBOX_IMAGE_NAME = "kortix/suna:0.1.3"
|
||||
SANDBOX_SNAPSHOT_NAME = "kortix/suna:0.1.3"
|
||||
SANDBOX_ENTRYPOINT = "/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf"
|
||||
|
||||
# LangFuse configuration
|
||||
|
|
|
@ -8,7 +8,7 @@ MODELS = {
|
|||
"input_cost_per_million_tokens": 3.00,
|
||||
"output_cost_per_million_tokens": 15.00
|
||||
},
|
||||
"tier_availability": ["free", "paid"]
|
||||
"tier_availability": ["paid"]
|
||||
},
|
||||
# "openrouter/deepseek/deepseek-chat": {
|
||||
# "aliases": ["deepseek"],
|
||||
|
@ -56,7 +56,7 @@ MODELS = {
|
|||
"input_cost_per_million_tokens": 5.00,
|
||||
"output_cost_per_million_tokens": 15.00
|
||||
},
|
||||
"tier_availability": ["free", "paid"]
|
||||
"tier_availability": ["paid"]
|
||||
},
|
||||
|
||||
# Paid tier only models
|
||||
|
|
2725
backend/uv.lock
2725
backend/uv.lock
File diff suppressed because it is too large
Load Diff
|
@ -73,6 +73,7 @@
|
|||
"json5": "^2.2.3",
|
||||
"jsonrepair": "^3.12.0",
|
||||
"jszip": "^3.10.1",
|
||||
"libphonenumber-js": "^1.12.10",
|
||||
"lucide-react": "^0.479.0",
|
||||
"marked": "^15.0.7",
|
||||
"motion": "^12.5.0",
|
||||
|
@ -89,6 +90,7 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"react-papaparse": "^4.4.0",
|
||||
"react-pdf": "^9.2.1",
|
||||
"react-phone-number-input": "^3.4.12",
|
||||
"react-scan": "^0.0.44",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
|
@ -6981,6 +6983,12 @@
|
|||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
|
@ -7418,6 +7426,12 @@
|
|||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/country-flag-icons": {
|
||||
"version": "1.5.19",
|
||||
"resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.5.19.tgz",
|
||||
"integrity": "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||
|
@ -9597,6 +9611,27 @@
|
|||
"integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/input-format": {
|
||||
"version": "0.3.14",
|
||||
"resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz",
|
||||
"integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.1.0",
|
||||
"react-dom": ">=18.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
|
@ -10318,6 +10353,12 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.12.10",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
|
||||
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
|
@ -12756,6 +12797,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-phone-number-input": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.12.tgz",
|
||||
"integrity": "sha512-Raob77KdtLGm49iC6nuOX9qy6Mg16idkgC7Y1mHmvG2WBYoauHpzxYNlfmFskQKeiztrJIwPhPzBhjFwjenNCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"country-flag-icons": "^1.5.17",
|
||||
"input-format": "^0.3.10",
|
||||
"libphonenumber-js": "^1.11.20",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.0.tgz",
|
||||
|
|
|
@ -76,6 +76,7 @@
|
|||
"json5": "^2.2.3",
|
||||
"jsonrepair": "^3.12.0",
|
||||
"jszip": "^3.10.1",
|
||||
"libphonenumber-js": "^1.12.10",
|
||||
"lucide-react": "^0.479.0",
|
||||
"marked": "^15.0.7",
|
||||
"motion": "^12.5.0",
|
||||
|
@ -92,6 +93,7 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"react-papaparse": "^4.4.0",
|
||||
"react-pdf": "^9.2.1",
|
||||
"react-phone-number-input": "^3.4.12",
|
||||
"react-scan": "^0.0.44",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
import { DashboardContent } from "../../../components/dashboard/dashboard-content";
|
||||
import { BackgroundAALChecker } from "@/components/auth/background-aal-checker";
|
||||
import { Suspense } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { isFlagEnabled } from "@/lib/feature-flags";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
return (
|
||||
<BackgroundAALChecker>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex flex-col h-full w-full">
|
||||
|
@ -27,5 +29,6 @@ export default async function DashboardPage() {
|
|||
>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
</BackgroundAALChecker>
|
||||
);
|
||||
}
|
|
@ -509,8 +509,11 @@ export default function ThreadPage({
|
|||
setDebugMode(debugParam === 'true');
|
||||
}, [searchParams]);
|
||||
|
||||
const hasCheckedUpgradeDialog = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLoadCompleted && subscriptionData) {
|
||||
if (initialLoadCompleted && subscriptionData && !hasCheckedUpgradeDialog.current) {
|
||||
hasCheckedUpgradeDialog.current = true;
|
||||
const hasSeenUpgradeDialog = localStorage.getItem('suna_upgrade_dialog_displayed');
|
||||
const isFreeTier = subscriptionStatus === 'no_subscription';
|
||||
if (!hasSeenUpgradeDialog && isFreeTier && !isLocalMode()) {
|
||||
|
@ -670,6 +673,7 @@ export default function ThreadPage({
|
|||
setIsSidePanelOpen(true);
|
||||
userClosedPanelRef.current = false;
|
||||
}}
|
||||
defaultShowSnackbar="tokens"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -10,11 +10,13 @@ import { PricingSection } from '@/components/home/sections/pricing-section';
|
|||
import { UseCasesSection } from '@/components/home/sections/use-cases-section';
|
||||
import { ModalProviders } from '@/providers/modal-providers';
|
||||
import { HeroVideoSection } from '@/components/home/sections/hero-video-section';
|
||||
import { BackgroundAALChecker } from '@/components/auth/background-aal-checker';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<ModalProviders />
|
||||
<BackgroundAALChecker>
|
||||
<main className="flex flex-col items-center justify-center min-h-screen w-full">
|
||||
<div className="w-full divide-y divide-border">
|
||||
<HeroSection />
|
||||
|
@ -37,6 +39,7 @@ export default function Home() {
|
|||
<FooterSection />
|
||||
</div>
|
||||
</main>
|
||||
</BackgroundAALChecker>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -247,7 +247,7 @@ function LoginContent() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-11 items-center justify-center px-6 text-center rounded-lg border border-border bg-background hover:bg-accent transition-colors"
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { PhoneVerificationPage } from "@/components/auth/phone-verification/phone-verification-page";
|
||||
|
||||
export default function PhoneVerificationRoute() {
|
||||
return <PhoneVerificationPage />;
|
||||
}
|
|
@ -31,7 +31,7 @@ export const ToolCallsContext = createContext<{
|
|||
setToolCalls: React.Dispatch<React.SetStateAction<ParsedTag[]>>;
|
||||
}>({
|
||||
toolCalls: [],
|
||||
setToolCalls: () => { },
|
||||
setToolCalls: () => {},
|
||||
});
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { useGetAAL } from '@/hooks/react-query/phone-verification';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
|
||||
interface BackgroundAALCheckerProps {
|
||||
children: React.ReactNode;
|
||||
redirectTo?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BackgroundAALChecker runs MFA checks silently in the background without blocking the UI.
|
||||
*
|
||||
* Only redirects when:
|
||||
* - New users (created after cutoff) who don't have MFA enrolled
|
||||
* - Users who have MFA enrolled but need verification
|
||||
* - Users who need to reauthenticate due to MFA changes
|
||||
*
|
||||
* Does NOT show loading states or block the UI - runs entirely in background.
|
||||
*/
|
||||
export function BackgroundAALChecker({
|
||||
children,
|
||||
redirectTo = '/auth/phone-verification',
|
||||
enabled = true
|
||||
}: BackgroundAALCheckerProps) {
|
||||
const { user, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Only run queries if user is authenticated and check is enabled
|
||||
const { data: aalData } = useGetAAL();
|
||||
|
||||
useEffect(() => {
|
||||
// Only check if user is authenticated, not loading, and checks are enabled
|
||||
if (!authLoading && user && enabled && aalData) {
|
||||
const { action_required, current_level, next_level, verification_required } = aalData;
|
||||
|
||||
console.log('Background AAL Check:', {
|
||||
action_required,
|
||||
current_level,
|
||||
next_level,
|
||||
phone_verification_required: verification_required,
|
||||
message: aalData.message
|
||||
});
|
||||
|
||||
// Only redirect if the user is trying to access protected routes
|
||||
// Allow users to stay on the home page "/" even if phone verification fails
|
||||
const isProtectedRoute = pathname.startsWith('/dashboard') ||
|
||||
pathname.startsWith('/agents') ||
|
||||
pathname.startsWith('/projects') ||
|
||||
pathname.startsWith('/settings');
|
||||
|
||||
if (!isProtectedRoute) {
|
||||
// Don't redirect from home page or other public routes
|
||||
console.log('Background: On public route, skipping phone verification redirect');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle new users who need phone verification enrollment
|
||||
if (verification_required) {
|
||||
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');
|
||||
router.push(redirectTo);
|
||||
return;
|
||||
}
|
||||
// If new user has MFA enrolled, follow standard AAL flow below
|
||||
}
|
||||
|
||||
// Standard AAL flow (for all users)
|
||||
switch (action_required) {
|
||||
case 'verify_mfa':
|
||||
// User has MFA enrolled but needs to verify it
|
||||
console.log('Background: Redirecting to MFA verification');
|
||||
router.push(redirectTo);
|
||||
break;
|
||||
|
||||
case 'reauthenticate':
|
||||
// User has stale JWT due to MFA changes, force reauthentication
|
||||
console.log('Background: MFA state changed, forcing reauthentication');
|
||||
router.push('/auth?message=Please sign in again due to security changes');
|
||||
break;
|
||||
|
||||
case 'none':
|
||||
// No action required, user can proceed
|
||||
console.log('Background: AAL check passed, no action required');
|
||||
break;
|
||||
|
||||
case 'unknown':
|
||||
default:
|
||||
// Unknown AAL state, log and allow access (fail open)
|
||||
console.warn('Background: Unknown AAL state:', { current_level, next_level, action_required });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [user, authLoading, enabled, aalData, router, redirectTo, pathname]);
|
||||
|
||||
// Always render children immediately - no loading states
|
||||
return <>{children}</>;
|
||||
}
|
|
@ -0,0 +1,285 @@
|
|||
'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,
|
||||
Shield,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface OtpVerificationProps {
|
||||
phoneNumber?: string;
|
||||
onVerify: (otp: string) => Promise<void>;
|
||||
onResend: () => Promise<void>;
|
||||
onSendCode?: () => Promise<void>;
|
||||
onRemovePhone?: () => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
showExistingOptions?: boolean;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
export function OtpVerification({
|
||||
phoneNumber,
|
||||
onVerify,
|
||||
onResend,
|
||||
onSendCode,
|
||||
onRemovePhone,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
showExistingOptions = false,
|
||||
challengeId,
|
||||
}: 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(() => {
|
||||
if (challengeId) {
|
||||
// Focus first input when challenge is available
|
||||
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);
|
||||
}
|
||||
}, [challengeId]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (onSendCode) {
|
||||
setOtp(['', '', '', '', '', '']);
|
||||
setLocalError(null);
|
||||
setCanResend(false);
|
||||
setCountdown(30);
|
||||
await onSendCode();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{showExistingOptions
|
||||
? 'Verify Phone Number'
|
||||
: 'Enter Verification Code'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{challengeId
|
||||
? "We've sent a 6-digit code to your phone"
|
||||
: showExistingOptions
|
||||
? 'Phone already registered. Verify it by OTP.'
|
||||
: 'Enter the 6-digit code sent to your phone'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(error || localError) && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{error || localError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<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 || !challengeId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons - different layout based on whether code has been sent */}
|
||||
{challengeId ? (
|
||||
// Code has been sent - show verify and resend
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || otp.join('').length !== 6}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
Verify Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResend}
|
||||
disabled={!canResend || isLoading}
|
||||
className="text-sm"
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{canResend ? 'Resend code' : `Resend in ${countdown}s`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// No code sent yet - show send and remove options
|
||||
<div className="space-y-3">
|
||||
{onSendCode && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Send Verification Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onRemovePhone && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onRemovePhone}
|
||||
disabled={isLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove Phone Number
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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, Phone } from "lucide-react";
|
||||
import { PhoneInput as PhoneInputComponent } from "@/components/ui/phone-input";
|
||||
|
||||
interface PhoneInputFormProps {
|
||||
onSubmit: (phoneNumber: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function PhoneInput({ onSubmit, isLoading = false, error = null }: PhoneInputFormProps) {
|
||||
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>
|
||||
<PhoneInputComponent
|
||||
value={phoneNumber}
|
||||
onChange={(value) => setPhoneNumber(value || "")}
|
||||
defaultCountry="US"
|
||||
placeholder="Enter your phone number"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We'll send you a verification code to confirm your number
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(error || localError) && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{error || localError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || !phoneNumber.trim()}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sending code...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Send Verification Code
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,365 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PhoneInput } from './phone-input';
|
||||
import { OtpVerification } from './otp-verification';
|
||||
import {
|
||||
useEnrollPhoneNumber,
|
||||
useCreateChallenge,
|
||||
useVerifyChallenge,
|
||||
useListFactors,
|
||||
useGetAAL,
|
||||
useUnenrollFactor,
|
||||
} from '@/hooks/react-query/phone-verification';
|
||||
import { signOut } from '@/app/auth/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { LogOut, Loader2 } from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
interface PhoneVerificationPageProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function PhoneVerificationPage({
|
||||
onSuccess,
|
||||
}: PhoneVerificationPageProps) {
|
||||
const [step, setStep] = useState<'phone' | 'otp'>('phone');
|
||||
const [phoneNumber, setPhoneNumber] = useState('');
|
||||
const [factorId, setFactorId] = useState('');
|
||||
const [challengeId, setChallengeId] = useState('');
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [debugInfo, setDebugInfo] = useState<any>(null);
|
||||
const [isSubmittingPhone, setIsSubmittingPhone] = useState(false);
|
||||
const [hasExistingFactor, setHasExistingFactor] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
console.log({ step, challengeId, hasExistingFactor });
|
||||
|
||||
// Use React Query hooks
|
||||
const enrollMutation = useEnrollPhoneNumber();
|
||||
const challengeMutation = useCreateChallenge();
|
||||
const verifyMutation = useVerifyChallenge();
|
||||
const unenrollMutation = useUnenrollFactor();
|
||||
|
||||
// Add debugging hooks
|
||||
const { data: factors } = useListFactors();
|
||||
const { data: aalData } = useGetAAL();
|
||||
|
||||
// Check for existing verified factors on component mount
|
||||
useEffect(() => {
|
||||
// Don't interfere while we're submitting a phone number
|
||||
if (isSubmittingPhone) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (factors?.factors) {
|
||||
const phoneFactors = factors.factors.filter(
|
||||
(f) => f.factor_type === 'phone',
|
||||
);
|
||||
const verifiedPhoneFactor = phoneFactors.find(
|
||||
(f) => f.status === 'verified',
|
||||
);
|
||||
|
||||
console.log('📱 Checking existing factors:', {
|
||||
allFactors: factors.factors,
|
||||
phoneFactors,
|
||||
verifiedPhoneFactor,
|
||||
aalData,
|
||||
isSubmittingPhone,
|
||||
});
|
||||
|
||||
if (verifiedPhoneFactor) {
|
||||
// User already has a verified factor - show options
|
||||
console.log(
|
||||
'✅ Found existing verified phone factor:',
|
||||
verifiedPhoneFactor,
|
||||
);
|
||||
setStep('otp');
|
||||
setFactorId(verifiedPhoneFactor.id);
|
||||
setPhoneNumber(verifiedPhoneFactor.phone || '');
|
||||
setHasExistingFactor(true);
|
||||
// Don't set challengeId yet - let user choose to send code
|
||||
} else {
|
||||
// No verified factor found - check for unverified factors
|
||||
const unverifiedPhoneFactor = phoneFactors.find(
|
||||
(f) => f.status !== 'verified',
|
||||
);
|
||||
if (unverifiedPhoneFactor) {
|
||||
console.log(
|
||||
'⚠️ Found unverified phone factor:',
|
||||
unverifiedPhoneFactor,
|
||||
);
|
||||
setFactorId(unverifiedPhoneFactor.id);
|
||||
setPhoneNumber(unverifiedPhoneFactor.phone || '');
|
||||
setStep('otp');
|
||||
setHasExistingFactor(true);
|
||||
// Don't set challengeId yet - let user choose to send code
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [factors, aalData, isSubmittingPhone]);
|
||||
|
||||
const handleCreateChallengeForExistingFactor = async () => {
|
||||
try {
|
||||
console.log('🔵 Creating challenge for existing factor:', factorId);
|
||||
|
||||
const challengeResponse = await challengeMutation.mutateAsync({
|
||||
factor_id: factorId,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'✅ Challenge created for existing factor:',
|
||||
challengeResponse,
|
||||
);
|
||||
|
||||
setChallengeId(challengeResponse.id);
|
||||
setSuccess('Verification code sent to your phone');
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to create challenge for existing factor:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnenrollFactor = async () => {
|
||||
try {
|
||||
console.log('🔵 Unenrolling factor:', factorId);
|
||||
|
||||
await unenrollMutation.mutateAsync(factorId);
|
||||
|
||||
console.log('✅ Factor unenrolled successfully');
|
||||
|
||||
// Reset state and go back to phone input
|
||||
setStep('phone');
|
||||
setFactorId('');
|
||||
setPhoneNumber('');
|
||||
setChallengeId('');
|
||||
setHasExistingFactor(false);
|
||||
setSuccess('Phone number removed. You can now add a new one.');
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to unenroll factor:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhoneSubmit = async (phone: string) => {
|
||||
try {
|
||||
setIsSubmittingPhone(true);
|
||||
console.log('🔵 Starting phone enrollment for:', phone);
|
||||
|
||||
// Step 1: Enroll the phone number
|
||||
const enrollResponse = await enrollMutation.mutateAsync({
|
||||
friendly_name: 'Primary Phone',
|
||||
phone_number: phone,
|
||||
});
|
||||
|
||||
console.log('✅ Enrollment response:', enrollResponse);
|
||||
|
||||
// Step 2: Create a challenge (sends SMS)
|
||||
const challengeResponse = await challengeMutation.mutateAsync({
|
||||
factor_id: enrollResponse.id,
|
||||
});
|
||||
|
||||
console.log('✅ Challenge response:', challengeResponse);
|
||||
|
||||
setPhoneNumber(phone);
|
||||
setFactorId(enrollResponse.id);
|
||||
setChallengeId(challengeResponse.id);
|
||||
setStep('otp');
|
||||
setHasExistingFactor(false);
|
||||
setSuccess('Verification code sent to your phone');
|
||||
} catch (err) {
|
||||
console.error('❌ Phone submission failed:', err);
|
||||
|
||||
// If enrollment fails because factor already exists, try to handle existing factor
|
||||
if (err instanceof Error && err.message.includes('already exists')) {
|
||||
console.log(
|
||||
'🔄 Factor already exists, checking for existing factors...',
|
||||
);
|
||||
// Force refetch of factors
|
||||
window.location.reload();
|
||||
}
|
||||
} finally {
|
||||
setIsSubmittingPhone(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpVerify = async (otp: string) => {
|
||||
try {
|
||||
console.log('🔵 Starting OTP verification with:', {
|
||||
factor_id: factorId,
|
||||
challenge_id: challengeId,
|
||||
code: otp,
|
||||
});
|
||||
|
||||
// Check status BEFORE verification
|
||||
console.log('📊 Status BEFORE verification:', {
|
||||
factors: factors,
|
||||
aalData: aalData,
|
||||
});
|
||||
|
||||
// Verify the challenge with the OTP code - this will automatically invalidate caches
|
||||
const verifyResponse = await verifyMutation.mutateAsync({
|
||||
factor_id: factorId,
|
||||
challenge_id: challengeId,
|
||||
code: otp,
|
||||
});
|
||||
|
||||
console.log('✅ Verification response:', verifyResponse);
|
||||
|
||||
// Store debug info to display
|
||||
setDebugInfo({
|
||||
verifyResponse,
|
||||
beforeFactors: factors,
|
||||
beforeAAL: aalData,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setSuccess('Phone number verified successfully!');
|
||||
|
||||
// Wait a bit for cache invalidation, then redirect
|
||||
setTimeout(() => {
|
||||
console.log('🔄 Redirecting after successful verification...');
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('❌ OTP verification failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
try {
|
||||
console.log('🔵 Resending code for factor:', factorId);
|
||||
|
||||
// Create a new challenge for the enrolled factor
|
||||
const challengeResponse = await challengeMutation.mutateAsync({
|
||||
factor_id: factorId,
|
||||
});
|
||||
|
||||
console.log('✅ Resend challenge response:', challengeResponse);
|
||||
|
||||
setChallengeId(challengeResponse.id);
|
||||
setSuccess('New verification code sent');
|
||||
} catch (err) {
|
||||
console.error('❌ Resend failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await signOut().catch(() => void 0);
|
||||
window.location.href = '/';
|
||||
},
|
||||
});
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOutMutation.mutate();
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
enrollMutation.isPending ||
|
||||
challengeMutation.isPending ||
|
||||
verifyMutation.isPending ||
|
||||
unenrollMutation.isPending;
|
||||
const error =
|
||||
enrollMutation.error?.message ||
|
||||
challengeMutation.error?.message ||
|
||||
verifyMutation.error?.message ||
|
||||
unenrollMutation.error?.message ||
|
||||
null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-4 relative">
|
||||
{/* Logout Button */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSignOut}
|
||||
disabled={signOutMutation.isPending}
|
||||
className="flex items-center gap-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{signOutMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-4 w-4" />
|
||||
)}
|
||||
<span className="hidden sm:inline">
|
||||
{signOutMutation.isPending ? 'Signing out...' : 'Sign out'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
{/* Debug Information */}
|
||||
{false && (factors || aalData || debugInfo) && (
|
||||
<div className="p-4 rounded-lg text-xs space-y-2">
|
||||
<h3 className="font-semibold">Debug Info:</h3>
|
||||
{aalData && (
|
||||
<div>
|
||||
<strong>AAL:</strong> {aalData.current_level} →{' '}
|
||||
{aalData.next_level}
|
||||
(action: {aalData.action_required})
|
||||
</div>
|
||||
)}
|
||||
{factors && (
|
||||
<div>
|
||||
<strong>Factors:</strong>{' '}
|
||||
{factors.factors
|
||||
?.map((f) => `${f.factor_type}:${f.status}:${f.id}`)
|
||||
.join(', ') || 'none'}
|
||||
</div>
|
||||
)}
|
||||
{debugInfo && (
|
||||
<div>
|
||||
<strong>Last Verification:</strong> {debugInfo.timestamp}
|
||||
<br />
|
||||
<strong>Response:</strong>{' '}
|
||||
{JSON.stringify(debugInfo.verifyResponse)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{success}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{step === 'phone' ? (
|
||||
<PhoneInput
|
||||
onSubmit={handlePhoneSubmit}
|
||||
isLoading={isLoading}
|
||||
error={null}
|
||||
/>
|
||||
) : (
|
||||
<OtpVerification
|
||||
phoneNumber={phoneNumber}
|
||||
onVerify={handleOtpVerify}
|
||||
onResend={handleResendCode}
|
||||
onSendCode={handleCreateChallengeForExistingFactor}
|
||||
onRemovePhone={handleUnenrollFactor}
|
||||
isLoading={isLoading}
|
||||
error={null}
|
||||
showExistingOptions={hasExistingFactor}
|
||||
challengeId={challengeId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -25,7 +25,7 @@ interface BillingModalProps {
|
|||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export function BillingModal({ open, onOpenChange, returnUrl = window?.location?.href || '/' }: BillingModalProps) {
|
||||
export function BillingModal({ open, onOpenChange, returnUrl = typeof window !== 'undefined' ? window?.location?.href || '/' : '/' }: BillingModalProps) {
|
||||
const { session, isLoading: authLoading } = useAuth();
|
||||
const [subscriptionData, setSubscriptionData] = useState<SubscriptionStatus | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
|
@ -5,11 +5,11 @@ import { useState, useEffect, useMemo } from 'react';
|
|||
import { isLocalMode } from '@/lib/config';
|
||||
import { useAvailableModels } from '@/hooks/react-query/subscriptions/use-model';
|
||||
|
||||
export const STORAGE_KEY_MODEL = 'suna-preferred-model-v2';
|
||||
export const STORAGE_KEY_MODEL = 'suna-preferred-model-v3';
|
||||
export const STORAGE_KEY_CUSTOM_MODELS = 'customModels';
|
||||
export const DEFAULT_PREMIUM_MODEL_ID = 'claude-sonnet-4';
|
||||
// export const DEFAULT_FREE_MODEL_ID = 'deepseek';
|
||||
export const DEFAULT_FREE_MODEL_ID = 'claude-sonnet-4';
|
||||
export const DEFAULT_FREE_MODEL_ID = 'moonshotai/kimi-k2';
|
||||
// export const DEFAULT_FREE_MODEL_ID = 'claude-sonnet-4';
|
||||
|
||||
export type SubscriptionStatus = 'no_subscription' | 'active';
|
||||
|
||||
|
@ -32,7 +32,7 @@ export interface CustomModel {
|
|||
export const MODELS = {
|
||||
// Free tier models (available to all users)
|
||||
'claude-sonnet-4': {
|
||||
tier: 'free',
|
||||
tier: 'premium',
|
||||
priority: 100,
|
||||
recommended: true,
|
||||
lowQuality: false
|
||||
|
@ -59,7 +59,7 @@ export const MODELS = {
|
|||
lowQuality: false
|
||||
},
|
||||
'grok-4': {
|
||||
tier: 'free',
|
||||
tier: 'premium',
|
||||
priority: 98,
|
||||
recommended: false,
|
||||
lowQuality: false
|
||||
|
|
|
@ -7,6 +7,7 @@ import React, {
|
|||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { handleFiles } from './file-upload-handler';
|
||||
import { MessageInput } from './message-input';
|
||||
|
@ -14,10 +15,17 @@ import { AttachmentGroup } from '../attachment-group';
|
|||
import { useModelSelection } from './_use-model-selection';
|
||||
import { useFileDelete } from '@/hooks/react-query/files';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { FloatingToolPreview, ToolCallInput } from './floating-tool-preview';
|
||||
import { Settings2, Sparkles, Brain, ChevronRight, Zap, Workflow, Database, Wrench } from 'lucide-react';
|
||||
import { ToolCallInput } from './floating-tool-preview';
|
||||
import { ChatSnack } from './chat-snack';
|
||||
import { Brain, Zap, Workflow, Database } from 'lucide-react';
|
||||
import { FaGoogle, FaDiscord } from 'react-icons/fa';
|
||||
import { SiNotion } from 'react-icons/si';
|
||||
import { AgentConfigModal } from '@/components/agents/agent-config-modal';
|
||||
import { PipedreamRegistry } from '@/components/agents/pipedream/pipedream-registry';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useSubscriptionWithStreaming } from '@/hooks/react-query/subscriptions/use-subscriptions';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { BillingModal } from '@/components/billing/billing-modal';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export interface ChatInputHandles {
|
||||
|
@ -54,6 +62,8 @@ export interface ChatInputProps {
|
|||
enableAdvancedConfig?: boolean;
|
||||
onConfigureAgent?: (agentId: string) => void;
|
||||
hideAgentSelection?: boolean;
|
||||
defaultShowSnackbar?: 'tokens' | 'upgrade' | false;
|
||||
showToLowCreditUsers?: boolean;
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
|
@ -64,6 +74,8 @@ export interface UploadedFile {
|
|||
localUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
||||
(
|
||||
{
|
||||
|
@ -92,11 +104,14 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
enableAdvancedConfig = false,
|
||||
onConfigureAgent,
|
||||
hideAgentSelection = false,
|
||||
defaultShowSnackbar = false,
|
||||
showToLowCreditUsers = true,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const isControlled =
|
||||
controlledValue !== undefined && controlledOnChange !== undefined;
|
||||
const router = useRouter();
|
||||
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState('');
|
||||
const value = isControlled ? controlledValue : uncontrolledValue;
|
||||
|
@ -105,7 +120,12 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const router = useRouter();
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [configModalTab, setConfigModalTab] = useState('integrations');
|
||||
const [registryDialogOpen, setRegistryDialogOpen] = useState(false);
|
||||
const [showSnackbar, setShowSnackbar] = useState(defaultShowSnackbar);
|
||||
const [userDismissedUsage, setUserDismissedUsage] = useState(false);
|
||||
const [billingModalOpen, setBillingModalOpen] = useState(false);
|
||||
|
||||
const {
|
||||
selectedModel,
|
||||
|
@ -117,9 +137,37 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
refreshCustomModels,
|
||||
} = useModelSelection();
|
||||
|
||||
const { data: subscriptionData } = useSubscriptionWithStreaming(isAgentRunning);
|
||||
const deleteFileMutation = useFileDelete();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Show usage preview logic:
|
||||
// - Always show to free users when showToLowCreditUsers is true
|
||||
// - For paid users, only show when they're at 70% or more of their cost limit (30% or below remaining)
|
||||
const shouldShowUsage = !isLocalMode() && subscriptionData && showToLowCreditUsers && (() => {
|
||||
// Free users: always show
|
||||
if (subscriptionStatus === 'no_subscription') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Paid users: only show when at 70% or more of cost limit
|
||||
const currentUsage = subscriptionData.current_usage || 0;
|
||||
const costLimit = subscriptionData.cost_limit || 0;
|
||||
|
||||
if (costLimit === 0) return false; // No limit set
|
||||
|
||||
return currentUsage >= (costLimit * 0.7); // 70% or more used (30% or less remaining)
|
||||
})();
|
||||
|
||||
// Auto-show usage preview when we have subscription data
|
||||
useEffect(() => {
|
||||
if (shouldShowUsage && defaultShowSnackbar !== false && !userDismissedUsage && (showSnackbar === false || showSnackbar === defaultShowSnackbar)) {
|
||||
setShowSnackbar('upgrade');
|
||||
} else if (!shouldShowUsage && showSnackbar !== false) {
|
||||
setShowSnackbar(false);
|
||||
}
|
||||
}, [subscriptionData, showSnackbar, defaultShowSnackbar, shouldShowUsage, subscriptionStatus, showToLowCreditUsers, userDismissedUsage]);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hasLoadedFromLocalStorage = useRef(false);
|
||||
|
@ -279,17 +327,25 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
setIsDraggingOver(false);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
<FloatingToolPreview
|
||||
<div className="mx-auto w-full max-w-4xl relative">
|
||||
<div className="relative">
|
||||
<ChatSnack
|
||||
toolCalls={toolCalls}
|
||||
currentIndex={toolCallIndex}
|
||||
onExpand={onExpandToolPreview || (() => { })}
|
||||
toolCallIndex={toolCallIndex}
|
||||
onExpandToolPreview={onExpandToolPreview}
|
||||
agentName={agentName}
|
||||
isVisible={showToolPreview}
|
||||
showToolPreview={showToolPreview}
|
||||
showUsagePreview={showSnackbar}
|
||||
subscriptionData={subscriptionData}
|
||||
onCloseUsage={() => { setShowSnackbar(false); setUserDismissedUsage(true); }}
|
||||
onOpenUpgrade={() => setBillingModalOpen(true)}
|
||||
isVisible={showToolPreview || !!showSnackbar}
|
||||
/>
|
||||
<Card
|
||||
className={`-mb-2 shadow-none w-full max-w-4xl mx-auto bg-transparent border-none overflow-hidden ${enableAdvancedConfig && selectedAgentId ? '' : 'rounded-3xl'}`}
|
||||
className={`-mb-2 shadow-none w-full max-w-4xl mx-auto bg-transparent border-none overflow-visible ${enableAdvancedConfig && selectedAgentId ? '' : 'rounded-3xl'} relative`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => {
|
||||
|
@ -310,6 +366,8 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
}
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||
<CardContent className={`w-full p-1.5 ${enableAdvancedConfig && selectedAgentId ? 'pb-1' : 'pb-2'} ${bgColor} border ${enableAdvancedConfig && selectedAgentId ? 'rounded-t-3xl' : 'rounded-3xl'}`}>
|
||||
<AttachmentGroup
|
||||
|
@ -424,6 +482,33 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
|||
)}
|
||||
</div>
|
||||
</Card>
|
||||
<AgentConfigModal
|
||||
isOpen={configModalOpen}
|
||||
onOpenChange={setConfigModalOpen}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentSelect={onAgentSelect}
|
||||
initialTab={configModalTab}
|
||||
/>
|
||||
<Dialog open={registryDialogOpen} onOpenChange={setRegistryDialogOpen}>
|
||||
<DialogContent className="p-0 max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Integrations</DialogTitle>
|
||||
</DialogHeader>
|
||||
<PipedreamRegistry
|
||||
showAgentSelector={true}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onAgentChange={onAgentSelect}
|
||||
onToolsSelected={(profileId, selectedTools, appName, appSlug) => {
|
||||
console.log('Tools selected:', { profileId, selectedTools, appName, appSlug });
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<BillingModal
|
||||
open={billingModalOpen}
|
||||
onOpenChange={setBillingModalOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -0,0 +1,182 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { UsagePreview } from './usage-preview';
|
||||
import { FloatingToolPreview, ToolCallInput } from './floating-tool-preview';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
|
||||
export interface ChatSnackProps {
|
||||
// Tool preview props
|
||||
toolCalls?: ToolCallInput[];
|
||||
toolCallIndex?: number;
|
||||
onExpandToolPreview?: () => void;
|
||||
agentName?: string;
|
||||
showToolPreview?: boolean;
|
||||
|
||||
// Usage preview props
|
||||
showUsagePreview?: 'tokens' | 'upgrade' | false;
|
||||
subscriptionData?: any;
|
||||
onCloseUsage?: () => void;
|
||||
onOpenUpgrade?: () => void;
|
||||
|
||||
// General props
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const SNACK_LAYOUT_ID = 'chat-snack-float';
|
||||
const SNACK_CONTENT_LAYOUT_ID = 'chat-snack-content';
|
||||
|
||||
export const ChatSnack: React.FC<ChatSnackProps> = ({
|
||||
toolCalls = [],
|
||||
toolCallIndex = 0,
|
||||
onExpandToolPreview,
|
||||
agentName,
|
||||
showToolPreview = false,
|
||||
showUsagePreview = false,
|
||||
subscriptionData,
|
||||
onCloseUsage,
|
||||
onOpenUpgrade,
|
||||
isVisible = false,
|
||||
}) => {
|
||||
const [currentView, setCurrentView] = React.useState(0);
|
||||
|
||||
// Determine what notifications we have - match exact rendering conditions
|
||||
const notifications = [];
|
||||
|
||||
// Tool notification: only if we have tool calls and showToolPreview is true
|
||||
if (showToolPreview && toolCalls.length > 0) {
|
||||
notifications.push('tool');
|
||||
}
|
||||
|
||||
// Usage notification: must match ALL rendering conditions
|
||||
if (showUsagePreview && !isLocalMode() && subscriptionData) {
|
||||
notifications.push('usage');
|
||||
}
|
||||
|
||||
|
||||
|
||||
const totalNotifications = notifications.length;
|
||||
const hasMultiple = totalNotifications > 1;
|
||||
|
||||
// Reset currentView when notifications change
|
||||
React.useEffect(() => {
|
||||
if (currentView >= totalNotifications && totalNotifications > 0) {
|
||||
setCurrentView(0);
|
||||
}
|
||||
}, [totalNotifications, currentView]);
|
||||
|
||||
// Auto-cycle through notifications
|
||||
React.useEffect(() => {
|
||||
if (!hasMultiple || !isVisible) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentView((prev) => (prev + 1) % totalNotifications);
|
||||
}, 20000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [hasMultiple, isVisible, totalNotifications, currentView]); // Reset timer when currentView changes
|
||||
|
||||
if (!isVisible || totalNotifications === 0) return null;
|
||||
|
||||
const currentNotification = notifications[currentView];
|
||||
|
||||
const renderContent = () => {
|
||||
if (currentNotification === 'tool' && showToolPreview) {
|
||||
return (
|
||||
<FloatingToolPreview
|
||||
toolCalls={toolCalls}
|
||||
currentIndex={toolCallIndex}
|
||||
onExpand={onExpandToolPreview || (() => { })}
|
||||
agentName={agentName}
|
||||
isVisible={true}
|
||||
showIndicators={hasMultiple}
|
||||
indicatorIndex={currentView}
|
||||
indicatorTotal={totalNotifications}
|
||||
onIndicatorClick={(index) => setCurrentView(index)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentNotification === 'usage' && showUsagePreview && !isLocalMode()) {
|
||||
return (
|
||||
<motion.div
|
||||
layoutId={SNACK_LAYOUT_ID}
|
||||
layout
|
||||
transition={{
|
||||
layout: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30
|
||||
}
|
||||
}}
|
||||
className="-mb-4 w-full"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<motion.div
|
||||
layoutId={SNACK_CONTENT_LAYOUT_ID}
|
||||
className={cn(
|
||||
"bg-card border border-border rounded-3xl p-2 w-full transition-all duration-200",
|
||||
onOpenUpgrade && "cursor-pointer hover:shadow-md"
|
||||
)}
|
||||
whileHover={onOpenUpgrade ? { scale: 1.02 } : undefined}
|
||||
whileTap={onOpenUpgrade ? { scale: 0.98 } : undefined}
|
||||
onClick={(e) => {
|
||||
// Don't trigger if clicking on indicators or close button
|
||||
const target = e.target as HTMLElement;
|
||||
const isIndicatorClick = target.closest('[data-indicator-click]');
|
||||
const isCloseClick = target.closest('[data-close-click]');
|
||||
|
||||
if (!isIndicatorClick && !isCloseClick && onOpenUpgrade) {
|
||||
onOpenUpgrade();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UsagePreview
|
||||
type={showUsagePreview}
|
||||
subscriptionData={subscriptionData}
|
||||
onClose={() => {
|
||||
// First close the usage notification
|
||||
if (onCloseUsage) onCloseUsage();
|
||||
|
||||
// Check what notifications will remain after closing usage
|
||||
const willHaveToolNotification = showToolPreview && toolCalls.length > 0;
|
||||
|
||||
// If there will be other notifications, switch to them
|
||||
if (willHaveToolNotification) {
|
||||
setCurrentView(0); // Switch to tool notification
|
||||
}
|
||||
}}
|
||||
hasMultiple={hasMultiple}
|
||||
showIndicators={hasMultiple}
|
||||
currentIndex={currentView}
|
||||
totalCount={totalNotifications}
|
||||
onIndicatorClick={(index) => setCurrentView(index)}
|
||||
onOpenUpgrade={onOpenUpgrade}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
key={currentNotification}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
>
|
||||
{renderContent()}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
|
@ -25,6 +25,11 @@ interface FloatingToolPreviewProps {
|
|||
onExpand: () => void;
|
||||
agentName?: string;
|
||||
isVisible: boolean;
|
||||
// Indicators for multiple notification types (not tool calls)
|
||||
showIndicators?: boolean;
|
||||
indicatorIndex?: number;
|
||||
indicatorTotal?: number;
|
||||
onIndicatorClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
const FLOATING_LAYOUT_ID = 'tool-panel-float';
|
||||
|
@ -61,6 +66,10 @@ export const FloatingToolPreview: React.FC<FloatingToolPreviewProps> = ({
|
|||
onExpand,
|
||||
agentName,
|
||||
isVisible,
|
||||
showIndicators = false,
|
||||
indicatorIndex = 0,
|
||||
indicatorTotal = 1,
|
||||
onIndicatorClick,
|
||||
}) => {
|
||||
const [isExpanding, setIsExpanding] = React.useState(false);
|
||||
const currentToolCall = toolCalls[currentIndex];
|
||||
|
@ -137,11 +146,6 @@ export const FloatingToolPreview: React.FC<FloatingToolPreviewProps> = ({
|
|||
<h4 className="text-sm font-medium text-foreground truncate">
|
||||
{getUserFriendlyToolName(toolName)}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{currentIndex + 1}/{totalCalls}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div layoutId="tool-status" className="flex items-center gap-2">
|
||||
|
@ -164,6 +168,32 @@ export const FloatingToolPreview: React.FC<FloatingToolPreviewProps> = ({
|
|||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Apple-style notification indicators - only for multiple notification types */}
|
||||
{showIndicators && indicatorTotal === 2 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent tool expansion
|
||||
// Toggle between the two notifications (binary switch)
|
||||
const nextIndex = indicatorIndex === 0 ? 1 : 0;
|
||||
onIndicatorClick?.(nextIndex);
|
||||
}}
|
||||
className="flex items-center gap-1.5 mr-3 px-2 py-1.5 rounded-lg hover:bg-muted/30 transition-colors"
|
||||
style={{ opacity: isExpanding ? 0 : 1 }}
|
||||
>
|
||||
{Array.from({ length: indicatorTotal }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-out rounded-full",
|
||||
index === indicatorIndex
|
||||
? "w-6 h-2 bg-foreground"
|
||||
: "w-3 h-2 bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Button value='ghost' className="bg-transparent hover:bg-transparent flex-shrink-0" style={{ opacity: isExpanding ? 0 : 1 }}>
|
||||
<Maximize2 className="h-4 w-4 text-muted-foreground group-hover:text-foreground transition-colors" />
|
||||
</Button>
|
||||
|
|
|
@ -223,7 +223,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
|
||||
</div>
|
||||
|
||||
{subscriptionStatus === 'no_subscription' && !isLocalMode() &&
|
||||
{/* {subscriptionStatus === 'no_subscription' && !isLocalMode() &&
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
|
@ -234,7 +234,7 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
} */}
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
{renderDropdown()}
|
||||
|
@ -277,13 +277,13 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{subscriptionStatus === 'no_subscription' && !isLocalMode() &&
|
||||
{/* {subscriptionStatus === 'no_subscription' && !isLocalMode() &&
|
||||
<div className='sm:hidden absolute -bottom-8 left-0 right-0 flex justify-center'>
|
||||
<p className='text-xs text-amber-500 px-2 py-1'>
|
||||
Upgrade for better performance
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
} */}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -599,8 +599,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
|||
{/* Premium Models Section */}
|
||||
<div className="mt-4 border-t border-border pt-2">
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-blue-500 flex items-center">
|
||||
{/* <Crown className="h-3.5 w-3.5 mr-1.5" /> */}
|
||||
Additional Models
|
||||
<Crown className="h-3.5 w-3.5 mr-1.5" />
|
||||
Premium Models
|
||||
</div>
|
||||
|
||||
{/* Premium models container with paywall overlay */}
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { X, Zap } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isLocalMode } from '@/lib/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface UsagePreviewProps {
|
||||
type: 'tokens' | 'upgrade';
|
||||
subscriptionData?: any;
|
||||
onClose?: () => void;
|
||||
onOpenUpgrade?: () => void;
|
||||
hasMultiple?: boolean;
|
||||
showIndicators?: boolean;
|
||||
currentIndex?: number;
|
||||
totalCount?: number;
|
||||
onIndicatorClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
export const UsagePreview: React.FC<UsagePreviewProps> = ({
|
||||
type,
|
||||
subscriptionData,
|
||||
onClose,
|
||||
onOpenUpgrade,
|
||||
hasMultiple = false,
|
||||
showIndicators = false,
|
||||
currentIndex = 0,
|
||||
totalCount = 1,
|
||||
onIndicatorClick,
|
||||
}) => {
|
||||
if (isLocalMode()) return null;
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return `$${amount.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const getUsageDisplay = () => {
|
||||
if (!subscriptionData) return 'Loading usage...';
|
||||
|
||||
const current = subscriptionData.current_usage || 0;
|
||||
const limit = subscriptionData.cost_limit || 0;
|
||||
|
||||
if (limit === 0) return 'No usage limit set';
|
||||
|
||||
const isOverLimit = current > limit;
|
||||
const usageText = `${formatCurrency(current)} / ${formatCurrency(limit)}`;
|
||||
|
||||
if (isOverLimit) {
|
||||
return `${usageText} (over limit)`;
|
||||
}
|
||||
|
||||
return usageText;
|
||||
};
|
||||
|
||||
const isOverLimit = () => {
|
||||
if (!subscriptionData) return false;
|
||||
const current = subscriptionData.current_usage || 0;
|
||||
const limit = subscriptionData.cost_limit || 0;
|
||||
return current > limit;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<motion.div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-2xl flex items-center justify-center",
|
||||
isOverLimit()
|
||||
? "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
||||
: "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800"
|
||||
)}
|
||||
>
|
||||
<Zap className={cn(
|
||||
"h-5 w-5",
|
||||
isOverLimit()
|
||||
? "text-red-500 dark:text-red-400"
|
||||
: "text-blue-500 dark:text-blue-400"
|
||||
)} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<motion.div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-foreground truncate">
|
||||
Upgrade for more usage & better AI Models
|
||||
</h4>
|
||||
</motion.div>
|
||||
|
||||
<motion.div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
isOverLimit() ? "bg-red-500" : "bg-blue-500"
|
||||
)} />
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{getUsageDisplay()}
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Apple-style notification indicators - only for multiple notification types */}
|
||||
{showIndicators && totalCount === 2 && (
|
||||
<button
|
||||
data-indicator-click
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const nextIndex = currentIndex === 0 ? 1 : 0;
|
||||
onIndicatorClick?.(nextIndex);
|
||||
}}
|
||||
className="flex items-center gap-1.5 mr-3 px-2 py-1.5 rounded-lg hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
{Array.from({ length: totalCount }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-out rounded-full",
|
||||
index === currentIndex
|
||||
? "w-6 h-2 bg-foreground"
|
||||
: "w-3 h-2 bg-muted-foreground/40"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 flex-shrink-0 hover:bg-muted/50" onClick={(e) => { e.stopPropagation(); onClose?.(); }}>
|
||||
<X className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -150,6 +150,22 @@ export function renderMarkdownContent(
|
|||
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
|
||||
</div>
|
||||
);
|
||||
} else if (toolName === 'complete') {
|
||||
// Handle complete tool specially - extract text and attachments
|
||||
const completeText = toolCall.parameters.text || '';
|
||||
const attachments = toolCall.parameters.attachments || '';
|
||||
|
||||
// Convert single attachment to array for consistent handling
|
||||
const attachmentArray = Array.isArray(attachments) ? attachments :
|
||||
(typeof attachments === 'string' ? attachments.split(',').map(a => a.trim()) : []);
|
||||
|
||||
// Render complete tool content with attachment UI
|
||||
contentParts.push(
|
||||
<div key={`complete-${match.index}-${index}`} className="space-y-3">
|
||||
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{completeText}</Markdown>
|
||||
{renderAttachments(attachmentArray, fileViewerHandler, sandboxId, project)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const IconComponent = getToolIcon(toolName);
|
||||
|
||||
|
@ -242,6 +258,24 @@ export function renderMarkdownContent(
|
|||
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
|
||||
</div>
|
||||
);
|
||||
} else if (toolName === 'complete') {
|
||||
// Extract attachments from the XML attributes
|
||||
const attachmentsMatch = rawXml.match(/attachments=["']([^"']*)["']/i);
|
||||
const attachments = attachmentsMatch
|
||||
? attachmentsMatch[1].split(',').map(a => a.trim())
|
||||
: [];
|
||||
|
||||
// Extract content from the complete tag
|
||||
const contentMatch = rawXml.match(/<complete[^>]*>([\s\S]*?)<\/complete>/i);
|
||||
const completeContent = contentMatch ? contentMatch[1] : '';
|
||||
|
||||
// Render <complete> tag content with attachment UI (using the helper)
|
||||
contentParts.push(
|
||||
<div key={`complete-${match.index}`} className="space-y-3">
|
||||
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none break-words [&>:first-child]:mt-0 prose-headings:mt-3">{completeContent}</Markdown>
|
||||
{renderAttachments(attachments, fileViewerHandler, sandboxId, project)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
const IconComponent = getToolIcon(toolName);
|
||||
const paramDisplay = extractPrimaryParam(toolName, rawXml);
|
||||
|
|
|
@ -18,12 +18,14 @@ import {
|
|||
extractToolData,
|
||||
getFileIconAndColor,
|
||||
} from './utils';
|
||||
import { extractCompleteData } from './complete-tool/_utils';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import { FileAttachment } from '../file-attachment';
|
||||
|
||||
interface CompleteContent {
|
||||
summary?: string;
|
||||
|
@ -46,10 +48,26 @@ export function CompleteToolView({
|
|||
isSuccess = true,
|
||||
isStreaming = false,
|
||||
onFileClick,
|
||||
project,
|
||||
}: CompleteToolViewProps) {
|
||||
const [completeData, setCompleteData] = useState<CompleteContent>({});
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const {
|
||||
text,
|
||||
attachments,
|
||||
status,
|
||||
actualIsSuccess,
|
||||
actualToolTimestamp,
|
||||
actualAssistantTimestamp
|
||||
} = extractCompleteData(
|
||||
assistantContent,
|
||||
toolContent,
|
||||
isSuccess,
|
||||
toolTimestamp,
|
||||
assistantTimestamp
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (assistantContent) {
|
||||
try {
|
||||
|
@ -120,6 +138,16 @@ export function CompleteToolView({
|
|||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
const isImageFile = (filePath: string): boolean => {
|
||||
const filename = filePath.split('/').pop() || '';
|
||||
return filename.match(/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i) !== null;
|
||||
};
|
||||
|
||||
const isPreviewableFile = (filePath: string): boolean => {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() || '';
|
||||
return ext === 'html' || ext === 'htm' || ext === 'md' || ext === 'markdown' || ext === 'csv' || ext === 'tsv';
|
||||
};
|
||||
|
||||
const toolTitle = getToolTitle(name) || 'Task Complete';
|
||||
|
||||
const handleFileClick = (filePath: string) => {
|
||||
|
@ -147,17 +175,17 @@ export function CompleteToolView({
|
|||
<Badge
|
||||
variant="secondary"
|
||||
className={
|
||||
isSuccess
|
||||
actualIsSuccess
|
||||
? "bg-gradient-to-b from-emerald-200 to-emerald-100 text-emerald-700 dark:from-emerald-800/50 dark:to-emerald-900/60 dark:text-emerald-300"
|
||||
: "bg-gradient-to-b from-rose-200 to-rose-100 text-rose-700 dark:from-rose-800/50 dark:to-rose-900/60 dark:text-rose-300"
|
||||
}
|
||||
>
|
||||
{isSuccess ? (
|
||||
{actualIsSuccess ? (
|
||||
<CheckCircle className="h-3.5 w-3.5 mr-1" />
|
||||
) : (
|
||||
<AlertTriangle className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{isSuccess ? 'Completed' : 'Failed'}
|
||||
{actualIsSuccess ? 'Completed' : 'Failed'}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
|
@ -173,8 +201,8 @@ export function CompleteToolView({
|
|||
<CardContent className="p-0 flex-1 overflow-hidden relative">
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Success Animation/Icon - Only show when completed successfully */}
|
||||
{!isStreaming && isSuccess && !completeData.summary && !completeData.tasksCompleted && !completeData.attachments && (
|
||||
{/* Success Animation/Icon - Only show when completed successfully and no text/attachments */}
|
||||
{!isStreaming && actualIsSuccess && !text && !attachments && !completeData.summary && !completeData.tasksCompleted && (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-100 to-emerald-200 dark:from-emerald-800/40 dark:to-emerald-900/60 flex items-center justify-center">
|
||||
|
@ -187,19 +215,95 @@ export function CompleteToolView({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary Section */}
|
||||
{completeData.summary && (
|
||||
{/* Text/Summary Section */}
|
||||
{(text || completeData.summary || completeData.result) && (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-muted/50 rounded-2xl p-4 border border-border">
|
||||
<Markdown className="text-sm prose prose-sm dark:prose-invert chat-markdown max-w-none [&>:first-child]:mt-0 prose-headings:mt-3">
|
||||
{completeData.summary}
|
||||
{text || completeData.summary || completeData.result}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachments Section */}
|
||||
{completeData.attachments && completeData.attachments.length > 0 && (
|
||||
{attachments && attachments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
Files ({attachments.length})
|
||||
</div>
|
||||
|
||||
<div className={cn(
|
||||
"grid gap-3",
|
||||
attachments.length === 1 ? "grid-cols-1" :
|
||||
attachments.length > 4 ? "grid-cols-1 sm:grid-cols-2 md:grid-cols-3" :
|
||||
"grid-cols-1 sm:grid-cols-2"
|
||||
)}>
|
||||
{attachments
|
||||
.sort((a, b) => {
|
||||
const aIsImage = isImageFile(a);
|
||||
const bIsImage = isImageFile(b);
|
||||
const aIsPreviewable = isPreviewableFile(a);
|
||||
const bIsPreviewable = isPreviewableFile(b);
|
||||
|
||||
if (aIsImage && !bIsImage) return -1;
|
||||
if (!aIsImage && bIsImage) return 1;
|
||||
if (aIsPreviewable && !bIsPreviewable) return -1;
|
||||
if (!aIsPreviewable && bIsPreviewable) return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((attachment, index) => {
|
||||
const isImage = isImageFile(attachment);
|
||||
const isPreviewable = isPreviewableFile(attachment);
|
||||
const shouldSpanFull = (attachments!.length % 2 === 1 &&
|
||||
attachments!.length > 1 &&
|
||||
index === attachments!.length - 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"relative group",
|
||||
isImage ? "flex items-center justify-center h-full" : "",
|
||||
isPreviewable ? "w-full" : ""
|
||||
)}
|
||||
style={(shouldSpanFull || isPreviewable) ? { gridColumn: '1 / -1' } : undefined}
|
||||
>
|
||||
<FileAttachment
|
||||
filepath={attachment}
|
||||
onClick={handleFileClick}
|
||||
sandboxId={project?.sandbox?.id}
|
||||
showPreview={true}
|
||||
className={cn(
|
||||
"w-full",
|
||||
isImage ? "h-auto min-h-[54px]" :
|
||||
isPreviewable ? "min-h-[240px] max-h-[400px] overflow-auto" : "h-[54px]"
|
||||
)}
|
||||
customStyle={
|
||||
isImage ? {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
'--attachment-height': shouldSpanFull ? '240px' : '180px'
|
||||
} as React.CSSProperties :
|
||||
isPreviewable ? {
|
||||
gridColumn: '1 / -1'
|
||||
} :
|
||||
shouldSpanFull ? {
|
||||
gridColumn: '1 / -1'
|
||||
} : {
|
||||
width: '100%'
|
||||
}
|
||||
}
|
||||
collapsed={false}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : completeData.attachments && completeData.attachments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
|
@ -243,7 +347,7 @@ export function CompleteToolView({
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Tasks Completed Section */}
|
||||
{completeData.tasksCompleted && completeData.tasksCompleted.length > 0 && (
|
||||
|
@ -288,7 +392,7 @@ export function CompleteToolView({
|
|||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!completeData.summary && !completeData.result && !completeData.attachments && !completeData.tasksCompleted && !isStreaming && (
|
||||
{!text && !attachments && !completeData.summary && !completeData.result && !completeData.attachments && !completeData.tasksCompleted && !isStreaming && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<CheckCircle2 className="h-8 w-8 text-muted-foreground" />
|
||||
|
|
|
@ -0,0 +1,234 @@
|
|||
import { extractToolData, normalizeContentToString } from '../utils';
|
||||
|
||||
export interface CompleteData {
|
||||
text: string | null;
|
||||
attachments: string[] | null;
|
||||
status: string | null;
|
||||
success?: boolean;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
const parseContent = (content: any): any => {
|
||||
if (typeof content === 'string') {
|
||||
try {
|
||||
return JSON.parse(content);
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
};
|
||||
|
||||
const extractFromNewFormat = (content: any): {
|
||||
text: string | null;
|
||||
attachments: string[] | null;
|
||||
status: string | null;
|
||||
success?: boolean;
|
||||
timestamp?: string;
|
||||
} => {
|
||||
const parsedContent = parseContent(content);
|
||||
|
||||
if (!parsedContent || typeof parsedContent !== 'object') {
|
||||
return { text: null, attachments: null, status: null, success: undefined, timestamp: undefined };
|
||||
}
|
||||
|
||||
if ('tool_execution' in parsedContent && typeof parsedContent.tool_execution === 'object') {
|
||||
const toolExecution = parsedContent.tool_execution;
|
||||
const args = toolExecution.arguments || {};
|
||||
|
||||
let parsedOutput = toolExecution.result?.output;
|
||||
if (typeof parsedOutput === 'string') {
|
||||
try {
|
||||
parsedOutput = JSON.parse(parsedOutput);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
let attachments: string[] | null = null;
|
||||
if (args.attachments) {
|
||||
if (typeof args.attachments === 'string') {
|
||||
attachments = args.attachments.split(',').map((a: string) => a.trim()).filter((a: string) => a.length > 0);
|
||||
} else if (Array.isArray(args.attachments)) {
|
||||
attachments = args.attachments;
|
||||
}
|
||||
}
|
||||
|
||||
let status: string | null = null;
|
||||
if (parsedOutput && typeof parsedOutput === 'object' && parsedOutput.status) {
|
||||
status = parsedOutput.status;
|
||||
}
|
||||
|
||||
const extractedData = {
|
||||
text: args.text || null,
|
||||
attachments,
|
||||
status: status || parsedContent.summary || null,
|
||||
success: toolExecution.result?.success,
|
||||
timestamp: toolExecution.execution_details?.timestamp
|
||||
};
|
||||
|
||||
console.log('CompleteToolView: Extracted from new format:', {
|
||||
hasText: !!extractedData.text,
|
||||
attachmentCount: extractedData.attachments?.length || 0,
|
||||
hasStatus: !!extractedData.status,
|
||||
success: extractedData.success
|
||||
});
|
||||
|
||||
return extractedData;
|
||||
}
|
||||
|
||||
if ('role' in parsedContent && 'content' in parsedContent) {
|
||||
return extractFromNewFormat(parsedContent.content);
|
||||
}
|
||||
|
||||
return { text: null, attachments: null, status: null, success: undefined, timestamp: undefined };
|
||||
};
|
||||
|
||||
const extractFromLegacyFormat = (content: any): {
|
||||
text: string | null;
|
||||
attachments: string[] | null;
|
||||
status: string | null;
|
||||
} => {
|
||||
const toolData = extractToolData(content);
|
||||
|
||||
if (toolData.toolResult && toolData.arguments) {
|
||||
console.log('CompleteToolView: Extracted from legacy format (extractToolData):', {
|
||||
hasText: !!toolData.arguments.text,
|
||||
attachmentCount: toolData.arguments.attachments ?
|
||||
(Array.isArray(toolData.arguments.attachments) ? toolData.arguments.attachments.length : 1) : 0
|
||||
});
|
||||
|
||||
let attachments: string[] | null = null;
|
||||
if (toolData.arguments.attachments) {
|
||||
if (Array.isArray(toolData.arguments.attachments)) {
|
||||
attachments = toolData.arguments.attachments;
|
||||
} else if (typeof toolData.arguments.attachments === 'string') {
|
||||
attachments = toolData.arguments.attachments.split(',').map(a => a.trim()).filter(a => a.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: toolData.arguments.text || null,
|
||||
attachments,
|
||||
status: null
|
||||
};
|
||||
}
|
||||
|
||||
const contentStr = normalizeContentToString(content);
|
||||
if (!contentStr) {
|
||||
return { text: null, attachments: null, status: null };
|
||||
}
|
||||
|
||||
let attachments: string[] | null = null;
|
||||
const attachmentsMatch = contentStr.match(/attachments=["']([^"']*)["']/i);
|
||||
if (attachmentsMatch) {
|
||||
attachments = attachmentsMatch[1].split(',').map(a => a.trim()).filter(a => a.length > 0);
|
||||
}
|
||||
|
||||
let text: string | null = null;
|
||||
const textMatch = contentStr.match(/<complete[^>]*>([^<]*)<\/complete>/i);
|
||||
if (textMatch) {
|
||||
text = textMatch[1].trim();
|
||||
}
|
||||
|
||||
console.log('CompleteToolView: Extracted from legacy format (manual parsing):', {
|
||||
hasText: !!text,
|
||||
attachmentCount: attachments?.length || 0
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
attachments,
|
||||
status: null
|
||||
};
|
||||
};
|
||||
|
||||
export function extractCompleteData(
|
||||
assistantContent: any,
|
||||
toolContent: any,
|
||||
isSuccess: boolean,
|
||||
toolTimestamp?: string,
|
||||
assistantTimestamp?: string
|
||||
): {
|
||||
text: string | null;
|
||||
attachments: string[] | null;
|
||||
status: string | null;
|
||||
actualIsSuccess: boolean;
|
||||
actualToolTimestamp?: string;
|
||||
actualAssistantTimestamp?: string;
|
||||
} {
|
||||
let text: string | null = null;
|
||||
let attachments: string[] | null = null;
|
||||
let status: string | null = null;
|
||||
let actualIsSuccess = isSuccess;
|
||||
let actualToolTimestamp = toolTimestamp;
|
||||
let actualAssistantTimestamp = assistantTimestamp;
|
||||
|
||||
const assistantNewFormat = extractFromNewFormat(assistantContent);
|
||||
const toolNewFormat = extractFromNewFormat(toolContent);
|
||||
|
||||
console.log('CompleteToolView: Format detection results:', {
|
||||
assistantNewFormat: {
|
||||
hasText: !!assistantNewFormat.text,
|
||||
attachmentCount: assistantNewFormat.attachments?.length || 0,
|
||||
hasStatus: !!assistantNewFormat.status
|
||||
},
|
||||
toolNewFormat: {
|
||||
hasText: !!toolNewFormat.text,
|
||||
attachmentCount: toolNewFormat.attachments?.length || 0,
|
||||
hasStatus: !!toolNewFormat.status
|
||||
}
|
||||
});
|
||||
|
||||
if (assistantNewFormat.text || assistantNewFormat.attachments || assistantNewFormat.status) {
|
||||
text = assistantNewFormat.text;
|
||||
attachments = assistantNewFormat.attachments;
|
||||
status = assistantNewFormat.status;
|
||||
if (assistantNewFormat.success !== undefined) {
|
||||
actualIsSuccess = assistantNewFormat.success;
|
||||
}
|
||||
if (assistantNewFormat.timestamp) {
|
||||
actualAssistantTimestamp = assistantNewFormat.timestamp;
|
||||
}
|
||||
console.log('CompleteToolView: Using assistant new format data');
|
||||
} else if (toolNewFormat.text || toolNewFormat.attachments || toolNewFormat.status) {
|
||||
text = toolNewFormat.text;
|
||||
attachments = toolNewFormat.attachments;
|
||||
status = toolNewFormat.status;
|
||||
if (toolNewFormat.success !== undefined) {
|
||||
actualIsSuccess = toolNewFormat.success;
|
||||
}
|
||||
if (toolNewFormat.timestamp) {
|
||||
actualToolTimestamp = toolNewFormat.timestamp;
|
||||
}
|
||||
console.log('CompleteToolView: Using tool new format data');
|
||||
} else {
|
||||
const assistantLegacy = extractFromLegacyFormat(assistantContent);
|
||||
const toolLegacy = extractFromLegacyFormat(toolContent);
|
||||
|
||||
text = assistantLegacy.text || toolLegacy.text;
|
||||
attachments = assistantLegacy.attachments || toolLegacy.attachments;
|
||||
status = assistantLegacy.status || toolLegacy.status;
|
||||
|
||||
console.log('CompleteToolView: Using legacy format data:', {
|
||||
hasText: !!text,
|
||||
attachmentCount: attachments?.length || 0,
|
||||
hasStatus: !!status
|
||||
});
|
||||
}
|
||||
|
||||
console.log('CompleteToolView: Final extracted data:', {
|
||||
hasText: !!text,
|
||||
attachmentCount: attachments?.length || 0,
|
||||
hasStatus: !!status,
|
||||
actualIsSuccess
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
attachments,
|
||||
status,
|
||||
actualIsSuccess,
|
||||
actualToolTimestamp,
|
||||
actualAssistantTimestamp
|
||||
};
|
||||
}
|
|
@ -0,0 +1,212 @@
|
|||
import * as React from "react";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import * as RPNInput from "react-phone-number-input";
|
||||
import flags from "react-phone-number-input/flags";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PhoneInputProps = Omit<
|
||||
React.ComponentProps<"input">,
|
||||
"onChange" | "value" | "ref"
|
||||
> &
|
||||
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
|
||||
onChange?: (value: RPNInput.Value) => void;
|
||||
};
|
||||
|
||||
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
|
||||
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
|
||||
({ className, onChange, value, ...props }, ref) => {
|
||||
return (
|
||||
<RPNInput.default
|
||||
ref={ref}
|
||||
className={cn("flex", className)}
|
||||
flagComponent={FlagComponent}
|
||||
countrySelectComponent={CountrySelect}
|
||||
inputComponent={InputComponent}
|
||||
smartCaret={false}
|
||||
value={value || undefined}
|
||||
/**
|
||||
* Handles the onChange event.
|
||||
*
|
||||
* react-phone-number-input might trigger the onChange event as undefined
|
||||
* when a valid phone number is not entered. To prevent this,
|
||||
* the value is coerced to an empty string.
|
||||
*
|
||||
* @param {E164Number | undefined} value - The entered value
|
||||
*/
|
||||
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PhoneInput.displayName = "PhoneInput";
|
||||
|
||||
const InputComponent = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<Input
|
||||
className={cn("rounded-e-lg rounded-s-none", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
|
||||
InputComponent.displayName = "InputComponent";
|
||||
|
||||
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
|
||||
|
||||
type CountrySelectProps = {
|
||||
disabled?: boolean;
|
||||
value: RPNInput.Country;
|
||||
options: CountryEntry[];
|
||||
onChange: (country: RPNInput.Country) => void;
|
||||
};
|
||||
|
||||
const CountrySelect = ({
|
||||
disabled,
|
||||
value: selectedCountry,
|
||||
options: countryList,
|
||||
onChange,
|
||||
}: CountrySelectProps) => {
|
||||
const scrollAreaRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
modal
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn("flex gap-1 rounded-e-none rounded-s-lg px-3", {
|
||||
"opacity-50 cursor-not-allowed": disabled,
|
||||
})}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FlagComponent
|
||||
country={selectedCountry}
|
||||
countryName={selectedCountry}
|
||||
/>
|
||||
<ChevronsUpDown
|
||||
className={cn(
|
||||
"h-4 w-4 opacity-50",
|
||||
disabled ? "hidden" : "flex",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command
|
||||
filter={(value, search) => {
|
||||
const searchLower = search.toLowerCase();
|
||||
const valueLower = value.toLowerCase();
|
||||
|
||||
// Check if search matches country name, calling code, or country code
|
||||
if (valueLower.includes(searchLower)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search country..."
|
||||
/>
|
||||
<CommandList>
|
||||
<ScrollArea ref={scrollAreaRef} className="h-72">
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryList.map(({ value, label }) =>
|
||||
value ? (
|
||||
<CountrySelectOption
|
||||
key={value}
|
||||
country={value}
|
||||
countryName={label}
|
||||
selectedCountry={selectedCountry}
|
||||
onChange={onChange}
|
||||
onSelectComplete={() => setIsOpen(false)}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface CountrySelectOptionProps {
|
||||
country: RPNInput.Country;
|
||||
countryName: string;
|
||||
selectedCountry: RPNInput.Country;
|
||||
onChange: (country: RPNInput.Country) => void;
|
||||
onSelectComplete: () => void;
|
||||
}
|
||||
|
||||
const CountrySelectOption = ({
|
||||
country,
|
||||
countryName,
|
||||
selectedCountry,
|
||||
onChange,
|
||||
onSelectComplete,
|
||||
}: CountrySelectOptionProps) => {
|
||||
const handleSelect = () => {
|
||||
onChange(country);
|
||||
onSelectComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className="gap-2"
|
||||
onSelect={handleSelect}
|
||||
value={`${countryName} +${RPNInput.getCountryCallingCode(country)} ${country}`}
|
||||
>
|
||||
<FlagComponent country={country} countryName={countryName} />
|
||||
<span className="flex-1 text-sm">{countryName}</span>
|
||||
<span className="text-sm text-foreground/50">
|
||||
{`+${RPNInput.getCountryCallingCode(country)}`}
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={`ml-auto size-4 ${
|
||||
country === selectedCountry ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const FlagComponent = ({ country, countryName }: { country: RPNInput.Country; countryName?: string }) => {
|
||||
const Flag = flags[country];
|
||||
|
||||
return (
|
||||
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20 [&_svg:not([class*='size-'])]:size-full">
|
||||
{Flag && <Flag title={countryName} />}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export { PhoneInput };
|
|
@ -23,6 +23,7 @@ function ScrollArea({
|
|||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { phoneVerificationService } from '@/lib/api/phone-verification';
|
||||
import { useAuth } from '@/components/AuthProvider';
|
||||
|
||||
export const useEnrollPhoneNumber = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: phoneVerificationService.enrollPhoneNumber,
|
||||
onSuccess: () => {
|
||||
// Invalidate factors list after enrollment
|
||||
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateChallenge = () => {
|
||||
return useMutation({
|
||||
mutationFn: phoneVerificationService.createChallenge,
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyChallenge = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: phoneVerificationService.verifyChallenge,
|
||||
onSuccess: () => {
|
||||
// Invalidate all phone verification related caches after successful verification
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useChallengeAndVerify = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: phoneVerificationService.challengeAndVerify,
|
||||
onSuccess: () => {
|
||||
// Invalidate all phone verification related caches after successful verification
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useListFactors = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['phone-verification-factors'],
|
||||
queryFn: phoneVerificationService.listFactors,
|
||||
enabled: !!user, // Only run when user is authenticated
|
||||
staleTime: Infinity, // 2 minutes
|
||||
retry: 2,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnenrollFactor = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: phoneVerificationService.unenrollFactor,
|
||||
onSuccess: () => {
|
||||
// Invalidate caches after unenrolling
|
||||
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const useUnenrollPhoneFactor = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: phoneVerificationService.unenrollFactor,
|
||||
onSuccess: () => {
|
||||
// Invalidate caches after unenrolling
|
||||
queryClient.invalidateQueries({ queryKey: ['phone-verification-factors'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mfa-aal'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const useGetAAL = () => {
|
||||
const { user } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['mfa-aal'],
|
||||
queryFn: phoneVerificationService.getAAL,
|
||||
enabled: !!user, // Only run when user is authenticated
|
||||
staleTime: Infinity, // 1 minute
|
||||
retry: 2,
|
||||
});
|
||||
};
|
|
@ -7,6 +7,8 @@ import {
|
|||
SubscriptionStatus,
|
||||
} from '@/lib/api';
|
||||
import { subscriptionKeys } from './keys';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useSubscription = createQueryHook(
|
||||
subscriptionKeys.details(),
|
||||
|
@ -17,6 +19,39 @@ export const useSubscription = createQueryHook(
|
|||
},
|
||||
);
|
||||
|
||||
// Smart subscription hook that adapts refresh based on streaming state
|
||||
export const useSubscriptionWithStreaming = (isStreaming: boolean = false) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
// Track page visibility
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setIsVisible(!document.hidden);
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, []);
|
||||
|
||||
return useQuery({
|
||||
queryKey: subscriptionKeys.details(),
|
||||
queryFn: getSubscription,
|
||||
staleTime: 1000 * 60 * 2, // 2 minutes
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: (data) => {
|
||||
// No refresh if tab is hidden
|
||||
if (!isVisible) return false;
|
||||
|
||||
// If actively streaming: refresh every 5s (costs are changing)
|
||||
if (isStreaming) return 5 * 1000;
|
||||
|
||||
// If visible but not streaming: refresh every 5min
|
||||
return 5 * 60 * 1000;
|
||||
},
|
||||
refetchIntervalInBackground: false, // Stop when tab backgrounded
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePortalSession = createMutationHook(
|
||||
(params: { return_url: string }) => createPortalSession(params),
|
||||
{
|
||||
|
|
|
@ -43,6 +43,10 @@ export const apiClient = {
|
|||
headers['Authorization'] = `Bearer ${session.access_token}`;
|
||||
}
|
||||
|
||||
if (session?.refresh_token) {
|
||||
headers['X-Refresh-Token'] = session.refresh_token;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
|
|
|
@ -0,0 +1,172 @@
|
|||
import { backendApi } from '@/lib/api-client';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
|
||||
|
||||
export interface FactorInfo {
|
||||
id: string;
|
||||
friendly_name?: string;
|
||||
factor_type?: string;
|
||||
status?: string;
|
||||
phone?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PhoneVerificationEnroll {
|
||||
friendly_name: string;
|
||||
phone_number: string;
|
||||
}
|
||||
|
||||
export interface PhoneVerificationChallenge {
|
||||
factor_id: string;
|
||||
}
|
||||
|
||||
export interface PhoneVerificationVerify {
|
||||
factor_id: string;
|
||||
challenge_id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface PhoneVerificationChallengeAndVerify {
|
||||
factor_id: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface PhoneVerificationResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
id?: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface EnrollFactorResponse {
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
phone_number: string;
|
||||
qr_code?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface ChallengeResponse {
|
||||
id: string;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface ListFactorsResponse {
|
||||
factors: FactorInfo[];
|
||||
}
|
||||
|
||||
export interface AALResponse {
|
||||
current_level?: string;
|
||||
next_level?: string;
|
||||
current_authentication_methods?: string[];
|
||||
// Add action guidance based on AAL status
|
||||
action_required?: string;
|
||||
message?: string;
|
||||
// Phone verification requirement fields
|
||||
phone_verification_required?: boolean;
|
||||
user_created_at?: string;
|
||||
cutoff_date?: string;
|
||||
// Computed verification status fields (same as PhoneVerificationStatus)
|
||||
verification_required?: boolean;
|
||||
is_verified?: boolean;
|
||||
factors?: FactorInfo[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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'
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
|
||||
/**
|
||||
* List all enrolled MFA factors
|
||||
*/
|
||||
async listFactors(): Promise<ListFactorsResponse> {
|
||||
const response = await backendApi.get<ListFactorsResponse>('/mfa/factors');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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'
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get Authenticator Assurance Level
|
||||
*/
|
||||
async getAAL(): Promise<AALResponse> {
|
||||
const response = await backendApi.get<AALResponse>('/mfa/aal');
|
||||
return response.data;
|
||||
}
|
||||
};
|
2
setup.py
2
setup.py
|
@ -649,7 +649,7 @@ class SetupWizard:
|
|||
)
|
||||
print_info("Create a snapshot with these exact settings:")
|
||||
print_info(f" - Name:\t\t{Colors.GREEN}kortix/suna:0.1.3{Colors.ENDC}")
|
||||
print_info(f" - Image name:\t{Colors.GREEN}kortix/suna:0.1.3{Colors.ENDC}")
|
||||
print_info(f" - Snapshot name:\t{Colors.GREEN}kortix/suna:0.1.3{Colors.ENDC}")
|
||||
print_info(
|
||||
f" - Entrypoint:\t{Colors.GREEN}/usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf{Colors.ENDC}"
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue