Merge branch 'dev' into kpczerwinski/open-2909-add-gpt-52

This commit is contained in:
Krzysztof Czerwinski
2025-12-20 16:00:17 +09:00
119 changed files with 9578 additions and 946 deletions

View File

@@ -11,7 +11,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v10
with:
# operations-per-run: 5000
stale-issue-message: >

View File

@@ -61,6 +61,6 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v5
- uses: actions/labeler@v6
with:
sync-labels: true

View File

@@ -57,6 +57,9 @@ class APIKeySmith:
def hash_key(self, raw_key: str) -> tuple[str, str]:
"""Migrate a legacy hash to secure hash format."""
if not raw_key.startswith(self.PREFIX):
raise ValueError("Key without 'agpt_' prefix would fail validation")
salt = self._generate_salt()
hash = self._hash_key_with_salt(raw_key, salt)
return hash, salt.hex()

View File

@@ -20,6 +20,7 @@ from backend.data.model import (
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util.exceptions import BlockExecutionError
from backend.util.request import Requests
TEST_CREDENTIALS = APIKeyCredentials(
@@ -246,7 +247,11 @@ class AIShortformVideoCreatorBlock(Block):
await asyncio.sleep(10)
logger.error("Video creation timed out")
raise TimeoutError("Video creation timed out")
raise BlockExecutionError(
message="Video creation timed out",
block_name=self.name,
block_id=self.id,
)
def __init__(self):
super().__init__(
@@ -422,7 +427,11 @@ class AIAdMakerVideoCreatorBlock(Block):
await asyncio.sleep(10)
logger.error("Video creation timed out")
raise TimeoutError("Video creation timed out")
raise BlockExecutionError(
message="Video creation timed out",
block_name=self.name,
block_id=self.id,
)
def __init__(self):
super().__init__(
@@ -599,7 +608,11 @@ class AIScreenshotToVideoAdBlock(Block):
await asyncio.sleep(10)
logger.error("Video creation timed out")
raise TimeoutError("Video creation timed out")
raise BlockExecutionError(
message="Video creation timed out",
block_name=self.name,
block_id=self.id,
)
def __init__(self):
super().__init__(

View File

@@ -106,7 +106,10 @@ class ConditionBlock(Block):
ComparisonOperator.LESS_THAN_OR_EQUAL: lambda a, b: a <= b,
}
result = comparison_funcs[operator](value1, value2)
try:
result = comparison_funcs[operator](value1, value2)
except Exception as e:
raise ValueError(f"Comparison failed: {e}") from e
yield "result", result

View File

@@ -15,6 +15,7 @@ from backend.sdk import (
SchemaField,
cost,
)
from backend.util.exceptions import BlockExecutionError
from ._config import firecrawl
@@ -59,11 +60,18 @@ class FirecrawlExtractBlock(Block):
) -> BlockOutput:
app = FirecrawlApp(api_key=credentials.api_key.get_secret_value())
extract_result = app.extract(
urls=input_data.urls,
prompt=input_data.prompt,
schema=input_data.output_schema,
enable_web_search=input_data.enable_web_search,
)
try:
extract_result = app.extract(
urls=input_data.urls,
prompt=input_data.prompt,
schema=input_data.output_schema,
enable_web_search=input_data.enable_web_search,
)
except Exception as e:
raise BlockExecutionError(
message=f"Extract failed: {e}",
block_name=self.name,
block_id=self.id,
) from e
yield "data", extract_result.data

View File

@@ -19,6 +19,7 @@ from backend.data.model import (
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util.exceptions import ModerationError
from backend.util.file import MediaFileType, store_media_file
TEST_CREDENTIALS = APIKeyCredentials(
@@ -153,6 +154,8 @@ class AIImageEditorBlock(Block):
),
aspect_ratio=input_data.aspect_ratio.value,
seed=input_data.seed,
user_id=user_id,
graph_exec_id=graph_exec_id,
)
yield "output_image", result
@@ -164,6 +167,8 @@ class AIImageEditorBlock(Block):
input_image_b64: Optional[str],
aspect_ratio: str,
seed: Optional[int],
user_id: str,
graph_exec_id: str,
) -> MediaFileType:
client = ReplicateClient(api_token=api_key.get_secret_value())
input_params = {
@@ -173,11 +178,21 @@ class AIImageEditorBlock(Block):
**({"seed": seed} if seed is not None else {}),
}
output: FileOutput | list[FileOutput] = await client.async_run( # type: ignore
model_name,
input=input_params,
wait=False,
)
try:
output: FileOutput | list[FileOutput] = await client.async_run( # type: ignore
model_name,
input=input_params,
wait=False,
)
except Exception as e:
if "flagged as sensitive" in str(e).lower():
raise ModerationError(
message="Content was flagged as sensitive by the model provider",
user_id=user_id,
graph_exec_id=graph_exec_id,
moderation_type="model_provider",
)
raise ValueError(f"Model execution failed: {e}") from e
if isinstance(output, list) and output:
output = output[0]

View File

@@ -1,5 +1,5 @@
import logging
from typing import Any, Literal
from typing import Any
from prisma.enums import ReviewStatus
@@ -45,11 +45,11 @@ class HumanInTheLoopBlock(Block):
)
class Output(BlockSchemaOutput):
reviewed_data: Any = SchemaField(
description="The data after human review (may be modified)"
approved_data: Any = SchemaField(
description="The data when approved (may be modified by reviewer)"
)
status: Literal["approved", "rejected"] = SchemaField(
description="Status of the review: 'approved' or 'rejected'"
rejected_data: Any = SchemaField(
description="The data when rejected (may be modified by reviewer)"
)
review_message: str = SchemaField(
description="Any message provided by the reviewer", default=""
@@ -69,8 +69,7 @@ class HumanInTheLoopBlock(Block):
"editable": True,
},
test_output=[
("status", "approved"),
("reviewed_data", {"name": "John Doe", "age": 30}),
("approved_data", {"name": "John Doe", "age": 30}),
],
test_mock={
"get_or_create_human_review": lambda *_args, **_kwargs: ReviewResult(
@@ -116,8 +115,7 @@ class HumanInTheLoopBlock(Block):
logger.info(
f"HITL block skipping review for node {node_exec_id} - safe mode disabled"
)
yield "status", "approved"
yield "reviewed_data", input_data.data
yield "approved_data", input_data.data
yield "review_message", "Auto-approved (safe mode disabled)"
return
@@ -158,12 +156,11 @@ class HumanInTheLoopBlock(Block):
)
if result.status == ReviewStatus.APPROVED:
yield "status", "approved"
yield "reviewed_data", result.data
yield "approved_data", result.data
if result.message:
yield "review_message", result.message
elif result.status == ReviewStatus.REJECTED:
yield "status", "rejected"
yield "rejected_data", result.data
if result.message:
yield "review_message", result.message

View File

@@ -2,7 +2,6 @@ from enum import Enum
from typing import Any, Dict, Literal, Optional
from pydantic import SecretStr
from requests.exceptions import RequestException
from backend.data.block import (
Block,
@@ -332,8 +331,8 @@ class IdeogramModelBlock(Block):
try:
response = await Requests().post(url, headers=headers, json=data)
return response.json()["data"][0]["url"]
except RequestException as e:
raise Exception(f"Failed to fetch image with V3 endpoint: {str(e)}")
except Exception as e:
raise ValueError(f"Failed to fetch image with V3 endpoint: {e}") from e
async def _run_model_legacy(
self,
@@ -385,8 +384,8 @@ class IdeogramModelBlock(Block):
try:
response = await Requests().post(url, headers=headers, json=data)
return response.json()["data"][0]["url"]
except RequestException as e:
raise Exception(f"Failed to fetch image with legacy endpoint: {str(e)}")
except Exception as e:
raise ValueError(f"Failed to fetch image with legacy endpoint: {e}") from e
async def upscale_image(self, api_key: SecretStr, image_url: str):
url = "https://api.ideogram.ai/upscale"
@@ -413,5 +412,5 @@ class IdeogramModelBlock(Block):
return (response.json())["data"][0]["url"]
except RequestException as e:
raise Exception(f"Failed to upscale image: {str(e)}")
except Exception as e:
raise ValueError(f"Failed to upscale image: {e}") from e

View File

@@ -16,6 +16,7 @@ from backend.data.block import (
BlockSchemaOutput,
)
from backend.data.model import SchemaField
from backend.util.exceptions import BlockExecutionError
class SearchTheWebBlock(Block, GetRequest):
@@ -56,7 +57,17 @@ class SearchTheWebBlock(Block, GetRequest):
# Prepend the Jina Search URL to the encoded query
jina_search_url = f"https://s.jina.ai/{encoded_query}"
results = await self.get_request(jina_search_url, headers=headers, json=False)
try:
results = await self.get_request(
jina_search_url, headers=headers, json=False
)
except Exception as e:
raise BlockExecutionError(
message=f"Search failed: {e}",
block_name=self.name,
block_id=self.id,
) from e
# Output the search results
yield "results", results

View File

@@ -18,6 +18,7 @@ from backend.data.block import (
BlockSchemaOutput,
)
from backend.data.model import APIKeyCredentials, CredentialsField, SchemaField
from backend.util.exceptions import BlockExecutionError, BlockInputError
logger = logging.getLogger(__name__)
@@ -111,9 +112,27 @@ class ReplicateModelBlock(Block):
yield "status", "succeeded"
yield "model_name", input_data.model_name
except Exception as e:
error_msg = f"Unexpected error running Replicate model: {str(e)}"
logger.error(error_msg)
raise RuntimeError(error_msg)
error_msg = str(e)
logger.error(f"Error running Replicate model: {error_msg}")
# Input validation errors (422, 400) → BlockInputError
if (
"422" in error_msg
or "Input validation failed" in error_msg
or "400" in error_msg
):
raise BlockInputError(
message=f"Invalid model inputs: {error_msg}",
block_name=self.name,
block_id=self.id,
) from e
# Everything else → BlockExecutionError
else:
raise BlockExecutionError(
message=f"Replicate model error: {error_msg}",
block_name=self.name,
block_id=self.id,
) from e
async def run_model(self, model_ref: str, model_inputs: dict, api_key: SecretStr):
"""

View File

@@ -45,10 +45,16 @@ class GetWikipediaSummaryBlock(Block, GetRequest):
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
topic = input_data.topic
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{topic}"
response = await self.get_request(url, json=True)
if "extract" not in response:
raise RuntimeError(f"Unable to parse Wikipedia response: {response}")
yield "summary", response["extract"]
# Note: User-Agent is now automatically set by the request library
# to comply with Wikimedia's robot policy (https://w.wiki/4wJS)
try:
response = await self.get_request(url, json=True)
if "extract" not in response:
raise ValueError(f"Unable to parse Wikipedia response: {response}")
yield "summary", response["extract"]
except Exception as e:
raise ValueError(f"Failed to fetch Wikipedia summary: {e}") from e
TEST_CREDENTIALS = APIKeyCredentials(

View File

@@ -0,0 +1 @@
"""CLI utilities for backend development & administration"""

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Script to generate OpenAPI JSON specification for the FastAPI app.
This script imports the FastAPI app from backend.server.rest_api and outputs
the OpenAPI specification as JSON to stdout or a specified file.
Usage:
`poetry run python generate_openapi_json.py`
`poetry run python generate_openapi_json.py --output openapi.json`
`poetry run python generate_openapi_json.py --indent 4 --output openapi.json`
"""
import json
import os
from pathlib import Path
import click
@click.command()
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Output file path (default: stdout)",
)
@click.option(
"--pretty",
type=click.BOOL,
default=False,
help="Pretty-print JSON output (indented 2 spaces)",
)
def main(output: Path, pretty: bool):
"""Generate and output the OpenAPI JSON specification."""
openapi_schema = get_openapi_schema()
json_output = json.dumps(openapi_schema, indent=2 if pretty else None)
if output:
output.write_text(json_output)
click.echo(f"✅ OpenAPI specification written to {output}\n\nPreview:")
click.echo(f"\n{json_output[:500]} ...")
else:
print(json_output)
def get_openapi_schema():
"""Get the OpenAPI schema from the FastAPI app"""
from backend.server.rest_api import app
return app.openapi()
if __name__ == "__main__":
os.environ["LOG_LEVEL"] = "ERROR" # disable stdout log output
main()

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,24 @@
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from typing import Literal, Optional
from autogpt_libs.api_key.keysmith import APIKeySmith
from prisma.enums import APIKeyPermission, APIKeyStatus
from prisma.models import APIKey as PrismaAPIKey
from prisma.types import APIKeyWhereUniqueInput
from pydantic import BaseModel, Field
from pydantic import Field
from backend.data.includes import MAX_USER_API_KEYS_FETCH
from backend.util.exceptions import NotAuthorizedError, NotFoundError
from .base import APIAuthorizationInfo
logger = logging.getLogger(__name__)
keysmith = APIKeySmith()
class APIKeyInfo(BaseModel):
class APIKeyInfo(APIAuthorizationInfo):
id: str
name: str
head: str = Field(
@@ -26,12 +28,9 @@ class APIKeyInfo(BaseModel):
description=f"The last {APIKeySmith.TAIL_LENGTH} characters of the key"
)
status: APIKeyStatus
permissions: list[APIKeyPermission]
created_at: datetime
last_used_at: Optional[datetime] = None
revoked_at: Optional[datetime] = None
description: Optional[str] = None
user_id: str
type: Literal["api_key"] = "api_key" # type: ignore
@staticmethod
def from_db(api_key: PrismaAPIKey):
@@ -41,7 +40,7 @@ class APIKeyInfo(BaseModel):
head=api_key.head,
tail=api_key.tail,
status=APIKeyStatus(api_key.status),
permissions=[APIKeyPermission(p) for p in api_key.permissions],
scopes=[APIKeyPermission(p) for p in api_key.permissions],
created_at=api_key.createdAt,
last_used_at=api_key.lastUsedAt,
revoked_at=api_key.revokedAt,
@@ -211,7 +210,7 @@ async def suspend_api_key(key_id: str, user_id: str) -> APIKeyInfo:
def has_permission(api_key: APIKeyInfo, required_permission: APIKeyPermission) -> bool:
return required_permission in api_key.permissions
return required_permission in api_key.scopes
async def get_api_key_by_id(key_id: str, user_id: str) -> Optional[APIKeyInfo]:

View File

@@ -0,0 +1,15 @@
from datetime import datetime
from typing import Literal, Optional
from prisma.enums import APIKeyPermission
from pydantic import BaseModel
class APIAuthorizationInfo(BaseModel):
user_id: str
scopes: list[APIKeyPermission]
type: Literal["oauth", "api_key"]
created_at: datetime
expires_at: Optional[datetime] = None
last_used_at: Optional[datetime] = None
revoked_at: Optional[datetime] = None

View File

@@ -0,0 +1,872 @@
"""
OAuth 2.0 Provider Data Layer
Handles management of OAuth applications, authorization codes,
access tokens, and refresh tokens.
Hashing strategy:
- Access tokens & Refresh tokens: SHA256 (deterministic, allows direct lookup by hash)
- Client secrets: Scrypt with salt (lookup by client_id, then verify with salt)
"""
import hashlib
import logging
import secrets
import uuid
from datetime import datetime, timedelta, timezone
from typing import Literal, Optional
from autogpt_libs.api_key.keysmith import APIKeySmith
from prisma.enums import APIKeyPermission as APIPermission
from prisma.models import OAuthAccessToken as PrismaOAuthAccessToken
from prisma.models import OAuthApplication as PrismaOAuthApplication
from prisma.models import OAuthAuthorizationCode as PrismaOAuthAuthorizationCode
from prisma.models import OAuthRefreshToken as PrismaOAuthRefreshToken
from prisma.types import OAuthApplicationUpdateInput
from pydantic import BaseModel, Field, SecretStr
from .base import APIAuthorizationInfo
logger = logging.getLogger(__name__)
keysmith = APIKeySmith() # Only used for client secret hashing (Scrypt)
def _generate_token() -> str:
"""Generate a cryptographically secure random token."""
return secrets.token_urlsafe(32)
def _hash_token(token: str) -> str:
"""Hash a token using SHA256 (deterministic, for direct lookup)."""
return hashlib.sha256(token.encode()).hexdigest()
# Token TTLs
AUTHORIZATION_CODE_TTL = timedelta(minutes=10)
ACCESS_TOKEN_TTL = timedelta(hours=1)
REFRESH_TOKEN_TTL = timedelta(days=30)
ACCESS_TOKEN_PREFIX = "agpt_xt_"
REFRESH_TOKEN_PREFIX = "agpt_rt_"
# ============================================================================
# Exception Classes
# ============================================================================
class OAuthError(Exception):
"""Base OAuth error"""
pass
class InvalidClientError(OAuthError):
"""Invalid client_id or client_secret"""
pass
class InvalidGrantError(OAuthError):
"""Invalid or expired authorization code/refresh token"""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Invalid grant: {reason}")
class InvalidTokenError(OAuthError):
"""Invalid, expired, or revoked token"""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Invalid token: {reason}")
# ============================================================================
# Data Models
# ============================================================================
class OAuthApplicationInfo(BaseModel):
"""OAuth application information (without client secret hash)"""
id: str
name: str
description: Optional[str] = None
logo_url: Optional[str] = None
client_id: str
redirect_uris: list[str]
grant_types: list[str]
scopes: list[APIPermission]
owner_id: str
is_active: bool
created_at: datetime
updated_at: datetime
@staticmethod
def from_db(app: PrismaOAuthApplication):
return OAuthApplicationInfo(
id=app.id,
name=app.name,
description=app.description,
logo_url=app.logoUrl,
client_id=app.clientId,
redirect_uris=app.redirectUris,
grant_types=app.grantTypes,
scopes=[APIPermission(s) for s in app.scopes],
owner_id=app.ownerId,
is_active=app.isActive,
created_at=app.createdAt,
updated_at=app.updatedAt,
)
class OAuthApplicationInfoWithSecret(OAuthApplicationInfo):
"""OAuth application with client secret hash (for validation)"""
client_secret_hash: str
client_secret_salt: str
@staticmethod
def from_db(app: PrismaOAuthApplication):
return OAuthApplicationInfoWithSecret(
**OAuthApplicationInfo.from_db(app).model_dump(),
client_secret_hash=app.clientSecret,
client_secret_salt=app.clientSecretSalt,
)
def verify_secret(self, plaintext_secret: str) -> bool:
"""Verify a plaintext client secret against the stored hash"""
# Use keysmith.verify_key() with stored salt
return keysmith.verify_key(
plaintext_secret, self.client_secret_hash, self.client_secret_salt
)
class OAuthAuthorizationCodeInfo(BaseModel):
"""Authorization code information"""
id: str
code: str
created_at: datetime
expires_at: datetime
application_id: str
user_id: str
scopes: list[APIPermission]
redirect_uri: str
code_challenge: Optional[str] = None
code_challenge_method: Optional[str] = None
used_at: Optional[datetime] = None
@property
def is_used(self) -> bool:
return self.used_at is not None
@staticmethod
def from_db(code: PrismaOAuthAuthorizationCode):
return OAuthAuthorizationCodeInfo(
id=code.id,
code=code.code,
created_at=code.createdAt,
expires_at=code.expiresAt,
application_id=code.applicationId,
user_id=code.userId,
scopes=[APIPermission(s) for s in code.scopes],
redirect_uri=code.redirectUri,
code_challenge=code.codeChallenge,
code_challenge_method=code.codeChallengeMethod,
used_at=code.usedAt,
)
class OAuthAccessTokenInfo(APIAuthorizationInfo):
"""Access token information"""
id: str
expires_at: datetime # type: ignore
application_id: str
type: Literal["oauth"] = "oauth" # type: ignore
@staticmethod
def from_db(token: PrismaOAuthAccessToken):
return OAuthAccessTokenInfo(
id=token.id,
user_id=token.userId,
scopes=[APIPermission(s) for s in token.scopes],
created_at=token.createdAt,
expires_at=token.expiresAt,
last_used_at=None,
revoked_at=token.revokedAt,
application_id=token.applicationId,
)
class OAuthAccessToken(OAuthAccessTokenInfo):
"""Access token with plaintext token included (sensitive)"""
token: SecretStr = Field(description="Plaintext token (sensitive)")
@staticmethod
def from_db(token: PrismaOAuthAccessToken, plaintext_token: str): # type: ignore
return OAuthAccessToken(
**OAuthAccessTokenInfo.from_db(token).model_dump(),
token=SecretStr(plaintext_token),
)
class OAuthRefreshTokenInfo(BaseModel):
"""Refresh token information"""
id: str
user_id: str
scopes: list[APIPermission]
created_at: datetime
expires_at: datetime
application_id: str
revoked_at: Optional[datetime] = None
@property
def is_revoked(self) -> bool:
return self.revoked_at is not None
@staticmethod
def from_db(token: PrismaOAuthRefreshToken):
return OAuthRefreshTokenInfo(
id=token.id,
user_id=token.userId,
scopes=[APIPermission(s) for s in token.scopes],
created_at=token.createdAt,
expires_at=token.expiresAt,
application_id=token.applicationId,
revoked_at=token.revokedAt,
)
class OAuthRefreshToken(OAuthRefreshTokenInfo):
"""Refresh token with plaintext token included (sensitive)"""
token: SecretStr = Field(description="Plaintext token (sensitive)")
@staticmethod
def from_db(token: PrismaOAuthRefreshToken, plaintext_token: str): # type: ignore
return OAuthRefreshToken(
**OAuthRefreshTokenInfo.from_db(token).model_dump(),
token=SecretStr(plaintext_token),
)
class TokenIntrospectionResult(BaseModel):
"""Result of token introspection (RFC 7662)"""
active: bool
scopes: Optional[list[str]] = None
client_id: Optional[str] = None
user_id: Optional[str] = None
exp: Optional[int] = None # Unix timestamp
token_type: Optional[Literal["access_token", "refresh_token"]] = None
# ============================================================================
# OAuth Application Management
# ============================================================================
async def get_oauth_application(client_id: str) -> Optional[OAuthApplicationInfo]:
"""Get OAuth application by client ID (without secret)"""
app = await PrismaOAuthApplication.prisma().find_unique(
where={"clientId": client_id}
)
if not app:
return None
return OAuthApplicationInfo.from_db(app)
async def get_oauth_application_with_secret(
client_id: str,
) -> Optional[OAuthApplicationInfoWithSecret]:
"""Get OAuth application by client ID (with secret hash for validation)"""
app = await PrismaOAuthApplication.prisma().find_unique(
where={"clientId": client_id}
)
if not app:
return None
return OAuthApplicationInfoWithSecret.from_db(app)
async def validate_client_credentials(
client_id: str, client_secret: str
) -> OAuthApplicationInfo:
"""
Validate client credentials and return application info.
Raises:
InvalidClientError: If client_id or client_secret is invalid, or app is inactive
"""
app = await get_oauth_application_with_secret(client_id)
if not app:
raise InvalidClientError("Invalid client_id")
if not app.is_active:
raise InvalidClientError("Application is not active")
# Verify client secret
if not app.verify_secret(client_secret):
raise InvalidClientError("Invalid client_secret")
# Return without secret hash
return OAuthApplicationInfo(**app.model_dump(exclude={"client_secret_hash"}))
def validate_redirect_uri(app: OAuthApplicationInfo, redirect_uri: str) -> bool:
"""Validate that redirect URI is registered for the application"""
return redirect_uri in app.redirect_uris
def validate_scopes(
app: OAuthApplicationInfo, requested_scopes: list[APIPermission]
) -> bool:
"""Validate that all requested scopes are allowed for the application"""
return all(scope in app.scopes for scope in requested_scopes)
# ============================================================================
# Authorization Code Flow
# ============================================================================
def _generate_authorization_code() -> str:
"""Generate a cryptographically secure authorization code"""
# 32 bytes = 256 bits of entropy
return secrets.token_urlsafe(32)
async def create_authorization_code(
application_id: str,
user_id: str,
scopes: list[APIPermission],
redirect_uri: str,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[Literal["S256", "plain"]] = None,
) -> OAuthAuthorizationCodeInfo:
"""
Create a new authorization code.
Expires in 10 minutes and can only be used once.
"""
code = _generate_authorization_code()
now = datetime.now(timezone.utc)
expires_at = now + AUTHORIZATION_CODE_TTL
saved_code = await PrismaOAuthAuthorizationCode.prisma().create(
data={
"id": str(uuid.uuid4()),
"code": code,
"expiresAt": expires_at,
"applicationId": application_id,
"userId": user_id,
"scopes": [s for s in scopes],
"redirectUri": redirect_uri,
"codeChallenge": code_challenge,
"codeChallengeMethod": code_challenge_method,
}
)
return OAuthAuthorizationCodeInfo.from_db(saved_code)
async def consume_authorization_code(
code: str,
application_id: str,
redirect_uri: str,
code_verifier: Optional[str] = None,
) -> tuple[str, list[APIPermission]]:
"""
Consume an authorization code and return (user_id, scopes).
This marks the code as used and validates:
- Code exists and matches application
- Code is not expired
- Code has not been used
- Redirect URI matches
- PKCE code verifier matches (if code challenge was provided)
Raises:
InvalidGrantError: If code is invalid, expired, used, or PKCE fails
"""
auth_code = await PrismaOAuthAuthorizationCode.prisma().find_unique(
where={"code": code}
)
if not auth_code:
raise InvalidGrantError("authorization code not found")
# Validate application
if auth_code.applicationId != application_id:
raise InvalidGrantError(
"authorization code does not belong to this application"
)
# Check if already used
if auth_code.usedAt is not None:
raise InvalidGrantError(
f"authorization code already used at {auth_code.usedAt}"
)
# Check expiration
now = datetime.now(timezone.utc)
if auth_code.expiresAt < now:
raise InvalidGrantError("authorization code expired")
# Validate redirect URI
if auth_code.redirectUri != redirect_uri:
raise InvalidGrantError("redirect_uri mismatch")
# Validate PKCE if code challenge was provided
if auth_code.codeChallenge:
if not code_verifier:
raise InvalidGrantError("code_verifier required but not provided")
if not _verify_pkce(
code_verifier, auth_code.codeChallenge, auth_code.codeChallengeMethod
):
raise InvalidGrantError("PKCE verification failed")
# Mark code as used
await PrismaOAuthAuthorizationCode.prisma().update(
where={"code": code},
data={"usedAt": now},
)
return auth_code.userId, [APIPermission(s) for s in auth_code.scopes]
def _verify_pkce(
code_verifier: str, code_challenge: str, code_challenge_method: Optional[str]
) -> bool:
"""
Verify PKCE code verifier against code challenge.
Supports:
- S256: SHA256(code_verifier) == code_challenge
- plain: code_verifier == code_challenge
"""
if code_challenge_method == "S256":
# Hash the verifier with SHA256 and base64url encode
hashed = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed_challenge = (
secrets.token_urlsafe(len(hashed)).encode("ascii").decode("ascii")
)
# For proper base64url encoding
import base64
computed_challenge = (
base64.urlsafe_b64encode(hashed).decode("ascii").rstrip("=")
)
return secrets.compare_digest(computed_challenge, code_challenge)
elif code_challenge_method == "plain" or code_challenge_method is None:
# Plain comparison
return secrets.compare_digest(code_verifier, code_challenge)
else:
logger.warning(f"Unsupported code challenge method: {code_challenge_method}")
return False
# ============================================================================
# Access Token Management
# ============================================================================
async def create_access_token(
application_id: str, user_id: str, scopes: list[APIPermission]
) -> OAuthAccessToken:
"""
Create a new access token.
Returns OAuthAccessToken (with plaintext token).
"""
plaintext_token = ACCESS_TOKEN_PREFIX + _generate_token()
token_hash = _hash_token(plaintext_token)
now = datetime.now(timezone.utc)
expires_at = now + ACCESS_TOKEN_TTL
saved_token = await PrismaOAuthAccessToken.prisma().create(
data={
"id": str(uuid.uuid4()),
"token": token_hash, # SHA256 hash for direct lookup
"expiresAt": expires_at,
"applicationId": application_id,
"userId": user_id,
"scopes": [s for s in scopes],
}
)
return OAuthAccessToken.from_db(saved_token, plaintext_token=plaintext_token)
async def validate_access_token(
token: str,
) -> tuple[OAuthAccessTokenInfo, OAuthApplicationInfo]:
"""
Validate an access token and return token info.
Raises:
InvalidTokenError: If token is invalid, expired, or revoked
InvalidClientError: If the client application is not marked as active
"""
token_hash = _hash_token(token)
# Direct lookup by hash
access_token = await PrismaOAuthAccessToken.prisma().find_unique(
where={"token": token_hash}, include={"Application": True}
)
if not access_token:
raise InvalidTokenError("access token not found")
if not access_token.Application: # should be impossible
raise InvalidClientError("Client application not found")
if not access_token.Application.isActive:
raise InvalidClientError("Client application is disabled")
if access_token.revokedAt is not None:
raise InvalidTokenError("access token has been revoked")
# Check expiration
now = datetime.now(timezone.utc)
if access_token.expiresAt < now:
raise InvalidTokenError("access token expired")
return (
OAuthAccessTokenInfo.from_db(access_token),
OAuthApplicationInfo.from_db(access_token.Application),
)
async def revoke_access_token(
token: str, application_id: str
) -> OAuthAccessTokenInfo | None:
"""
Revoke an access token.
Args:
token: The plaintext access token to revoke
application_id: The application ID making the revocation request.
Only tokens belonging to this application will be revoked.
Returns:
OAuthAccessTokenInfo if token was found and revoked, None otherwise.
Note:
Always performs exactly 2 DB queries regardless of outcome to prevent
timing side-channel attacks that could reveal token existence.
"""
try:
token_hash = _hash_token(token)
# Use update_many to filter by both token and applicationId
updated_count = await PrismaOAuthAccessToken.prisma().update_many(
where={
"token": token_hash,
"applicationId": application_id,
"revokedAt": None,
},
data={"revokedAt": datetime.now(timezone.utc)},
)
# Always perform second query to ensure constant time
result = await PrismaOAuthAccessToken.prisma().find_unique(
where={"token": token_hash}
)
# Only return result if we actually revoked something
if updated_count == 0:
return None
return OAuthAccessTokenInfo.from_db(result) if result else None
except Exception as e:
logger.exception(f"Error revoking access token: {e}")
return None
# ============================================================================
# Refresh Token Management
# ============================================================================
async def create_refresh_token(
application_id: str, user_id: str, scopes: list[APIPermission]
) -> OAuthRefreshToken:
"""
Create a new refresh token.
Returns OAuthRefreshToken (with plaintext token).
"""
plaintext_token = REFRESH_TOKEN_PREFIX + _generate_token()
token_hash = _hash_token(plaintext_token)
now = datetime.now(timezone.utc)
expires_at = now + REFRESH_TOKEN_TTL
saved_token = await PrismaOAuthRefreshToken.prisma().create(
data={
"id": str(uuid.uuid4()),
"token": token_hash, # SHA256 hash for direct lookup
"expiresAt": expires_at,
"applicationId": application_id,
"userId": user_id,
"scopes": [s for s in scopes],
}
)
return OAuthRefreshToken.from_db(saved_token, plaintext_token=plaintext_token)
async def refresh_tokens(
refresh_token: str, application_id: str
) -> tuple[OAuthAccessToken, OAuthRefreshToken]:
"""
Use a refresh token to create new access and refresh tokens.
Returns (new_access_token, new_refresh_token) both with plaintext tokens included.
Raises:
InvalidGrantError: If refresh token is invalid, expired, or revoked
"""
token_hash = _hash_token(refresh_token)
# Direct lookup by hash
rt = await PrismaOAuthRefreshToken.prisma().find_unique(where={"token": token_hash})
if not rt:
raise InvalidGrantError("refresh token not found")
# NOTE: no need to check Application.isActive, this is checked by the token endpoint
if rt.revokedAt is not None:
raise InvalidGrantError("refresh token has been revoked")
# Validate application
if rt.applicationId != application_id:
raise InvalidGrantError("refresh token does not belong to this application")
# Check expiration
now = datetime.now(timezone.utc)
if rt.expiresAt < now:
raise InvalidGrantError("refresh token expired")
# Revoke old refresh token
await PrismaOAuthRefreshToken.prisma().update(
where={"token": token_hash},
data={"revokedAt": now},
)
# Create new access and refresh tokens with same scopes
scopes = [APIPermission(s) for s in rt.scopes]
new_access_token = await create_access_token(
rt.applicationId,
rt.userId,
scopes,
)
new_refresh_token = await create_refresh_token(
rt.applicationId,
rt.userId,
scopes,
)
return new_access_token, new_refresh_token
async def revoke_refresh_token(
token: str, application_id: str
) -> OAuthRefreshTokenInfo | None:
"""
Revoke a refresh token.
Args:
token: The plaintext refresh token to revoke
application_id: The application ID making the revocation request.
Only tokens belonging to this application will be revoked.
Returns:
OAuthRefreshTokenInfo if token was found and revoked, None otherwise.
Note:
Always performs exactly 2 DB queries regardless of outcome to prevent
timing side-channel attacks that could reveal token existence.
"""
try:
token_hash = _hash_token(token)
# Use update_many to filter by both token and applicationId
updated_count = await PrismaOAuthRefreshToken.prisma().update_many(
where={
"token": token_hash,
"applicationId": application_id,
"revokedAt": None,
},
data={"revokedAt": datetime.now(timezone.utc)},
)
# Always perform second query to ensure constant time
result = await PrismaOAuthRefreshToken.prisma().find_unique(
where={"token": token_hash}
)
# Only return result if we actually revoked something
if updated_count == 0:
return None
return OAuthRefreshTokenInfo.from_db(result) if result else None
except Exception as e:
logger.exception(f"Error revoking refresh token: {e}")
return None
# ============================================================================
# Token Introspection
# ============================================================================
async def introspect_token(
token: str,
token_type_hint: Optional[Literal["access_token", "refresh_token"]] = None,
) -> TokenIntrospectionResult:
"""
Introspect a token and return its metadata (RFC 7662).
Returns TokenIntrospectionResult with active=True and metadata if valid,
or active=False if the token is invalid/expired/revoked.
"""
# Try as access token first (or if hint says "access_token")
if token_type_hint != "refresh_token":
try:
token_info, app = await validate_access_token(token)
return TokenIntrospectionResult(
active=True,
scopes=list(s.value for s in token_info.scopes),
client_id=app.client_id if app else None,
user_id=token_info.user_id,
exp=int(token_info.expires_at.timestamp()),
token_type="access_token",
)
except InvalidTokenError:
pass # Try as refresh token
# Try as refresh token
token_hash = _hash_token(token)
refresh_token = await PrismaOAuthRefreshToken.prisma().find_unique(
where={"token": token_hash}
)
if refresh_token and refresh_token.revokedAt is None:
# Check if valid (not expired)
now = datetime.now(timezone.utc)
if refresh_token.expiresAt > now:
app = await get_oauth_application_by_id(refresh_token.applicationId)
return TokenIntrospectionResult(
active=True,
scopes=list(s for s in refresh_token.scopes),
client_id=app.client_id if app else None,
user_id=refresh_token.userId,
exp=int(refresh_token.expiresAt.timestamp()),
token_type="refresh_token",
)
# Token not found or inactive
return TokenIntrospectionResult(active=False)
async def get_oauth_application_by_id(app_id: str) -> Optional[OAuthApplicationInfo]:
"""Get OAuth application by ID"""
app = await PrismaOAuthApplication.prisma().find_unique(where={"id": app_id})
if not app:
return None
return OAuthApplicationInfo.from_db(app)
async def list_user_oauth_applications(user_id: str) -> list[OAuthApplicationInfo]:
"""Get all OAuth applications owned by a user"""
apps = await PrismaOAuthApplication.prisma().find_many(
where={"ownerId": user_id},
order={"createdAt": "desc"},
)
return [OAuthApplicationInfo.from_db(app) for app in apps]
async def update_oauth_application(
app_id: str,
*,
owner_id: str,
is_active: Optional[bool] = None,
logo_url: Optional[str] = None,
) -> Optional[OAuthApplicationInfo]:
"""
Update OAuth application active status.
Only the owner can update their app's status.
Returns the updated app info, or None if app not found or not owned by user.
"""
# First verify ownership
app = await PrismaOAuthApplication.prisma().find_first(
where={"id": app_id, "ownerId": owner_id}
)
if not app:
return None
patch: OAuthApplicationUpdateInput = {}
if is_active is not None:
patch["isActive"] = is_active
if logo_url:
patch["logoUrl"] = logo_url
if not patch:
return OAuthApplicationInfo.from_db(app) # return unchanged
updated_app = await PrismaOAuthApplication.prisma().update(
where={"id": app_id},
data=patch,
)
return OAuthApplicationInfo.from_db(updated_app) if updated_app else None
# ============================================================================
# Token Cleanup
# ============================================================================
async def cleanup_expired_oauth_tokens() -> dict[str, int]:
"""
Delete expired OAuth tokens from the database.
This removes:
- Expired authorization codes (10 min TTL)
- Expired access tokens (1 hour TTL)
- Expired refresh tokens (30 day TTL)
Returns a dict with counts of deleted tokens by type.
"""
now = datetime.now(timezone.utc)
# Delete expired authorization codes
codes_result = await PrismaOAuthAuthorizationCode.prisma().delete_many(
where={"expiresAt": {"lt": now}}
)
# Delete expired access tokens
access_result = await PrismaOAuthAccessToken.prisma().delete_many(
where={"expiresAt": {"lt": now}}
)
# Delete expired refresh tokens
refresh_result = await PrismaOAuthRefreshToken.prisma().delete_many(
where={"expiresAt": {"lt": now}}
)
deleted = {
"authorization_codes": codes_result,
"access_tokens": access_result,
"refresh_tokens": refresh_result,
}
total = sum(deleted.values())
if total > 0:
logger.info(f"Cleaned up {total} expired OAuth tokens: {deleted}")
return deleted

View File

@@ -100,7 +100,7 @@ async def get_or_create_human_review(
return None
else:
return ReviewResult(
data=review.payload if review.status == ReviewStatus.APPROVED else None,
data=review.payload,
status=review.status,
message=review.reviewMessage or "",
processed=review.processed,

View File

@@ -23,6 +23,7 @@ from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import MetaData, create_engine
from backend.data.auth.oauth import cleanup_expired_oauth_tokens
from backend.data.block import BlockInput
from backend.data.execution import GraphExecutionWithNodes
from backend.data.model import CredentialsMetaInput
@@ -242,6 +243,12 @@ def cleanup_expired_files():
run_async(cleanup_expired_files_async())
def cleanup_oauth_tokens():
"""Clean up expired OAuth tokens from the database."""
# Wait for completion
run_async(cleanup_expired_oauth_tokens())
def execution_accuracy_alerts():
"""Check execution accuracy and send alerts if drops are detected."""
return report_execution_accuracy_alerts()
@@ -446,6 +453,17 @@ class Scheduler(AppService):
jobstore=Jobstores.EXECUTION.value,
)
# OAuth Token Cleanup - configurable interval
self.scheduler.add_job(
cleanup_oauth_tokens,
id="cleanup_oauth_tokens",
trigger="interval",
replace_existing=True,
seconds=config.oauth_token_cleanup_interval_hours
* 3600, # Convert hours to seconds
jobstore=Jobstores.EXECUTION.value,
)
# Execution Accuracy Monitoring - configurable interval
self.scheduler.add_job(
execution_accuracy_alerts,
@@ -604,6 +622,11 @@ class Scheduler(AppService):
"""Manually trigger cleanup of expired cloud storage files."""
return cleanup_expired_files()
@expose
def execute_cleanup_oauth_tokens(self):
"""Manually trigger cleanup of expired OAuth tokens."""
return cleanup_oauth_tokens()
@expose
def execute_report_execution_accuracy_alerts(self):
"""Manually trigger execution accuracy alert checking."""

View File

@@ -1,36 +1,107 @@
from fastapi import HTTPException, Security
from fastapi.security import APIKeyHeader
from fastapi import HTTPException, Security, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from prisma.enums import APIKeyPermission
from backend.data.api_key import APIKeyInfo, has_permission, validate_api_key
from backend.data.auth.api_key import APIKeyInfo, validate_api_key
from backend.data.auth.base import APIAuthorizationInfo
from backend.data.auth.oauth import (
InvalidClientError,
InvalidTokenError,
OAuthAccessTokenInfo,
validate_access_token,
)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
bearer_auth = HTTPBearer(auto_error=False)
async def require_api_key(api_key: str | None = Security(api_key_header)) -> APIKeyInfo:
"""Base middleware for API key authentication"""
"""Middleware for API key authentication only"""
if api_key is None:
raise HTTPException(status_code=401, detail="Missing API key")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key"
)
api_key_obj = await validate_api_key(api_key)
if not api_key_obj:
raise HTTPException(status_code=401, detail="Invalid API key")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key"
)
return api_key_obj
async def require_access_token(
bearer: HTTPAuthorizationCredentials | None = Security(bearer_auth),
) -> OAuthAccessTokenInfo:
"""Middleware for OAuth access token authentication only"""
if bearer is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing Authorization header",
)
try:
token_info, _ = await validate_access_token(bearer.credentials)
except (InvalidClientError, InvalidTokenError) as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
return token_info
async def require_auth(
api_key: str | None = Security(api_key_header),
bearer: HTTPAuthorizationCredentials | None = Security(bearer_auth),
) -> APIAuthorizationInfo:
"""
Unified authentication middleware supporting both API keys and OAuth tokens.
Supports two authentication methods, which are checked in order:
1. X-API-Key header (existing API key authentication)
2. Authorization: Bearer <token> header (OAuth access token)
Returns:
APIAuthorizationInfo: base class of both APIKeyInfo and OAuthAccessTokenInfo.
"""
# Try API key first
if api_key is not None:
api_key_info = await validate_api_key(api_key)
if api_key_info:
return api_key_info
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key"
)
# Try OAuth bearer token
if bearer is not None:
try:
token_info, _ = await validate_access_token(bearer.credentials)
return token_info
except (InvalidClientError, InvalidTokenError) as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
# No credentials provided
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication. Provide API key or access token.",
)
def require_permission(permission: APIKeyPermission):
"""Dependency function for checking specific permissions"""
"""
Dependency function for checking specific permissions
(works with API keys and OAuth tokens)
"""
async def check_permission(
api_key: APIKeyInfo = Security(require_api_key),
) -> APIKeyInfo:
if not has_permission(api_key, permission):
auth: APIAuthorizationInfo = Security(require_auth),
) -> APIAuthorizationInfo:
if permission not in auth.scopes:
raise HTTPException(
status_code=403,
detail=f"API key lacks the required permission '{permission}'",
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required permission: {permission.value}",
)
return api_key
return auth
return check_permission

View File

@@ -16,7 +16,7 @@ from fastapi import APIRouter, Body, HTTPException, Path, Security, status
from prisma.enums import APIKeyPermission
from pydantic import BaseModel, Field, SecretStr
from backend.data.api_key import APIKeyInfo
from backend.data.auth.base import APIAuthorizationInfo
from backend.data.model import (
APIKeyCredentials,
Credentials,
@@ -255,7 +255,7 @@ def _get_oauth_handler_for_external(
@integrations_router.get("/providers", response_model=list[ProviderInfo])
async def list_providers(
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_INTEGRATIONS)
),
) -> list[ProviderInfo]:
@@ -319,7 +319,7 @@ async def list_providers(
async def initiate_oauth(
provider: Annotated[str, Path(title="The OAuth provider")],
request: OAuthInitiateRequest,
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.MANAGE_INTEGRATIONS)
),
) -> OAuthInitiateResponse:
@@ -337,7 +337,10 @@ async def initiate_oauth(
if not validate_callback_url(request.callback_url):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Callback URL origin is not allowed. Allowed origins: {settings.config.external_oauth_callback_origins}",
detail=(
f"Callback URL origin is not allowed. "
f"Allowed origins: {settings.config.external_oauth_callback_origins}",
),
)
# Validate provider
@@ -359,13 +362,15 @@ async def initiate_oauth(
)
# Store state token with external flow metadata
# Note: initiated_by_api_key_id is only available for API key auth, not OAuth
api_key_id = getattr(auth, "id", None) if auth.type == "api_key" else None
state_token, code_challenge = await creds_manager.store.store_state_token(
user_id=api_key.user_id,
user_id=auth.user_id,
provider=provider if isinstance(provider_name, str) else provider_name.value,
scopes=request.scopes,
callback_url=request.callback_url,
state_metadata=request.state_metadata,
initiated_by_api_key_id=api_key.id,
initiated_by_api_key_id=api_key_id,
)
# Build login URL
@@ -393,7 +398,7 @@ async def initiate_oauth(
async def complete_oauth(
provider: Annotated[str, Path(title="The OAuth provider")],
request: OAuthCompleteRequest,
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.MANAGE_INTEGRATIONS)
),
) -> OAuthCompleteResponse:
@@ -406,7 +411,7 @@ async def complete_oauth(
"""
# Verify state token
valid_state = await creds_manager.store.verify_state_token(
api_key.user_id, request.state_token, provider
auth.user_id, request.state_token, provider
)
if not valid_state:
@@ -453,7 +458,7 @@ async def complete_oauth(
)
# Store credentials
await creds_manager.create(api_key.user_id, credentials)
await creds_manager.create(auth.user_id, credentials)
logger.info(f"Successfully completed external OAuth for provider {provider}")
@@ -470,7 +475,7 @@ async def complete_oauth(
@integrations_router.get("/credentials", response_model=list[CredentialSummary])
async def list_credentials(
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_INTEGRATIONS)
),
) -> list[CredentialSummary]:
@@ -479,7 +484,7 @@ async def list_credentials(
Returns metadata about each credential without exposing sensitive tokens.
"""
credentials = await creds_manager.store.get_all_creds(api_key.user_id)
credentials = await creds_manager.store.get_all_creds(auth.user_id)
return [
CredentialSummary(
id=cred.id,
@@ -499,7 +504,7 @@ async def list_credentials(
)
async def list_credentials_by_provider(
provider: Annotated[str, Path(title="The provider to list credentials for")],
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_INTEGRATIONS)
),
) -> list[CredentialSummary]:
@@ -507,7 +512,7 @@ async def list_credentials_by_provider(
List credentials for a specific provider.
"""
credentials = await creds_manager.store.get_creds_by_provider(
api_key.user_id, provider
auth.user_id, provider
)
return [
CredentialSummary(
@@ -536,7 +541,7 @@ async def create_credential(
CreateUserPasswordCredentialRequest,
CreateHostScopedCredentialRequest,
] = Body(..., discriminator="type"),
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.MANAGE_INTEGRATIONS)
),
) -> CreateCredentialResponse:
@@ -591,7 +596,7 @@ async def create_credential(
# Store credentials
try:
await creds_manager.create(api_key.user_id, credentials)
await creds_manager.create(auth.user_id, credentials)
except Exception as e:
logger.error(f"Failed to store credentials: {e}")
raise HTTPException(
@@ -623,7 +628,7 @@ class DeleteCredentialResponse(BaseModel):
async def delete_credential(
provider: Annotated[str, Path(title="The provider")],
cred_id: Annotated[str, Path(title="The credential ID to delete")],
api_key: APIKeyInfo = Security(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.DELETE_INTEGRATIONS)
),
) -> DeleteCredentialResponse:
@@ -634,7 +639,7 @@ async def delete_credential(
use the main API's delete endpoint which handles webhook cleanup and
token revocation.
"""
creds = await creds_manager.store.get_creds_by_id(api_key.user_id, cred_id)
creds = await creds_manager.store.get_creds_by_id(auth.user_id, cred_id)
if not creds:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Credentials not found"
@@ -645,6 +650,6 @@ async def delete_credential(
detail="Credentials do not match the specified provider",
)
await creds_manager.delete(api_key.user_id, cred_id)
await creds_manager.delete(auth.user_id, cred_id)
return DeleteCredentialResponse(deleted=True, credentials_id=cred_id)

View File

@@ -14,7 +14,7 @@ from fastapi import APIRouter, Security
from prisma.enums import APIKeyPermission
from pydantic import BaseModel, Field
from backend.data.api_key import APIKeyInfo
from backend.data.auth.base import APIAuthorizationInfo
from backend.server.external.middleware import require_permission
from backend.server.v2.chat.model import ChatSession
from backend.server.v2.chat.tools import find_agent_tool, run_agent_tool
@@ -24,9 +24,9 @@ logger = logging.getLogger(__name__)
tools_router = APIRouter(prefix="/tools", tags=["tools"])
# Note: We use Security() as a function parameter dependency (api_key: APIKeyInfo = Security(...))
# Note: We use Security() as a function parameter dependency (auth: APIAuthorizationInfo = Security(...))
# rather than in the decorator's dependencies= list. This avoids duplicate permission checks
# while still enforcing auth AND giving us access to the api_key for extracting user_id.
# while still enforcing auth AND giving us access to auth for extracting user_id.
# Request models
@@ -80,7 +80,9 @@ def _create_ephemeral_session(user_id: str | None) -> ChatSession:
)
async def find_agent(
request: FindAgentRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.USE_TOOLS)
),
) -> dict[str, Any]:
"""
Search for agents in the marketplace based on capabilities and user needs.
@@ -91,9 +93,9 @@ async def find_agent(
Returns:
List of matching agents or no results response
"""
session = _create_ephemeral_session(api_key.user_id)
session = _create_ephemeral_session(auth.user_id)
result = await find_agent_tool._execute(
user_id=api_key.user_id,
user_id=auth.user_id,
session=session,
query=request.query,
)
@@ -105,7 +107,9 @@ async def find_agent(
)
async def run_agent(
request: RunAgentRequest,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.USE_TOOLS)
),
) -> dict[str, Any]:
"""
Run or schedule an agent from the marketplace.
@@ -129,9 +133,9 @@ async def run_agent(
- execution_started: If agent was run or scheduled successfully
- error: If something went wrong
"""
session = _create_ephemeral_session(api_key.user_id)
session = _create_ephemeral_session(auth.user_id)
result = await run_agent_tool._execute(
user_id=api_key.user_id,
user_id=auth.user_id,
session=session,
username_agent_slug=request.username_agent_slug,
inputs=request.inputs,

View File

@@ -5,6 +5,7 @@ from typing import Annotated, Any, Literal, Optional, Sequence
from fastapi import APIRouter, Body, HTTPException, Security
from prisma.enums import AgentExecutionStatus, APIKeyPermission
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
import backend.data.block
@@ -12,7 +13,8 @@ import backend.server.v2.store.cache as store_cache
import backend.server.v2.store.model as store_model
from backend.data import execution as execution_db
from backend.data import graph as graph_db
from backend.data.api_key import APIKeyInfo
from backend.data import user as user_db
from backend.data.auth.base import APIAuthorizationInfo
from backend.data.block import BlockInput, CompletedBlockOutput
from backend.executor.utils import add_graph_execution
from backend.server.external.middleware import require_permission
@@ -24,27 +26,33 @@ logger = logging.getLogger(__name__)
v1_router = APIRouter()
class NodeOutput(TypedDict):
key: str
value: Any
class UserInfoResponse(BaseModel):
id: str
name: Optional[str]
email: str
timezone: str = Field(
description="The user's last known timezone (e.g. 'Europe/Amsterdam'), "
"or 'not-set' if not set"
)
class ExecutionNode(TypedDict):
node_id: str
input: Any
output: dict[str, Any]
@v1_router.get(
path="/me",
tags=["user", "meta"],
)
async def get_user_info(
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.IDENTITY)
),
) -> UserInfoResponse:
user = await user_db.get_user_by_id(auth.user_id)
class ExecutionNodeOutput(TypedDict):
node_id: str
outputs: list[NodeOutput]
class GraphExecutionResult(TypedDict):
execution_id: str
status: str
nodes: list[ExecutionNode]
output: Optional[list[dict[str, str]]]
return UserInfoResponse(
id=user.id,
name=user.name,
email=user.email,
timezone=user.timezone,
)
@v1_router.get(
@@ -65,7 +73,9 @@ async def get_graph_blocks() -> Sequence[dict[Any, Any]]:
async def execute_graph_block(
block_id: str,
data: BlockInput,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.EXECUTE_BLOCK)),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.EXECUTE_BLOCK)
),
) -> CompletedBlockOutput:
obj = backend.data.block.get_block(block_id)
if not obj:
@@ -85,12 +95,14 @@ async def execute_graph(
graph_id: str,
graph_version: int,
node_input: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)],
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.EXECUTE_GRAPH)),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.EXECUTE_GRAPH)
),
) -> dict[str, Any]:
try:
graph_exec = await add_graph_execution(
graph_id=graph_id,
user_id=api_key.user_id,
user_id=auth.user_id,
inputs=node_input,
graph_version=graph_version,
)
@@ -100,6 +112,19 @@ async def execute_graph(
raise HTTPException(status_code=400, detail=msg)
class ExecutionNode(TypedDict):
node_id: str
input: Any
output: dict[str, Any]
class GraphExecutionResult(TypedDict):
execution_id: str
status: str
nodes: list[ExecutionNode]
output: Optional[list[dict[str, str]]]
@v1_router.get(
path="/graphs/{graph_id}/executions/{graph_exec_id}/results",
tags=["graphs"],
@@ -107,10 +132,12 @@ async def execute_graph(
async def get_graph_execution_results(
graph_id: str,
graph_exec_id: str,
api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.READ_GRAPH)),
auth: APIAuthorizationInfo = Security(
require_permission(APIKeyPermission.READ_GRAPH)
),
) -> GraphExecutionResult:
graph_exec = await execution_db.get_graph_execution(
user_id=api_key.user_id,
user_id=auth.user_id,
execution_id=graph_exec_id,
include_node_executions=True,
)
@@ -122,7 +149,7 @@ async def get_graph_execution_results(
if not await graph_db.get_graph(
graph_id=graph_exec.graph_id,
version=graph_exec.graph_version,
user_id=api_key.user_id,
user_id=auth.user_id,
):
raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.")

View File

@@ -4,7 +4,7 @@ from typing import Any, Literal, Optional
import pydantic
from prisma.enums import OnboardingStep
from backend.data.api_key import APIKeyInfo, APIKeyPermission
from backend.data.auth.api_key import APIKeyInfo, APIKeyPermission
from backend.data.graph import Graph
from backend.util.timezone_name import TimeZoneName

View File

@@ -21,6 +21,7 @@ import backend.data.db
import backend.data.graph
import backend.data.user
import backend.integrations.webhooks.utils
import backend.server.routers.oauth
import backend.server.routers.postmark.postmark
import backend.server.routers.v1
import backend.server.v2.admin.credit_admin_routes
@@ -297,6 +298,11 @@ app.include_router(
tags=["v2", "chat"],
prefix="/api/chat",
)
app.include_router(
backend.server.routers.oauth.router,
tags=["oauth"],
prefix="/api/oauth",
)
app.mount("/external-api", external_app)

View File

@@ -0,0 +1,833 @@
"""
OAuth 2.0 Provider Endpoints
Implements OAuth 2.0 Authorization Code flow with PKCE support.
Flow:
1. User clicks "Login with AutoGPT" in 3rd party app
2. App redirects user to /oauth/authorize with client_id, redirect_uri, scope, state
3. User sees consent screen (if not already logged in, redirects to login first)
4. User approves → backend creates authorization code
5. User redirected back to app with code
6. App exchanges code for access/refresh tokens at /oauth/token
7. App uses access token to call external API endpoints
"""
import io
import logging
import os
import uuid
from datetime import datetime
from typing import Literal, Optional
from urllib.parse import urlencode
from autogpt_libs.auth import get_user_id
from fastapi import APIRouter, Body, HTTPException, Security, UploadFile, status
from gcloud.aio import storage as async_storage
from PIL import Image
from prisma.enums import APIKeyPermission
from pydantic import BaseModel, Field
from backend.data.auth.oauth import (
InvalidClientError,
InvalidGrantError,
OAuthApplicationInfo,
TokenIntrospectionResult,
consume_authorization_code,
create_access_token,
create_authorization_code,
create_refresh_token,
get_oauth_application,
get_oauth_application_by_id,
introspect_token,
list_user_oauth_applications,
refresh_tokens,
revoke_access_token,
revoke_refresh_token,
update_oauth_application,
validate_client_credentials,
validate_redirect_uri,
validate_scopes,
)
from backend.util.settings import Settings
from backend.util.virus_scanner import scan_content_safe
settings = Settings()
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# Request/Response Models
# ============================================================================
class TokenResponse(BaseModel):
"""OAuth 2.0 token response"""
token_type: Literal["Bearer"] = "Bearer"
access_token: str
access_token_expires_at: datetime
refresh_token: str
refresh_token_expires_at: datetime
scopes: list[str]
class ErrorResponse(BaseModel):
"""OAuth 2.0 error response"""
error: str
error_description: Optional[str] = None
class OAuthApplicationPublicInfo(BaseModel):
"""Public information about an OAuth application (for consent screen)"""
name: str
description: Optional[str] = None
logo_url: Optional[str] = None
scopes: list[str]
# ============================================================================
# Application Info Endpoint
# ============================================================================
@router.get(
"/app/{client_id}",
responses={
404: {"description": "Application not found or disabled"},
},
)
async def get_oauth_app_info(
client_id: str, user_id: str = Security(get_user_id)
) -> OAuthApplicationPublicInfo:
"""
Get public information about an OAuth application.
This endpoint is used by the consent screen to display application details
to the user before they authorize access.
Returns:
- name: Application name
- description: Application description (if provided)
- scopes: List of scopes the application is allowed to request
"""
app = await get_oauth_application(client_id)
if not app or not app.is_active:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found",
)
return OAuthApplicationPublicInfo(
name=app.name,
description=app.description,
logo_url=app.logo_url,
scopes=[s.value for s in app.scopes],
)
# ============================================================================
# Authorization Endpoint
# ============================================================================
class AuthorizeRequest(BaseModel):
"""OAuth 2.0 authorization request"""
client_id: str = Field(description="Client identifier")
redirect_uri: str = Field(description="Redirect URI")
scopes: list[str] = Field(description="List of scopes")
state: str = Field(description="Anti-CSRF token from client")
response_type: str = Field(
default="code", description="Must be 'code' for authorization code flow"
)
code_challenge: str = Field(description="PKCE code challenge (required)")
code_challenge_method: Literal["S256", "plain"] = Field(
default="S256", description="PKCE code challenge method (S256 recommended)"
)
class AuthorizeResponse(BaseModel):
"""OAuth 2.0 authorization response with redirect URL"""
redirect_url: str = Field(description="URL to redirect the user to")
@router.post("/authorize")
async def authorize(
request: AuthorizeRequest = Body(),
user_id: str = Security(get_user_id),
) -> AuthorizeResponse:
"""
OAuth 2.0 Authorization Endpoint
User must be logged in (authenticated with Supabase JWT).
This endpoint creates an authorization code and returns a redirect URL.
PKCE (Proof Key for Code Exchange) is REQUIRED for all authorization requests.
The frontend consent screen should call this endpoint after the user approves,
then redirect the user to the returned `redirect_url`.
Request Body:
- client_id: The OAuth application's client ID
- redirect_uri: Where to redirect after authorization (must match registered URI)
- scopes: List of permissions (e.g., "EXECUTE_GRAPH READ_GRAPH")
- state: Anti-CSRF token provided by client (will be returned in redirect)
- response_type: Must be "code" (for authorization code flow)
- code_challenge: PKCE code challenge (required)
- code_challenge_method: "S256" (recommended) or "plain"
Returns:
- redirect_url: The URL to redirect the user to (includes authorization code)
Error cases return a redirect_url with error parameters, or raise HTTPException
for critical errors (like invalid redirect_uri).
"""
try:
# Validate response_type
if request.response_type != "code":
return _error_redirect_url(
request.redirect_uri,
request.state,
"unsupported_response_type",
"Only 'code' response type is supported",
)
# Get application
app = await get_oauth_application(request.client_id)
if not app:
return _error_redirect_url(
request.redirect_uri,
request.state,
"invalid_client",
"Unknown client_id",
)
if not app.is_active:
return _error_redirect_url(
request.redirect_uri,
request.state,
"invalid_client",
"Application is not active",
)
# Validate redirect URI
if not validate_redirect_uri(app, request.redirect_uri):
# For invalid redirect_uri, we can't redirect safely
# Must return error instead
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Invalid redirect_uri. "
f"Must be one of: {', '.join(app.redirect_uris)}"
),
)
# Parse and validate scopes
try:
requested_scopes = [APIKeyPermission(s.strip()) for s in request.scopes]
except ValueError as e:
return _error_redirect_url(
request.redirect_uri,
request.state,
"invalid_scope",
f"Invalid scope: {e}",
)
if not requested_scopes:
return _error_redirect_url(
request.redirect_uri,
request.state,
"invalid_scope",
"At least one scope is required",
)
if not validate_scopes(app, requested_scopes):
return _error_redirect_url(
request.redirect_uri,
request.state,
"invalid_scope",
"Application is not authorized for all requested scopes. "
f"Allowed: {', '.join(s.value for s in app.scopes)}",
)
# Create authorization code
auth_code = await create_authorization_code(
application_id=app.id,
user_id=user_id,
scopes=requested_scopes,
redirect_uri=request.redirect_uri,
code_challenge=request.code_challenge,
code_challenge_method=request.code_challenge_method,
)
# Build redirect URL with authorization code
params = {
"code": auth_code.code,
"state": request.state,
}
redirect_url = f"{request.redirect_uri}?{urlencode(params)}"
logger.info(
f"Authorization code issued for user #{user_id} "
f"and app {app.name} (#{app.id})"
)
return AuthorizeResponse(redirect_url=redirect_url)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error in authorization endpoint: {e}", exc_info=True)
return _error_redirect_url(
request.redirect_uri,
request.state,
"server_error",
"An unexpected error occurred",
)
def _error_redirect_url(
redirect_uri: str,
state: str,
error: str,
error_description: Optional[str] = None,
) -> AuthorizeResponse:
"""Helper to build redirect URL with OAuth error parameters"""
params = {
"error": error,
"state": state,
}
if error_description:
params["error_description"] = error_description
redirect_url = f"{redirect_uri}?{urlencode(params)}"
return AuthorizeResponse(redirect_url=redirect_url)
# ============================================================================
# Token Endpoint
# ============================================================================
class TokenRequestByCode(BaseModel):
grant_type: Literal["authorization_code"]
code: str = Field(description="Authorization code")
redirect_uri: str = Field(
description="Redirect URI (must match authorization request)"
)
client_id: str
client_secret: str
code_verifier: str = Field(description="PKCE code verifier")
class TokenRequestByRefreshToken(BaseModel):
grant_type: Literal["refresh_token"]
refresh_token: str
client_id: str
client_secret: str
@router.post("/token")
async def token(
request: TokenRequestByCode | TokenRequestByRefreshToken = Body(),
) -> TokenResponse:
"""
OAuth 2.0 Token Endpoint
Exchanges authorization code or refresh token for access token.
Grant Types:
1. authorization_code: Exchange authorization code for tokens
- Required: grant_type, code, redirect_uri, client_id, client_secret
- Optional: code_verifier (required if PKCE was used)
2. refresh_token: Exchange refresh token for new access token
- Required: grant_type, refresh_token, client_id, client_secret
Returns:
- access_token: Bearer token for API access (1 hour TTL)
- token_type: "Bearer"
- expires_in: Seconds until access token expires
- refresh_token: Token for refreshing access (30 days TTL)
- scopes: List of scopes
"""
# Validate client credentials
try:
app = await validate_client_credentials(
request.client_id, request.client_secret
)
except InvalidClientError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
# Handle authorization_code grant
if request.grant_type == "authorization_code":
# Consume authorization code
try:
user_id, scopes = await consume_authorization_code(
code=request.code,
application_id=app.id,
redirect_uri=request.redirect_uri,
code_verifier=request.code_verifier,
)
except InvalidGrantError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Create access and refresh tokens
access_token = await create_access_token(app.id, user_id, scopes)
refresh_token = await create_refresh_token(app.id, user_id, scopes)
logger.info(
f"Access token issued for user #{user_id} and app {app.name} (#{app.id})"
"via authorization code"
)
if not access_token.token or not refresh_token.token:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate tokens",
)
return TokenResponse(
token_type="Bearer",
access_token=access_token.token.get_secret_value(),
access_token_expires_at=access_token.expires_at,
refresh_token=refresh_token.token.get_secret_value(),
refresh_token_expires_at=refresh_token.expires_at,
scopes=list(s.value for s in scopes),
)
# Handle refresh_token grant
elif request.grant_type == "refresh_token":
# Refresh access token
try:
new_access_token, new_refresh_token = await refresh_tokens(
request.refresh_token, app.id
)
except InvalidGrantError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
logger.info(
f"Tokens refreshed for user #{new_access_token.user_id} "
f"by app {app.name} (#{app.id})"
)
if not new_access_token.token or not new_refresh_token.token:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to generate tokens",
)
return TokenResponse(
token_type="Bearer",
access_token=new_access_token.token.get_secret_value(),
access_token_expires_at=new_access_token.expires_at,
refresh_token=new_refresh_token.token.get_secret_value(),
refresh_token_expires_at=new_refresh_token.expires_at,
scopes=list(s.value for s in new_access_token.scopes),
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported grant_type: {request.grant_type}. "
"Must be 'authorization_code' or 'refresh_token'",
)
# ============================================================================
# Token Introspection Endpoint
# ============================================================================
@router.post("/introspect")
async def introspect(
token: str = Body(description="Token to introspect"),
token_type_hint: Optional[Literal["access_token", "refresh_token"]] = Body(
None, description="Hint about token type ('access_token' or 'refresh_token')"
),
client_id: str = Body(description="Client identifier"),
client_secret: str = Body(description="Client secret"),
) -> TokenIntrospectionResult:
"""
OAuth 2.0 Token Introspection Endpoint (RFC 7662)
Allows clients to check if a token is valid and get its metadata.
Returns:
- active: Whether the token is currently active
- scopes: List of authorized scopes (if active)
- client_id: The client the token was issued to (if active)
- user_id: The user the token represents (if active)
- exp: Expiration timestamp (if active)
- token_type: "access_token" or "refresh_token" (if active)
"""
# Validate client credentials
try:
await validate_client_credentials(client_id, client_secret)
except InvalidClientError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
# Introspect the token
return await introspect_token(token, token_type_hint)
# ============================================================================
# Token Revocation Endpoint
# ============================================================================
@router.post("/revoke")
async def revoke(
token: str = Body(description="Token to revoke"),
token_type_hint: Optional[Literal["access_token", "refresh_token"]] = Body(
None, description="Hint about token type ('access_token' or 'refresh_token')"
),
client_id: str = Body(description="Client identifier"),
client_secret: str = Body(description="Client secret"),
):
"""
OAuth 2.0 Token Revocation Endpoint (RFC 7009)
Allows clients to revoke an access or refresh token.
Note: Revoking a refresh token does NOT revoke associated access tokens.
Revoking an access token does NOT revoke the associated refresh token.
"""
# Validate client credentials
try:
app = await validate_client_credentials(client_id, client_secret)
except InvalidClientError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
)
# Try to revoke as access token first
# Note: We pass app.id to ensure the token belongs to the authenticated app
if token_type_hint != "refresh_token":
revoked = await revoke_access_token(token, app.id)
if revoked:
logger.info(
f"Access token revoked for app {app.name} (#{app.id}); "
f"user #{revoked.user_id}"
)
return {"status": "ok"}
# Try to revoke as refresh token
revoked = await revoke_refresh_token(token, app.id)
if revoked:
logger.info(
f"Refresh token revoked for app {app.name} (#{app.id}); "
f"user #{revoked.user_id}"
)
return {"status": "ok"}
# Per RFC 7009, revocation endpoint returns 200 even if token not found
# or if token belongs to a different application.
# This prevents token scanning attacks.
logger.warning(f"Unsuccessful token revocation attempt by app {app.name} #{app.id}")
return {"status": "ok"}
# ============================================================================
# Application Management Endpoints (for app owners)
# ============================================================================
@router.get("/apps/mine")
async def list_my_oauth_apps(
user_id: str = Security(get_user_id),
) -> list[OAuthApplicationInfo]:
"""
List all OAuth applications owned by the current user.
Returns a list of OAuth applications with their details including:
- id, name, description, logo_url
- client_id (public identifier)
- redirect_uris, grant_types, scopes
- is_active status
- created_at, updated_at timestamps
Note: client_secret is never returned for security reasons.
"""
return await list_user_oauth_applications(user_id)
@router.patch("/apps/{app_id}/status")
async def update_app_status(
app_id: str,
user_id: str = Security(get_user_id),
is_active: bool = Body(description="Whether the app should be active", embed=True),
) -> OAuthApplicationInfo:
"""
Enable or disable an OAuth application.
Only the application owner can update the status.
When disabled, the application cannot be used for new authorizations
and existing access tokens will fail validation.
Returns the updated application info.
"""
updated_app = await update_oauth_application(
app_id=app_id,
owner_id=user_id,
is_active=is_active,
)
if not updated_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found or you don't have permission to update it",
)
action = "enabled" if is_active else "disabled"
logger.info(f"OAuth app {updated_app.name} (#{app_id}) {action} by user #{user_id}")
return updated_app
class UpdateAppLogoRequest(BaseModel):
logo_url: str = Field(description="URL of the uploaded logo image")
@router.patch("/apps/{app_id}/logo")
async def update_app_logo(
app_id: str,
request: UpdateAppLogoRequest = Body(),
user_id: str = Security(get_user_id),
) -> OAuthApplicationInfo:
"""
Update the logo URL for an OAuth application.
Only the application owner can update the logo.
The logo should be uploaded first using the media upload endpoint,
then this endpoint is called with the resulting URL.
Logo requirements:
- Must be square (1:1 aspect ratio)
- Minimum 512x512 pixels
- Maximum 2048x2048 pixels
Returns the updated application info.
"""
if (
not (app := await get_oauth_application_by_id(app_id))
or app.owner_id != user_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth App not found",
)
# Delete the current app logo file (if any and it's in our cloud storage)
await _delete_app_current_logo_file(app)
updated_app = await update_oauth_application(
app_id=app_id,
owner_id=user_id,
logo_url=request.logo_url,
)
if not updated_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found or you don't have permission to update it",
)
logger.info(
f"OAuth app {updated_app.name} (#{app_id}) logo updated by user #{user_id}"
)
return updated_app
# Logo upload constraints
LOGO_MIN_SIZE = 512
LOGO_MAX_SIZE = 2048
LOGO_ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
LOGO_MAX_FILE_SIZE = 3 * 1024 * 1024 # 3MB
@router.post("/apps/{app_id}/logo/upload")
async def upload_app_logo(
app_id: str,
file: UploadFile,
user_id: str = Security(get_user_id),
) -> OAuthApplicationInfo:
"""
Upload a logo image for an OAuth application.
Requirements:
- Image must be square (1:1 aspect ratio)
- Minimum 512x512 pixels
- Maximum 2048x2048 pixels
- Allowed formats: JPEG, PNG, WebP
- Maximum file size: 3MB
The image is uploaded to cloud storage and the app's logoUrl is updated.
Returns the updated application info.
"""
# Verify ownership to reduce vulnerability to DoS(torage) or DoM(oney) attacks
if (
not (app := await get_oauth_application_by_id(app_id))
or app.owner_id != user_id
):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OAuth App not found",
)
# Check GCS configuration
if not settings.config.media_gcs_bucket_name:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Media storage is not configured",
)
# Validate content type
content_type = file.content_type
if content_type not in LOGO_ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: JPEG, PNG, WebP. Got: {content_type}",
)
# Read file content
try:
file_bytes = await file.read()
except Exception as e:
logger.error(f"Error reading logo file: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to read uploaded file",
)
# Check file size
if len(file_bytes) > LOGO_MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"File too large. "
f"Maximum size is {LOGO_MAX_FILE_SIZE // 1024 // 1024}MB"
),
)
# Validate image dimensions
try:
image = Image.open(io.BytesIO(file_bytes))
width, height = image.size
if width != height:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Logo must be square. Got {width}x{height}",
)
if width < LOGO_MIN_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Logo too small. Minimum {LOGO_MIN_SIZE}x{LOGO_MIN_SIZE}. "
f"Got {width}x{height}",
)
if width > LOGO_MAX_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Logo too large. Maximum {LOGO_MAX_SIZE}x{LOGO_MAX_SIZE}. "
f"Got {width}x{height}",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error validating logo image: {e}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid image file",
)
# Scan for viruses
filename = file.filename or "logo"
await scan_content_safe(file_bytes, filename=filename)
# Generate unique filename
file_ext = os.path.splitext(filename)[1].lower() or ".png"
unique_filename = f"{uuid.uuid4()}{file_ext}"
storage_path = f"oauth-apps/{app_id}/logo/{unique_filename}"
# Upload to GCS
try:
async with async_storage.Storage() as async_client:
bucket_name = settings.config.media_gcs_bucket_name
await async_client.upload(
bucket_name, storage_path, file_bytes, content_type=content_type
)
logo_url = f"https://storage.googleapis.com/{bucket_name}/{storage_path}"
except Exception as e:
logger.error(f"Error uploading logo to GCS: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to upload logo",
)
# Delete the current app logo file (if any and it's in our cloud storage)
await _delete_app_current_logo_file(app)
# Update the app with the new logo URL
updated_app = await update_oauth_application(
app_id=app_id,
owner_id=user_id,
logo_url=logo_url,
)
if not updated_app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Application not found or you don't have permission to update it",
)
logger.info(
f"OAuth app {updated_app.name} (#{app_id}) logo uploaded by user #{user_id}"
)
return updated_app
async def _delete_app_current_logo_file(app: OAuthApplicationInfo):
"""
Delete the current logo file for the given app, if there is one in our cloud storage
"""
bucket_name = settings.config.media_gcs_bucket_name
storage_base_url = f"https://storage.googleapis.com/{bucket_name}/"
if app.logo_url and app.logo_url.startswith(storage_base_url):
# Parse blob path from URL: https://storage.googleapis.com/{bucket}/{path}
old_path = app.logo_url.replace(storage_base_url, "")
try:
async with async_storage.Storage() as async_client:
await async_client.delete(bucket_name, old_path)
logger.info(f"Deleted old logo for OAuth app #{app.id}: {old_path}")
except Exception as e:
# Log but don't fail - the new logo was uploaded successfully
logger.warning(
f"Failed to delete old logo for OAuth app #{app.id}: {e}", exc_info=e
)

File diff suppressed because it is too large Load Diff

View File

@@ -31,9 +31,9 @@ from typing_extensions import Optional, TypedDict
import backend.server.integrations.router
import backend.server.routers.analytics
import backend.server.v2.library.db as library_db
from backend.data import api_key as api_key_db
from backend.data import execution as execution_db
from backend.data import graph as graph_db
from backend.data.auth import api_key as api_key_db
from backend.data.block import BlockInput, CompletedBlockOutput, get_block, get_blocks
from backend.data.credit import (
AutoTopUpConfig,

View File

@@ -134,18 +134,14 @@ async def process_review_action(
# Build review decisions map
review_decisions = {}
for review in request.reviews:
if review.approved:
review_decisions[review.node_exec_id] = (
ReviewStatus.APPROVED,
review.reviewed_data,
review.message,
)
else:
review_decisions[review.node_exec_id] = (
ReviewStatus.REJECTED,
None,
review.message,
)
review_status = (
ReviewStatus.APPROVED if review.approved else ReviewStatus.REJECTED
)
review_decisions[review.node_exec_id] = (
review_status,
review.reviewed_data,
review.message,
)
# Process all reviews
updated_reviews = await process_all_reviews_for_execution(

View File

@@ -14,12 +14,47 @@ from backend.util.virus_scanner import scan_content_safe
TEMP_DIR = Path(tempfile.gettempdir()).resolve()
# Maximum filename length (conservative limit for most filesystems)
MAX_FILENAME_LENGTH = 200
def sanitize_filename(filename: str) -> str:
"""
Sanitize and truncate filename to prevent filesystem errors.
"""
# Remove or replace invalid characters
sanitized = re.sub(r'[<>:"/\\|?*\n\r\t]', "_", filename)
# Truncate if too long
if len(sanitized) > MAX_FILENAME_LENGTH:
# Keep the extension if possible
if "." in sanitized:
name, ext = sanitized.rsplit(".", 1)
max_name_length = MAX_FILENAME_LENGTH - len(ext) - 1
sanitized = name[:max_name_length] + "." + ext
else:
sanitized = sanitized[:MAX_FILENAME_LENGTH]
# Ensure it's not empty or just dots
if not sanitized or sanitized.strip(".") == "":
sanitized = f"file_{uuid.uuid4().hex[:8]}"
return sanitized
def get_exec_file_path(graph_exec_id: str, path: str) -> str:
"""
Utility to build an absolute path in the {temp}/exec_file/{exec_id}/... folder.
"""
return str(TEMP_DIR / "exec_file" / graph_exec_id / path)
try:
full_path = TEMP_DIR / "exec_file" / graph_exec_id / path
return str(full_path)
except OSError as e:
if "File name too long" in str(e):
raise ValueError(
f"File path too long: {len(path)} characters. Maximum path length exceeded."
) from e
raise ValueError(f"Invalid file path: {e}") from e
def clean_exec_files(graph_exec_id: str, file: str = "") -> None:
@@ -117,8 +152,11 @@ async def store_media_file(
# Generate filename from cloud path
_, path_part = cloud_storage.parse_cloud_path(file)
filename = Path(path_part).name or f"{uuid.uuid4()}.bin"
target_path = _ensure_inside_base(base_path / filename, base_path)
filename = sanitize_filename(Path(path_part).name or f"{uuid.uuid4()}.bin")
try:
target_path = _ensure_inside_base(base_path / filename, base_path)
except OSError as e:
raise ValueError(f"Invalid file path '{filename}': {e}") from e
# Check file size limit
if len(cloud_content) > MAX_FILE_SIZE:
@@ -144,7 +182,10 @@ async def store_media_file(
# Generate filename and decode
extension = _extension_from_mime(mime_type)
filename = f"{uuid.uuid4()}{extension}"
target_path = _ensure_inside_base(base_path / filename, base_path)
try:
target_path = _ensure_inside_base(base_path / filename, base_path)
except OSError as e:
raise ValueError(f"Invalid file path '{filename}': {e}") from e
content = base64.b64decode(b64_content)
# Check file size limit
@@ -160,8 +201,11 @@ async def store_media_file(
elif file.startswith(("http://", "https://")):
# URL
parsed_url = urlparse(file)
filename = Path(parsed_url.path).name or f"{uuid.uuid4()}"
target_path = _ensure_inside_base(base_path / filename, base_path)
filename = sanitize_filename(Path(parsed_url.path).name or f"{uuid.uuid4()}")
try:
target_path = _ensure_inside_base(base_path / filename, base_path)
except OSError as e:
raise ValueError(f"Invalid file path '{filename}': {e}") from e
# Download and save
resp = await Requests().get(file)
@@ -177,8 +221,12 @@ async def store_media_file(
target_path.write_bytes(resp.content)
else:
# Local path
target_path = _ensure_inside_base(base_path / file, base_path)
# Local path - sanitize the filename part to prevent long filename errors
sanitized_file = sanitize_filename(file)
try:
target_path = _ensure_inside_base(base_path / sanitized_file, base_path)
except OSError as e:
raise ValueError(f"Invalid file path '{sanitized_file}': {e}") from e
if not target_path.is_file():
raise ValueError(f"Local file does not exist: {target_path}")

View File

@@ -21,6 +21,26 @@ from tenacity import (
from backend.util.json import loads
class HTTPClientError(Exception):
"""4xx client errors (400-499)"""
def __init__(self, message: str, status_code: int):
super().__init__(message)
self.status_code = status_code
class HTTPServerError(Exception):
"""5xx server errors (500-599)"""
def __init__(self, message: str, status_code: int):
super().__init__(message)
self.status_code = status_code
# Default User-Agent for all requests
DEFAULT_USER_AGENT = "AutoGPT-Platform/1.0 (https://github.com/Significant-Gravitas/AutoGPT; info@agpt.co) aiohttp"
# Retry status codes for which we will automatically retry the request
THROTTLE_RETRY_STATUS_CODES: set[int] = {429, 500, 502, 503, 504, 408}
@@ -450,6 +470,10 @@ class Requests:
if self.extra_headers is not None:
req_headers.update(self.extra_headers)
# Set default User-Agent if not provided
if "User-Agent" not in req_headers and "user-agent" not in req_headers:
req_headers["User-Agent"] = DEFAULT_USER_AGENT
# Override Host header if using IP connection
if connector:
req_headers["Host"] = hostname
@@ -476,9 +500,16 @@ class Requests:
response.raise_for_status()
except ClientResponseError as e:
body = await response.read()
raise Exception(
f"HTTP {response.status} Error: {response.reason}, Body: {body.decode(errors='replace')}"
) from e
error_message = f"HTTP {response.status} Error: {response.reason}, Body: {body.decode(errors='replace')}"
# Raise specific exceptions based on status code range
if 400 <= response.status <= 499:
raise HTTPClientError(error_message, response.status) from e
elif 500 <= response.status <= 599:
raise HTTPServerError(error_message, response.status) from e
else:
# Generic fallback for other HTTP errors
raise Exception(error_message) from e
# If allowed and a redirect is received, follow the redirect manually
if allow_redirects and response.status in (301, 302, 303, 307, 308):

View File

@@ -362,6 +362,13 @@ class Config(UpdateTrackingModel["Config"], BaseSettings):
description="Hours between cloud storage cleanup runs (1-24 hours)",
)
oauth_token_cleanup_interval_hours: int = Field(
default=6,
ge=1,
le=24,
description="Hours between OAuth token cleanup runs (1-24 hours)",
)
upload_file_size_limit_mb: int = Field(
default=256,
ge=1,

View File

@@ -5,6 +5,13 @@ from typing import Any, Type, TypeVar, Union, cast, get_args, get_origin, overlo
from prisma import Json as PrismaJson
def _is_type_or_subclass(origin: Any, target_type: type) -> bool:
"""Check if origin is exactly the target type or a subclass of it."""
return origin is target_type or (
isinstance(origin, type) and issubclass(origin, target_type)
)
class ConversionError(ValueError):
pass
@@ -138,7 +145,11 @@ def _try_convert(value: Any, target_type: Any, raise_on_mismatch: bool) -> Any:
if origin is None:
origin = target_type
if origin not in [list, dict, tuple, str, set, int, float, bool]:
# Early return for unsupported types (skip subclasses of supported types)
supported_types = [list, dict, tuple, str, set, int, float, bool]
if origin not in supported_types and not (
isinstance(origin, type) and any(issubclass(origin, t) for t in supported_types)
):
return value
# Handle the case when value is already of the target type
@@ -168,44 +179,47 @@ def _try_convert(value: Any, target_type: Any, raise_on_mismatch: bool) -> Any:
raise TypeError(f"Value {value} is not of expected type {target_type}")
else:
# Need to convert value to the origin type
if origin is list:
value = __convert_list(value)
if _is_type_or_subclass(origin, list):
converted_list = __convert_list(value)
if args:
return [convert(v, args[0]) for v in value]
else:
return value
elif origin is dict:
value = __convert_dict(value)
converted_list = [convert(v, args[0]) for v in converted_list]
return origin(converted_list) if origin is not list else converted_list
elif _is_type_or_subclass(origin, dict):
converted_dict = __convert_dict(value)
if args:
key_type, val_type = args
return {
convert(k, key_type): convert(v, val_type) for k, v in value.items()
converted_dict = {
convert(k, key_type): convert(v, val_type)
for k, v in converted_dict.items()
}
else:
return value
elif origin is tuple:
value = __convert_tuple(value)
return origin(converted_dict) if origin is not dict else converted_dict
elif _is_type_or_subclass(origin, tuple):
converted_tuple = __convert_tuple(value)
if args:
if len(args) == 1:
return tuple(convert(v, args[0]) for v in value)
converted_tuple = tuple(
convert(v, args[0]) for v in converted_tuple
)
else:
return tuple(convert(v, t) for v, t in zip(value, args))
else:
return value
elif origin is str:
return __convert_str(value)
elif origin is set:
converted_tuple = tuple(
convert(v, t) for v, t in zip(converted_tuple, args)
)
return origin(converted_tuple) if origin is not tuple else converted_tuple
elif _is_type_or_subclass(origin, str):
converted_str = __convert_str(value)
return origin(converted_str) if origin is not str else converted_str
elif _is_type_or_subclass(origin, set):
value = __convert_set(value)
if args:
return {convert(v, args[0]) for v in value}
else:
return value
elif origin is int:
return __convert_num(value, int)
elif origin is float:
return __convert_num(value, float)
elif origin is bool:
elif _is_type_or_subclass(origin, bool):
return __convert_bool(value)
elif _is_type_or_subclass(origin, int):
return __convert_num(value, int)
elif _is_type_or_subclass(origin, float):
return __convert_num(value, float)
else:
return value

View File

@@ -32,3 +32,17 @@ def test_type_conversion():
assert convert("5", List[int]) == [5]
assert convert("[5,4,2]", List[int]) == [5, 4, 2]
assert convert([5, 4, 2], List[str]) == ["5", "4", "2"]
# Test the specific case that was failing: empty list to Optional[str]
assert convert([], Optional[str]) == "[]"
assert convert([], str) == "[]"
# Test the actual failing case: empty list to ShortTextType
from backend.util.type import ShortTextType
assert convert([], Optional[ShortTextType]) == "[]"
assert convert([], ShortTextType) == "[]"
# Test other empty list conversions
assert convert([], int) == 0 # len([]) = 0
assert convert([], Optional[int]) == 0

View File

@@ -0,0 +1,129 @@
-- CreateTable
CREATE TABLE "OAuthApplication" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"clientId" TEXT NOT NULL,
"clientSecret" TEXT NOT NULL,
"clientSecretSalt" TEXT NOT NULL,
"redirectUris" TEXT[],
"grantTypes" TEXT[] DEFAULT ARRAY['authorization_code', 'refresh_token']::TEXT[],
"scopes" "APIKeyPermission"[],
"ownerId" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "OAuthApplication_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthAuthorizationCode" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"applicationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scopes" "APIKeyPermission"[],
"redirectUri" TEXT NOT NULL,
"codeChallenge" TEXT,
"codeChallengeMethod" TEXT,
"usedAt" TIMESTAMP(3),
CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthAccessToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"applicationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scopes" "APIKeyPermission"[],
"revokedAt" TIMESTAMP(3),
CONSTRAINT "OAuthAccessToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OAuthRefreshToken" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"applicationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"scopes" "APIKeyPermission"[],
"revokedAt" TIMESTAMP(3),
CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "OAuthApplication_clientId_key" ON "OAuthApplication"("clientId");
-- CreateIndex
CREATE INDEX "OAuthApplication_clientId_idx" ON "OAuthApplication"("clientId");
-- CreateIndex
CREATE INDEX "OAuthApplication_ownerId_idx" ON "OAuthApplication"("ownerId");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAuthorizationCode_code_key" ON "OAuthAuthorizationCode"("code");
-- CreateIndex
CREATE INDEX "OAuthAuthorizationCode_code_idx" ON "OAuthAuthorizationCode"("code");
-- CreateIndex
CREATE INDEX "OAuthAuthorizationCode_applicationId_userId_idx" ON "OAuthAuthorizationCode"("applicationId", "userId");
-- CreateIndex
CREATE INDEX "OAuthAuthorizationCode_expiresAt_idx" ON "OAuthAuthorizationCode"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthAccessToken_token_key" ON "OAuthAccessToken"("token");
-- CreateIndex
CREATE INDEX "OAuthAccessToken_token_idx" ON "OAuthAccessToken"("token");
-- CreateIndex
CREATE INDEX "OAuthAccessToken_userId_applicationId_idx" ON "OAuthAccessToken"("userId", "applicationId");
-- CreateIndex
CREATE INDEX "OAuthAccessToken_expiresAt_idx" ON "OAuthAccessToken"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "OAuthRefreshToken_token_key" ON "OAuthRefreshToken"("token");
-- CreateIndex
CREATE INDEX "OAuthRefreshToken_token_idx" ON "OAuthRefreshToken"("token");
-- CreateIndex
CREATE INDEX "OAuthRefreshToken_userId_applicationId_idx" ON "OAuthRefreshToken"("userId", "applicationId");
-- CreateIndex
CREATE INDEX "OAuthRefreshToken_expiresAt_idx" ON "OAuthRefreshToken"("expiresAt");
-- AddForeignKey
ALTER TABLE "OAuthApplication" ADD CONSTRAINT "OAuthApplication_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAuthorizationCode" ADD CONSTRAINT "OAuthAuthorizationCode_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthAccessToken" ADD CONSTRAINT "OAuthAccessToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "OAuthApplication"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OAuthRefreshToken" ADD CONSTRAINT "OAuthRefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- AlterEnum
ALTER TYPE "APIKeyPermission" ADD VALUE 'IDENTITY';
-- AlterTable
ALTER TABLE "OAuthApplication" ADD COLUMN "logoUrl" TEXT;

View File

@@ -115,6 +115,8 @@ format = "linter:format"
lint = "linter:lint"
test = "run_tests:test"
load-store-agents = "test.load_store_agents:run"
export-api-schema = "backend.cli.generate_openapi_json:main"
oauth-tool = "backend.cli.oauth_tool:cli"
[tool.isort]
profile = "black"

View File

@@ -61,6 +61,12 @@ model User {
IntegrationWebhooks IntegrationWebhook[]
NotificationBatches UserNotificationBatch[]
PendingHumanReviews PendingHumanReview[]
// OAuth Provider relations
OAuthApplications OAuthApplication[]
OAuthAuthorizationCodes OAuthAuthorizationCode[]
OAuthAccessTokens OAuthAccessToken[]
OAuthRefreshTokens OAuthRefreshToken[]
}
enum OnboardingStep {
@@ -924,6 +930,7 @@ enum SubmissionStatus {
}
enum APIKeyPermission {
IDENTITY // Info about the authenticated user
EXECUTE_GRAPH // Can execute agent graphs
READ_GRAPH // Can get graph versions and details
EXECUTE_BLOCK // Can execute individual blocks
@@ -975,3 +982,113 @@ enum APIKeyStatus {
REVOKED
SUSPENDED
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
////////////// OAUTH PROVIDER TABLES //////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
// OAuth2 applications that can access AutoGPT on behalf of users
model OAuthApplication {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Application metadata
name String
description String?
logoUrl String? // URL to app logo stored in GCS
clientId String @unique
clientSecret String // Hashed with Scrypt (same as API keys)
clientSecretSalt String // Salt for Scrypt hashing
// OAuth configuration
redirectUris String[] // Allowed callback URLs
grantTypes String[] @default(["authorization_code", "refresh_token"])
scopes APIKeyPermission[] // Which permissions the app can request
// Application management
ownerId String
Owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
isActive Boolean @default(true)
// Relations
AuthorizationCodes OAuthAuthorizationCode[]
AccessTokens OAuthAccessToken[]
RefreshTokens OAuthRefreshToken[]
@@index([clientId])
@@index([ownerId])
}
// Temporary authorization codes (10 min TTL)
model OAuthAuthorizationCode {
id String @id @default(uuid())
code String @unique
createdAt DateTime @default(now())
expiresAt DateTime // Now + 10 minutes
applicationId String
Application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
scopes APIKeyPermission[]
redirectUri String // Must match one from application
// PKCE (Proof Key for Code Exchange) support
codeChallenge String?
codeChallengeMethod String? // "S256" or "plain"
usedAt DateTime? // Set when code is consumed
@@index([code])
@@index([applicationId, userId])
@@index([expiresAt]) // For cleanup
}
// Access tokens (1 hour TTL)
model OAuthAccessToken {
id String @id @default(uuid())
token String @unique // SHA256 hash of plaintext token
createdAt DateTime @default(now())
expiresAt DateTime // Now + 1 hour
applicationId String
Application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
scopes APIKeyPermission[]
revokedAt DateTime? // Set when token is revoked
@@index([token]) // For token lookup
@@index([userId, applicationId])
@@index([expiresAt]) // For cleanup
}
// Refresh tokens (30 days TTL)
model OAuthRefreshToken {
id String @id @default(uuid())
token String @unique // SHA256 hash of plaintext token
createdAt DateTime @default(now())
expiresAt DateTime // Now + 30 days
applicationId String
Application OAuthApplication @relation(fields: [applicationId], references: [id], onDelete: Cascade)
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
scopes APIKeyPermission[]
revokedAt DateTime? // Set when token is revoked
@@index([token]) // For token lookup
@@index([userId, applicationId])
@@index([expiresAt]) // For cleanup
}

View File

@@ -23,13 +23,13 @@ from typing import Any, Dict, List
from faker import Faker
from backend.data.api_key import create_api_key
from backend.data.auth.api_key import create_api_key
from backend.data.credit import get_user_credit_model
from backend.data.db import prisma
from backend.data.graph import Graph, Link, Node, create_graph
from backend.data.user import get_or_create_user
# Import API functions from the backend
from backend.data.user import get_or_create_user
from backend.server.v2.library.db import create_library_agent, create_preset
from backend.server.v2.library.model import LibraryAgentPresetCreatable
from backend.server.v2.store.db import create_store_submission, review_store_submission
@@ -464,7 +464,7 @@ class TestDataCreator:
api_keys = []
for user in self.users:
from backend.data.api_key import APIKeyPermission
from backend.data.auth.api_key import APIKeyPermission
try:
# Use the API function to create API key

View File

@@ -3,6 +3,14 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
experimental: {
serverActions: {
bodySizeLimit: "256mb",
},
// Increase body size limit for API routes (file uploads) - 256MB to match backend limit
proxyClientMaxBodySize: "256mb",
middlewareClientMaxBodySize: "256mb",
},
images: {
domains: [
// We dont need to maintain alphabetical order here

View File

@@ -137,9 +137,8 @@
"concurrently": "9.2.1",
"cross-env": "10.1.0",
"eslint": "8.57.1",
"eslint-config-next": "15.5.2",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "1.14.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"orval": "7.13.0",

View File

@@ -331,14 +331,11 @@ importers:
specifier: 8.57.1
version: 8.57.1
eslint-config-next:
specifier: 15.5.2
version: 15.5.2(eslint@8.57.1)(typescript@5.9.3)
specifier: 15.5.7
version: 15.5.7(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-storybook:
specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 1.14.2
version: 1.14.2
msw:
specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -986,12 +983,15 @@ packages:
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@emnapi/core@1.5.0':
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
'@emnapi/core@1.7.1':
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
'@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
@@ -1329,6 +1329,10 @@ packages:
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint-community/regexpp@4.12.2':
resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/eslintrc@2.1.4':
resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1605,8 +1609,8 @@ packages:
'@next/env@15.4.10':
resolution: {integrity: sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==}
'@next/eslint-plugin-next@15.5.2':
resolution: {integrity: sha512-lkLrRVxcftuOsJNhWatf1P2hNVfh98k/omQHrCEPPriUypR6RcS13IvLdIrEvkm9AH2Nu2YpR5vLqBuy6twH3Q==}
'@next/eslint-plugin-next@15.5.7':
resolution: {integrity: sha512-DtRU2N7BkGr8r+pExfuWHwMEPX5SD57FeA6pxdgCHODo+b/UgIgjE+rgWKtJAbEbGhVZ2jtHn4g3wNhWFoNBQQ==}
'@next/swc-darwin-arm64@15.4.8':
resolution: {integrity: sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==}
@@ -2622,8 +2626,8 @@ packages:
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
'@rushstack/eslint-patch@1.12.0':
resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==}
'@rushstack/eslint-patch@1.15.0':
resolution: {integrity: sha512-ojSshQPKwVvSMR8yT2L/QtUkV5SXi/IfDiJ4/8d6UbTPjiHVmxZzUAzGD8Tzks1b9+qQkZa0isUOvYObedITaw==}
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
@@ -3097,8 +3101,8 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@tybys/wasm-util@0.10.0':
resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -3288,16 +3292,16 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@typescript-eslint/eslint-plugin@8.43.0':
resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==}
'@typescript-eslint/eslint-plugin@8.48.1':
resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.43.0
'@typescript-eslint/parser': ^8.48.1
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.43.0':
resolution: {integrity: sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==}
'@typescript-eslint/parser@8.48.1':
resolution: {integrity: sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -3315,6 +3319,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.48.1':
resolution: {integrity: sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.43.0':
resolution: {integrity: sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3323,6 +3333,10 @@ packages:
resolution: {integrity: sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/scope-manager@8.48.1':
resolution: {integrity: sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.43.0':
resolution: {integrity: sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3335,8 +3349,14 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.43.0':
resolution: {integrity: sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==}
'@typescript-eslint/tsconfig-utils@8.48.1':
resolution: {integrity: sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.48.1':
resolution: {integrity: sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
@@ -3350,6 +3370,10 @@ packages:
resolution: {integrity: sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/types@8.48.1':
resolution: {integrity: sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.43.0':
resolution: {integrity: sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3362,6 +3386,12 @@ packages:
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/typescript-estree@8.48.1':
resolution: {integrity: sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.43.0':
resolution: {integrity: sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3376,6 +3406,13 @@ packages:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.48.1':
resolution: {integrity: sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.43.0':
resolution: {integrity: sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3384,6 +3421,10 @@ packages:
resolution: {integrity: sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/visitor-keys@8.48.1':
resolution: {integrity: sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -4585,8 +4626,8 @@ packages:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-next@15.5.2:
resolution: {integrity: sha512-3hPZghsLupMxxZ2ggjIIrat/bPniM2yRpsVPVM40rp8ZMzKWOJp2CGWn7+EzoV2ddkUr5fxNfHpF+wU1hGt/3g==}
eslint-config-next@15.5.7:
resolution: {integrity: sha512-nU/TRGHHeG81NeLW5DeQT5t6BDUqbpsNQTvef1ld/tqHT+/zTx60/TIhKnmPISTTe++DVo+DLxDmk4rnwHaZVw==}
peerDependencies:
eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: '>=3.3.1'
@@ -4918,6 +4959,10 @@ packages:
peerDependencies:
next: '>=13.2.0'
generator-function@2.0.1:
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
engines: {node: '>= 0.4'}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -4946,8 +4991,8 @@ packages:
resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
engines: {node: '>= 0.4'}
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
get-tsconfig@4.13.0:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
@@ -5168,9 +5213,6 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@1.14.2:
resolution: {integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==}
import-in-the-middle@2.0.0:
resolution: {integrity: sha512-yNZhyQYqXpkT0AKq3F3KLasUSK4fHvebNH5hOsKQw2dhGSALvQ4U0BqUc5suziKvydO5u5hgN2hy1RJaho8U5A==}
@@ -5282,6 +5324,10 @@ packages:
resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
engines: {node: '>= 0.4'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@@ -5903,8 +5949,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-postinstall@0.3.3:
resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==}
napi-postinstall@0.3.4:
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
hasBin: true
@@ -6769,6 +6815,11 @@ packages:
engines: {node: '>= 0.4'}
hasBin: true
resolve@1.22.11:
resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==}
engines: {node: '>= 0.4'}
hasBin: true
resolve@1.22.8:
resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==}
hasBin: true
@@ -7858,7 +7909,7 @@ snapshots:
'@babel/helper-plugin-utils': 7.27.1
debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.10
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
@@ -8550,7 +8601,7 @@ snapshots:
'@date-fns/tz@1.4.1': {}
'@emnapi/core@1.5.0':
'@emnapi/core@1.7.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
@@ -8561,6 +8612,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/runtime@1.7.1':
dependencies:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
@@ -8739,6 +8795,8 @@ snapshots:
'@eslint-community/regexpp@4.12.1': {}
'@eslint-community/regexpp@4.12.2': {}
'@eslint/eslintrc@2.1.4':
dependencies:
ajv: 6.12.6
@@ -8996,16 +9054,16 @@ snapshots:
'@napi-rs/wasm-runtime@0.2.12':
dependencies:
'@emnapi/core': 1.5.0
'@emnapi/runtime': 1.5.0
'@tybys/wasm-util': 0.10.0
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@tybys/wasm-util': 0.10.1
optional: true
'@neoconfetti/react@1.0.0': {}
'@next/env@15.4.10': {}
'@next/eslint-plugin-next@15.5.2':
'@next/eslint-plugin-next@15.5.7':
dependencies:
fast-glob: 3.3.1
@@ -10115,7 +10173,7 @@ snapshots:
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.12.0': {}
'@rushstack/eslint-patch@1.15.0': {}
'@scarf/scarf@1.4.0': {}
@@ -10867,7 +10925,7 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.1
'@tybys/wasm-util@0.10.0':
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -11065,14 +11123,14 @@ snapshots:
dependencies:
'@types/node': 24.10.0
'@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.43.0
'@typescript-eslint/type-utils': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/utils': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.43.0
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/scope-manager': 8.48.1
'@typescript-eslint/type-utils': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/utils': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.48.1
eslint: 8.57.1
graphemer: 1.4.0
ignore: 7.0.5
@@ -11082,12 +11140,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3)':
'@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.43.0
'@typescript-eslint/types': 8.43.0
'@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.43.0
'@typescript-eslint/scope-manager': 8.48.1
'@typescript-eslint/types': 8.48.1
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.48.1
debug: 4.4.3
eslint: 8.57.1
typescript: 5.9.3
@@ -11097,7 +11155,7 @@ snapshots:
'@typescript-eslint/project-service@8.43.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.3)
'@typescript-eslint/types': 8.43.0
'@typescript-eslint/types': 8.48.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
@@ -11106,7 +11164,16 @@ snapshots:
'@typescript-eslint/project-service@8.46.2(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.46.2(typescript@5.9.3)
'@typescript-eslint/types': 8.46.2
'@typescript-eslint/types': 8.48.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.48.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3)
'@typescript-eslint/types': 8.48.1
debug: 4.4.3
typescript: 5.9.3
transitivePeerDependencies:
@@ -11122,6 +11189,11 @@ snapshots:
'@typescript-eslint/types': 8.46.2
'@typescript-eslint/visitor-keys': 8.46.2
'@typescript-eslint/scope-manager@8.48.1':
dependencies:
'@typescript-eslint/types': 8.48.1
'@typescript-eslint/visitor-keys': 8.48.1
'@typescript-eslint/tsconfig-utils@8.43.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
@@ -11130,11 +11202,15 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/type-utils@8.43.0(eslint@8.57.1)(typescript@5.9.3)':
'@typescript-eslint/tsconfig-utils@8.48.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.43.0
'@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.3)
'@typescript-eslint/utils': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
typescript: 5.9.3
'@typescript-eslint/type-utils@8.48.1(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.48.1
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
'@typescript-eslint/utils': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
debug: 4.4.3
eslint: 8.57.1
ts-api-utils: 2.1.0(typescript@5.9.3)
@@ -11146,6 +11222,8 @@ snapshots:
'@typescript-eslint/types@8.46.2': {}
'@typescript-eslint/types@8.48.1': {}
'@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.43.0(typescript@5.9.3)
@@ -11156,7 +11234,7 @@ snapshots:
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.2
semver: 7.7.3
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
@@ -11178,6 +11256,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.48.1(typescript@5.9.3)':
dependencies:
'@typescript-eslint/project-service': 8.48.1(typescript@5.9.3)
'@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.9.3)
'@typescript-eslint/types': 8.48.1
'@typescript-eslint/visitor-keys': 8.48.1
debug: 4.4.3
minimatch: 9.0.5
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.1.0(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.43.0(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1)
@@ -11200,6 +11293,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.48.1(eslint@8.57.1)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1)
'@typescript-eslint/scope-manager': 8.48.1
'@typescript-eslint/types': 8.48.1
'@typescript-eslint/typescript-estree': 8.48.1(typescript@5.9.3)
eslint: 8.57.1
typescript: 5.9.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.43.0':
dependencies:
'@typescript-eslint/types': 8.43.0
@@ -11210,6 +11314,11 @@ snapshots:
'@typescript-eslint/types': 8.46.2
eslint-visitor-keys: 4.2.1
'@typescript-eslint/visitor-keys@8.48.1':
dependencies:
'@typescript-eslint/types': 8.48.1
eslint-visitor-keys: 4.2.1
'@ungap/structured-clone@1.3.0': {}
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
@@ -12532,16 +12641,16 @@ snapshots:
escape-string-regexp@5.0.0: {}
eslint-config-next@15.5.2(eslint@8.57.1)(typescript@5.9.3):
eslint-config-next@15.5.7(eslint@8.57.1)(typescript@5.9.3):
dependencies:
'@next/eslint-plugin-next': 15.5.2
'@rushstack/eslint-patch': 1.12.0
'@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
'@next/eslint-plugin-next': 15.5.7
'@rushstack/eslint-patch': 1.15.0
'@typescript-eslint/eslint-plugin': 8.48.1(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
@@ -12556,7 +12665,7 @@ snapshots:
dependencies:
debug: 3.2.7
is-core-module: 2.16.1
resolve: 1.22.10
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
@@ -12565,28 +12674,28 @@ snapshots:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3
eslint: 8.57.1
get-tsconfig: 4.10.1
get-tsconfig: 4.13.0
is-bun-module: 2.0.0
stable-hash: 0.0.5
tinyglobby: 0.2.15
unrs-resolver: 1.11.1
optionalDependencies:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -12597,7 +12706,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -12609,7 +12718,7 @@ snapshots:
string.prototype.trimend: 1.0.9
tsconfig-paths: 3.15.0
optionalDependencies:
'@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 8.48.1(eslint@8.57.1)(typescript@5.9.3)
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
@@ -12958,6 +13067,8 @@ snapshots:
dependencies:
next: 15.4.10(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
generator-function@2.0.1: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
@@ -12990,7 +13101,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
get-tsconfig@4.10.1:
get-tsconfig@4.13.0:
dependencies:
resolve-pkg-maps: 1.0.0
@@ -13274,13 +13385,6 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@1.14.2:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
cjs-module-lexer: 1.4.3
module-details-from-path: 1.0.4
import-in-the-middle@2.0.0:
dependencies:
acorn: 8.15.0
@@ -13357,7 +13461,7 @@ snapshots:
is-bun-module@2.0.0:
dependencies:
semver: 7.7.2
semver: 7.7.3
is-callable@1.2.7: {}
@@ -13395,6 +13499,14 @@ snapshots:
has-tostringtag: 1.0.2
safe-regex-test: 1.1.0
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
generator-function: 2.0.1
get-proto: 1.0.1
has-tostringtag: 1.0.2
safe-regex-test: 1.1.0
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
@@ -14215,7 +14327,7 @@ snapshots:
nanoid@3.3.11: {}
napi-postinstall@0.3.3: {}
napi-postinstall@0.3.4: {}
natural-compare@1.4.0: {}
@@ -15185,6 +15297,12 @@ snapshots:
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.11:
dependencies:
is-core-module: 2.16.1
path-parse: 1.0.7
supports-preserve-symlinks-flag: 1.0.0
resolve@1.22.8:
dependencies:
is-core-module: 2.16.1
@@ -15996,7 +16114,7 @@ snapshots:
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.3
napi-postinstall: 0.3.4
optionalDependencies:
'@unrs/resolver-binding-android-arm-eabi': 1.11.1
'@unrs/resolver-binding-android-arm64': 1.11.1
@@ -16224,7 +16342,7 @@ snapshots:
is-async-function: 2.1.1
is-date-object: 1.1.0
is-finalizationregistry: 1.1.1
is-generator-function: 1.1.0
is-generator-function: 1.1.2
is-regex: 1.2.1
is-weakref: 1.1.1
isarray: 2.0.5

View File

@@ -8,7 +8,6 @@ import {
CardTitle,
} from "@/components/__legacy__/ui/card";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { CircleNotchIcon } from "@phosphor-icons/react/dist/ssr";
import { Play } from "lucide-react";
import OnboardingButton from "../components/OnboardingButton";
@@ -79,20 +78,13 @@ export default function Page() {
<CardContent className="flex flex-col gap-4">
{Object.entries(agent?.input_schema.properties || {}).map(
([key, inputSubSchema]) => (
<div key={key} className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip
description={inputSubSchema.description}
/>
</label>
<RunAgentInputs
schema={inputSubSchema}
value={onboarding.state?.agentInput?.[key]}
placeholder={inputSubSchema.description}
onChange={(value) => handleSetAgentInput(key, value)}
/>
</div>
<RunAgentInputs
key={key}
schema={inputSubSchema}
value={onboarding.state?.agentInput?.[key]}
placeholder={inputSubSchema.description}
onChange={(value) => handleSetAgentInput(key, value)}
/>
),
)}
<AgentOnboardingCredentials

View File

@@ -0,0 +1,296 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { AuthCard } from "@/components/auth/AuthCard";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { ImageIcon, SealCheckIcon } from "@phosphor-icons/react";
import {
postOauthAuthorize,
useGetOauthGetOauthAppInfo,
} from "@/app/api/__generated__/endpoints/oauth/oauth";
import type { APIKeyPermission } from "@/app/api/__generated__/models/aPIKeyPermission";
// Human-readable scope descriptions
const SCOPE_DESCRIPTIONS: { [key in APIKeyPermission]: string } = {
IDENTITY: "Read user ID, name, e-mail, and timezone",
EXECUTE_GRAPH: "Run your agents",
READ_GRAPH: "View your agents and their configurations",
EXECUTE_BLOCK: "Execute individual blocks",
READ_BLOCK: "View available blocks",
READ_STORE: "Access the Marketplace",
USE_TOOLS: "Use tools on your behalf",
MANAGE_INTEGRATIONS: "Set up new integrations",
READ_INTEGRATIONS: "View your connected integrations",
DELETE_INTEGRATIONS: "Remove connected integrations",
};
export default function AuthorizePage() {
const searchParams = useSearchParams();
// Extract OAuth parameters from URL
const clientID = searchParams.get("client_id");
const redirectURI = searchParams.get("redirect_uri");
const scope = searchParams.get("scope");
const state = searchParams.get("state");
const codeChallenge = searchParams.get("code_challenge");
const codeChallengeMethod =
searchParams.get("code_challenge_method") || "S256";
const responseType = searchParams.get("response_type") || "code";
// Parse requested scopes
const requestedScopes = scope?.split(" ").filter(Boolean) || [];
// Fetch application info using generated hook
const {
data: appInfoResponse,
isLoading,
error,
refetch,
} = useGetOauthGetOauthAppInfo(clientID || "", {
query: {
enabled: !!clientID,
staleTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
},
});
const appInfo = appInfoResponse?.status === 200 ? appInfoResponse.data : null;
// Validate required parameters
const missingParams: string[] = [];
if (!clientID) missingParams.push("client_id");
if (!redirectURI) missingParams.push("redirect_uri");
if (!scope) missingParams.push("scope");
if (!state) missingParams.push("state");
if (!codeChallenge) missingParams.push("code_challenge");
const [isAuthorizing, setIsAuthorizing] = useState(false);
const [authorizeError, setAuthorizeError] = useState<string | null>(null);
async function handleApprove() {
setIsAuthorizing(true);
setAuthorizeError(null);
try {
// Call the backend /oauth/authorize POST endpoint
// Returns JSON with redirect_url that we use to redirect the user
const response = await postOauthAuthorize({
client_id: clientID!,
redirect_uri: redirectURI!,
scopes: requestedScopes,
state: state!,
response_type: responseType,
code_challenge: codeChallenge!,
code_challenge_method: codeChallengeMethod as "S256" | "plain",
});
if (response.status === 200 && response.data.redirect_url) {
window.location.href = response.data.redirect_url;
} else {
setAuthorizeError("Authorization failed: no redirect URL received");
setIsAuthorizing(false);
}
} catch (err) {
console.error("Authorization error:", err);
setAuthorizeError(
err instanceof Error ? err.message : "Authorization failed",
);
setIsAuthorizing(false);
}
}
function handleDeny() {
// Redirect back to client with access_denied error
const params = new URLSearchParams({
error: "access_denied",
error_description: "User denied access",
state: state || "",
});
window.location.href = `${redirectURI}?${params.toString()}`;
}
// Show error if missing required parameters
if (missingParams.length > 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Request">
<ErrorCard
context="request parameters"
responseError={{
message: `Missing required parameters: ${missingParams.join(", ")}`,
}}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
</AuthCard>
</div>
);
}
// Show loading state
if (isLoading) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Loading...">
<div className="flex flex-col items-center gap-4 py-8">
<LoadingSpinner size="large" />
<Text variant="body" className="text-center text-slate-500">
Loading application information...
</Text>
</div>
</AuthCard>
</div>
);
}
// Show error if app not found
if (error || !appInfo) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Application Not Found">
<ErrorCard
context="application"
responseError={
error
? error
: {
message:
"The application you're trying to authorize could not be found or is disabled.",
}
}
onRetry={refetch}
/>
{redirectURI && (
<Button
variant="secondary"
onClick={handleDeny}
className="mt-4 w-full"
>
Return to Application
</Button>
)}
</AuthCard>
</div>
);
}
// Validate that requested scopes are allowed by the app
const invalidScopes = requestedScopes.filter(
(s) => !appInfo.scopes.includes(s),
);
if (invalidScopes.length > 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Scopes">
<ErrorCard
context="scopes"
responseError={{
message: `The application is requesting scopes it is not authorized for: ${invalidScopes.join(", ")}`,
}}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
<Button
variant="secondary"
onClick={handleDeny}
className="mt-4 w-full"
>
Return to Application
</Button>
</AuthCard>
</div>
);
}
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Authorize Application">
<div className="flex w-full flex-col gap-6">
{/* App info */}
<div className="flex flex-col items-center text-center">
{/* App logo */}
<div className="mb-4 flex size-16 items-center justify-center overflow-hidden rounded-xl border bg-slate-100">
{appInfo.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={appInfo.logo_url}
alt={`${appInfo.name} logo`}
className="h-full w-full object-cover"
/>
) : (
<ImageIcon className="h-8 w-8 text-slate-400" />
)}
</div>
<Text variant="h4" className="mb-2">
{appInfo.name}
</Text>
{appInfo.description && (
<Text variant="body" className="text-slate-600">
{appInfo.description}
</Text>
)}
</div>
{/* Permissions */}
<div>
<Text variant="body-medium" className="mb-3">
This application is requesting permission to:
</Text>
<ul className="space-y-2">
{requestedScopes.map((scopeKey) => (
<li key={scopeKey} className="flex items-start gap-3">
<SealCheckIcon className="mt-0.5 text-green-600" />
<Text variant="body">
{SCOPE_DESCRIPTIONS[scopeKey as APIKeyPermission] ||
scopeKey}
</Text>
</li>
))}
</ul>
</div>
{/* Error message */}
{authorizeError && (
<ErrorCard
context="authorization"
responseError={{ message: authorizeError }}
/>
)}
{/* Action buttons */}
<div className="flex flex-col gap-3">
<Button
variant="primary"
onClick={handleApprove}
disabled={isAuthorizing}
className="w-full text-lg"
>
{isAuthorizing ? "Authorizing..." : "Authorize"}
</Button>
<Button
variant="secondary"
onClick={handleDeny}
disabled={isAuthorizing}
className="w-full text-lg"
>
Deny
</Button>
</div>
{/* Warning */}
<Text variant="small" className="text-center text-slate-500">
By authorizing, you allow this application to access your AutoGPT
account with the permissions listed above.
</Text>
</div>
</AuthCard>
</div>
);
}

View File

@@ -74,6 +74,9 @@ export async function GET(request: Request) {
);
}
// Get redirect destination from 'next' query parameter
next = searchParams.get("next") || next;
const forwardedHost = request.headers.get("x-forwarded-host"); // original origin before load balancer
const isLocalEnv = process.env.NODE_ENV === "development";
if (isLocalEnv) {

View File

@@ -0,0 +1,331 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useState, useMemo, useRef } from "react";
import { AuthCard } from "@/components/auth/AuthCard";
import { Text } from "@/components/atoms/Text/Text";
import { Button } from "@/components/atoms/Button/Button";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import type {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsType,
} from "@/lib/autogpt-server-api";
import { CheckIcon, CircleIcon } from "@phosphor-icons/react";
import { useGetOauthGetOauthAppInfo } from "@/app/api/__generated__/endpoints/oauth/oauth";
import { okData } from "@/app/api/helpers";
import { OAuthApplicationPublicInfo } from "@/app/api/__generated__/models/oAuthApplicationPublicInfo";
// All credential types - we accept any type of credential
const ALL_CREDENTIAL_TYPES: CredentialsType[] = [
"api_key",
"oauth2",
"user_password",
"host_scoped",
];
/**
* Provider configuration for the setup wizard.
*
* Query parameters:
* - `providers`: base64-encoded JSON array of { provider, scopes? } objects
* - `app_name`: (optional) Name of the requesting application
* - `redirect_uri`: Where to redirect after completion
* - `state`: Anti-CSRF token
*
* Example `providers` JSON:
* [
* { "provider": "google", "scopes": ["https://www.googleapis.com/auth/gmail.readonly"] },
* { "provider": "github", "scopes": ["repo"] }
* ]
*
* Example URL:
* /auth/integrations/setup-wizard?app_name=My%20App&providers=W3sicHJvdmlkZXIiOiJnb29nbGUifV0=&redirect_uri=...
*/
interface ProviderConfig {
provider: string;
scopes?: string[];
}
function createSchemaFromProviderConfig(
config: ProviderConfig,
): BlockIOCredentialsSubSchema {
return {
type: "object",
properties: {},
credentials_provider: [config.provider],
credentials_types: ALL_CREDENTIAL_TYPES,
credentials_scopes: config.scopes,
discriminator: undefined,
discriminator_mapping: undefined,
discriminator_values: undefined,
};
}
function toDisplayName(provider: string): string {
// Convert snake_case or kebab-case to Title Case
return provider
.split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
function parseProvidersParam(providersParam: string): ProviderConfig[] {
try {
// Decode base64 and parse JSON
const decoded = atob(providersParam);
const parsed = JSON.parse(decoded);
if (!Array.isArray(parsed)) {
console.warn("providers parameter is not an array");
return [];
}
return parsed.filter(
(item): item is ProviderConfig =>
typeof item === "object" &&
item !== null &&
typeof item.provider === "string",
);
} catch (error) {
console.warn("Failed to parse providers parameter:", error);
return [];
}
}
export default function IntegrationSetupWizardPage() {
const searchParams = useSearchParams();
// Extract query parameters
// `providers` is a base64-encoded JSON array of { provider, scopes?: string[] } objects
const clientID = searchParams.get("client_id");
const providersParam = searchParams.get("providers");
const redirectURI = searchParams.get("redirect_uri");
const state = searchParams.get("state");
const { data: appInfo } = useGetOauthGetOauthAppInfo(clientID || "", {
query: { enabled: !!clientID, select: okData<OAuthApplicationPublicInfo> },
});
// Parse providers from base64-encoded JSON
const providerConfigs = useMemo<ProviderConfig[]>(() => {
if (!providersParam) return [];
return parseProvidersParam(providersParam);
}, [providersParam]);
// Track selected credentials for each provider
const [selectedCredentials, setSelectedCredentials] = useState<
Record<string, CredentialsMetaInput | undefined>
>({});
// Track if we've already redirected
const hasRedirectedRef = useRef(false);
// Check if all providers have credentials
const isAllComplete = useMemo(() => {
if (providerConfigs.length === 0) return false;
return providerConfigs.every(
(config) => selectedCredentials[config.provider],
);
}, [providerConfigs, selectedCredentials]);
// Handle credential selection
const handleCredentialSelect = (
provider: string,
credential?: CredentialsMetaInput,
) => {
setSelectedCredentials((prev) => ({
...prev,
[provider]: credential,
}));
};
// Handle completion - redirect back to client
const handleComplete = () => {
if (!redirectURI || hasRedirectedRef.current) return;
hasRedirectedRef.current = true;
const params = new URLSearchParams({
success: "true",
});
if (state) {
params.set("state", state);
}
window.location.href = `${redirectURI}?${params.toString()}`;
};
// Handle cancel - redirect back to client with error
const handleCancel = () => {
if (!redirectURI || hasRedirectedRef.current) return;
hasRedirectedRef.current = true;
const params = new URLSearchParams({
error: "user_cancelled",
error_description: "User cancelled the integration setup",
});
if (state) {
params.set("state", state);
}
window.location.href = `${redirectURI}?${params.toString()}`;
};
// Validate required parameters
const missingParams: string[] = [];
if (!providersParam) missingParams.push("providers");
if (!redirectURI) missingParams.push("redirect_uri");
if (missingParams.length > 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Request">
<ErrorCard
context="request details"
responseError={{
message: `Missing required parameters: ${missingParams.join(", ")}`,
}}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
</AuthCard>
</div>
);
}
if (providerConfigs.length === 0) {
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Invalid Request">
<ErrorCard
context="providers"
responseError={{ message: "No providers specified" }}
hint="Please contact the administrator of the app that sent you here."
isOurProblem={false}
/>
<Button
variant="secondary"
onClick={handleCancel}
className="mt-4 w-full"
>
Cancel
</Button>
</AuthCard>
</div>
);
}
return (
<div className="flex h-full min-h-[85vh] flex-col items-center justify-center py-10">
<AuthCard title="Connect Your Accounts">
<div className="flex w-full flex-col gap-6">
<Text variant="body" className="text-center text-slate-600">
{appInfo ? (
<>
<strong>{appInfo.name}</strong> is requesting you to connect the
following integrations to your AutoGPT account.
</>
) : (
"Please connect the following integrations to continue."
)}
</Text>
{/* Provider credentials list */}
<div className="space-y-4">
{providerConfigs.map((config) => {
const schema = createSchemaFromProviderConfig(config);
const isSelected = !!selectedCredentials[config.provider];
return (
<div
key={config.provider}
className="relative rounded-xl border border-slate-200 bg-white p-4"
>
<div className="mb-4 flex items-center gap-2">
<div className="relative size-8">
<Image
src={`/integrations/${config.provider}.png`}
alt={`${config.provider} icon`}
fill
className="object-contain group-disabled:opacity-50"
/>
</div>
<Text className="mx-1" variant="large-medium">
{toDisplayName(config.provider)}
</Text>
<div className="grow"></div>
{isSelected ? (
<CheckIcon
size={20}
className="text-green-500"
weight="bold"
/>
) : (
<CircleIcon
size={20}
className="text-slate-300"
weight="bold"
/>
)}
{isSelected && (
<Text variant="small" className="text-green-600">
Connected
</Text>
)}
</div>
<CredentialsInput
schema={schema}
selectedCredentials={selectedCredentials[config.provider]}
onSelectCredentials={(credMeta) =>
handleCredentialSelect(config.provider, credMeta)
}
showTitle={false}
className="mb-0"
/>
</div>
);
})}
</div>
{/* Action buttons */}
<div className="flex flex-col gap-3">
<Button
variant="primary"
onClick={handleComplete}
disabled={!isAllComplete}
className="w-full text-lg"
>
{isAllComplete
? "Continue"
: `Connect ${providerConfigs.length - Object.values(selectedCredentials).filter(Boolean).length} more`}
</Button>
<Button
variant="secondary"
onClick={handleCancel}
className="w-full text-lg"
>
Cancel
</Button>
</div>
{/* Link to integrations settings */}
<Text variant="small" className="text-center text-slate-500">
You can view and manage all your integrations in your{" "}
<Link
href="/profile/integrations"
target="_blank"
className="text-purple-600 underline hover:text-purple-800"
>
integration settings
</Link>
.
</Text>
</div>
</AuthCard>
</div>
);
}

View File

@@ -20,6 +20,7 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false);
const [hasAutoFramed, setHasAutoFramed] = useState(false);
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
const updateNodeStatus = useNodeStore(
@@ -187,9 +188,36 @@ export const useFlow = () => {
};
}, []);
const linkCount = graph?.links?.length ?? 0;
useEffect(() => {
fitView({ padding: 0.2, duration: 800, maxZoom: 2 });
}, [fitView]);
if (isGraphLoading || isBlocksLoading) {
setHasAutoFramed(false);
return;
}
if (hasAutoFramed) {
return;
}
const rafId = requestAnimationFrame(() => {
fitView({ padding: 0.2, duration: 800, maxZoom: 1 });
setHasAutoFramed(true);
});
return () => cancelAnimationFrame(rafId);
}, [
fitView,
hasAutoFramed,
customNodes.length,
isBlocksLoading,
isGraphLoading,
linkCount,
]);
useEffect(() => {
setHasAutoFramed(false);
}, [flowID, flowVersion]);
// Drag and drop block from block menu
const onDragOver = useCallback((event: React.DragEvent) => {

View File

@@ -106,7 +106,11 @@ export const CustomNode: React.FC<NodeProps<CustomNode>> = React.memo(
/>
<NodeAdvancedToggle nodeId={nodeId} />
{data.uiType != BlockUIType.OUTPUT && (
<OutputHandler outputSchema={outputSchema} nodeId={nodeId} />
<OutputHandler
uiType={data.uiType}
outputSchema={outputSchema}
nodeId={nodeId}
/>
)}
<NodeDataRenderer nodeId={nodeId} />
</div>

View File

@@ -20,17 +20,32 @@ export const FormCreator = React.memo(
className?: string;
}) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const getHardCodedValues = useNodeStore(
(state) => state.getHardCodedValues,
);
const handleChange = ({ formData }: any) => {
if ("credentials" in formData && !formData.credentials?.id) {
delete formData.credentials;
}
updateNodeData(nodeId, { hardcodedValues: formData });
const updatedValues =
uiType === BlockUIType.AGENT
? {
...getHardCodedValues(nodeId),
inputs: formData,
}
: formData;
updateNodeData(nodeId, { hardcodedValues: updatedValues });
};
const initialValues = getHardCodedValues(nodeId);
const hardcodedValues = getHardCodedValues(nodeId);
const initialValues =
uiType === BlockUIType.AGENT
? (hardcodedValues.inputs ?? {})
: hardcodedValues;
return (
<div className={className}>

View File

@@ -14,13 +14,16 @@ import {
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { getTypeDisplayInfo } from "./helpers";
import { generateHandleId } from "../handlers/helpers";
import { BlockUIType } from "../../types";
export const OutputHandler = ({
outputSchema,
nodeId,
uiType,
}: {
outputSchema: RJSFSchema;
nodeId: string;
uiType: BlockUIType;
}) => {
const { isOutputConnected } = useEdgeStore();
const properties = outputSchema?.properties || {};
@@ -79,7 +82,9 @@ export const OutputHandler = ({
</Text>
<NodeHandle
handleId={generateHandleId(key)}
handleId={
uiType === BlockUIType.AGENT ? key : generateHandleId(key)
}
isConnected={isConnected}
side="right"
/>

View File

@@ -7,6 +7,7 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { getV2GetSpecificAgent } from "@/app/api/__generated__/endpoints/store/store";
import {
getGetV2ListLibraryAgentsQueryKey,
getV2GetLibraryAgent,
usePostV2AddMarketplaceAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import {
@@ -151,7 +152,12 @@ export const useBlockMenuSearch = () => {
});
const libraryAgent = response.data as LibraryAgent;
addAgentToBuilder(libraryAgent);
const { data: libraryAgentDetails } = await getV2GetLibraryAgent(
libraryAgent.id,
);
addAgentToBuilder(libraryAgentDetails as LibraryAgent);
toast({
title: "Agent Added",

View File

@@ -1,6 +1,7 @@
import { getGetV2GetBuilderItemCountsQueryKey } from "@/app/api/__generated__/endpoints/default/default";
import {
getGetV2ListLibraryAgentsQueryKey,
getV2GetLibraryAgent,
usePostV2AddMarketplaceAgent,
} from "@/app/api/__generated__/endpoints/library/library";
import {
@@ -105,8 +106,16 @@ export const useMarketplaceAgentsContent = () => {
},
});
// Here, libraryAgent has empty input and output schemas.
// Not updating the endpoint because this endpoint is used elsewhere.
// TODO: Create a new endpoint for builder specific to marketplace agents.
const libraryAgent = response.data as LibraryAgent;
addAgentToBuilder(libraryAgent);
const { data: libraryAgentDetails } = await getV2GetLibraryAgent(
libraryAgent.id,
);
addAgentToBuilder(libraryAgentDetails as LibraryAgent);
toast({
title: "Agent Added",

View File

@@ -103,6 +103,7 @@ const FlowEditor: React.FC<{
updateNode,
getViewport,
setViewport,
fitView,
screenToFlowPosition,
} = useReactFlow<CustomNode, CustomEdge>();
const [nodeId, setNodeId] = useState<number>(1);
@@ -115,6 +116,7 @@ const FlowEditor: React.FC<{
const [pinBlocksPopover, setPinBlocksPopover] = useState(false);
// State to control if save popover should be pinned open
const [pinSavePopover, setPinSavePopover] = useState(false);
const [hasAutoFramed, setHasAutoFramed] = useState(false);
const {
agentName,
@@ -482,35 +484,26 @@ const FlowEditor: React.FC<{
return uuidv4();
}, []);
// Set the initial view port to center the canvas.
useEffect(() => {
const { x, y } = getViewport();
if (nodes.length <= 0 || x !== 0 || y !== 0) {
if (nodes.length === 0) {
return;
}
const topLeft = { x: Infinity, y: Infinity };
const bottomRight = { x: -Infinity, y: -Infinity };
if (hasAutoFramed) {
return;
}
nodes.forEach((node) => {
const { x, y } = node.position;
topLeft.x = Math.min(topLeft.x, x);
topLeft.y = Math.min(topLeft.y, y);
// Rough estimate of the width and height of the node: 500x400.
bottomRight.x = Math.max(bottomRight.x, x + 500);
bottomRight.y = Math.max(bottomRight.y, y + 400);
const rafId = requestAnimationFrame(() => {
fitView({ padding: 0.2, duration: 800, maxZoom: 1 });
setHasAutoFramed(true);
});
const centerX = (topLeft.x + bottomRight.x) / 2;
const centerY = (topLeft.y + bottomRight.y) / 2;
const zoom = 0.8;
return () => cancelAnimationFrame(rafId);
}, [fitView, hasAutoFramed, nodes.length]);
setViewport({
x: window.innerWidth / 2 - centerX * zoom,
y: window.innerHeight / 2 - centerY * zoom,
zoom: zoom,
});
}, [nodes, getViewport, setViewport]);
useEffect(() => {
setHasAutoFramed(false);
}, [flowID, flowVersion]);
const navigateToNode = useCallback(
(nodeId: string) => {

View File

@@ -1,16 +1,11 @@
"use client";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type {
BlockIOSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import {
getAgentCredentialsFields,
getAgentInputFields,
renderValue,
} from "./helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
type Props = {
agent: LibraryAgent;
@@ -28,19 +23,23 @@ export function AgentInputsReadOnly({
getAgentCredentialsFields(agent),
);
// Take actual input entries as leading; augment with schema from input fields.
// TODO: ensure consistent ordering.
const inputEntries =
inputs &&
Object.entries(inputs).map<[string, [BlockIOSubSchema | undefined, any]]>(
([k, v]) => [k, [inputFields[k], v]],
);
Object.entries(inputs).map(([key, value]) => ({
key,
schema: inputFields[key],
value,
}));
const hasInputs = inputEntries && inputEntries.length > 0;
const hasCredentials = credentialInputs && credentialFieldEntries.length > 0;
if (!hasInputs && !hasCredentials) {
return <div className="text-neutral-600">No input for this run.</div>;
return (
<Text variant="body" className="text-zinc-700">
No input for this run.
</Text>
);
}
return (
@@ -48,16 +47,20 @@ export function AgentInputsReadOnly({
{/* Regular inputs */}
{hasInputs && (
<div className="flex flex-col gap-4">
{inputEntries.map(([key, [schema, value]]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{schema?.title || key}
</label>
<p className="whitespace-pre-wrap break-words text-sm text-neutral-700">
{renderValue(value)}
</p>
</div>
))}
{inputEntries.map(({ key, schema, value }) => {
if (!schema) return null;
return (
<RunAgentInputs
key={key}
schema={schema}
value={value}
placeholder={schema.description}
onChange={() => {}}
readOnly={true}
/>
);
})}
</div>
)}

View File

@@ -15,13 +15,14 @@ import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsMod
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { getCredentialDisplayName } from "./helpers";
import { useCredentialsInputs } from "./useCredentialsInputs";
type UseCredentialsInputsReturn = ReturnType<typeof useCredentialsInputs>;
import {
CredentialsInputState,
useCredentialsInput,
} from "./useCredentialsInput";
function isLoaded(
data: UseCredentialsInputsReturn,
): data is Extract<UseCredentialsInputsReturn, { isLoading: false }> {
data: CredentialsInputState,
): data is Extract<CredentialsInputState, { isLoading: false }> {
return data.isLoading === false;
}
@@ -33,21 +34,23 @@ type Props = {
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
showTitle?: boolean;
};
export function CredentialsInput({
schema,
className,
selectedCredentials,
onSelectCredentials,
selectedCredentials: selectedCredential,
onSelectCredentials: onSelectCredential,
siblingInputs,
onLoaded,
readOnly = false,
showTitle = true,
}: Props) {
const hookData = useCredentialsInputs({
const hookData = useCredentialsInput({
schema,
selectedCredentials,
onSelectCredentials,
selectedCredential,
onSelectCredential,
siblingInputs,
onLoaded,
readOnly,
@@ -89,12 +92,14 @@ export function CredentialsInput({
return (
<div className={cn("mb-6", className)}>
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
{showTitle && (
<div className="mb-2 flex items-center gap-2">
<Text variant="large-medium">{displayName} credentials</Text>
{schema.description && (
<InformationTooltip description={schema.description} />
)}
</div>
)}
{hasCredentialsToShow ? (
<>
@@ -103,7 +108,7 @@ export function CredentialsInput({
credentials={credentialsToShow}
provider={provider}
displayName={displayName}
selectedCredentials={selectedCredentials}
selectedCredentials={selectedCredential}
onSelectCredential={handleCredentialSelect}
readOnly={readOnly}
/>
@@ -164,7 +169,7 @@ export function CredentialsInput({
open={isAPICredentialsModalOpen}
onClose={() => setAPICredentialsModalOpen(false)}
onCredentialsCreate={(credsMeta) => {
onSelectCredentials(credsMeta);
onSelectCredential(credsMeta);
setAPICredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
@@ -183,7 +188,7 @@ export function CredentialsInput({
open={isUserPasswordCredentialsModalOpen}
onClose={() => setUserPasswordCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
onSelectCredential(creds);
setUserPasswordCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}
@@ -195,7 +200,7 @@ export function CredentialsInput({
open={isHostScopedCredentialsModalOpen}
onClose={() => setHostScopedCredentialsModalOpen(false)}
onCredentialsCreate={(creds) => {
onSelectCredentials(creds);
onSelectCredential(creds);
setHostScopedCredentialsModalOpen(false);
}}
siblingInputs={siblingInputs}

View File

@@ -62,12 +62,15 @@ export function CredentialRow({
</div>
<IconKey className="h-5 w-5 shrink-0 text-zinc-800" />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-4">
<Text variant="body" className="tracking-tight">
<Text
variant="body"
className="line-clamp-1 flex-[0_0_50%] text-ellipsis tracking-tight"
>
{getCredentialDisplayName(credential, displayName)}
</Text>
<Text
variant="large"
className="relative top-1 font-mono tracking-tight"
className="relative top-1 hidden flex-[0_0_40%] overflow-hidden truncate font-mono tracking-tight md:block"
>
{"*".repeat(MASKED_KEY_LENGTH)}
</Text>

View File

@@ -5,32 +5,33 @@ import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useQueryClient } from "@tanstack/react-query";
import { useContext, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
getActionButtonText,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
type Args = {
export type CredentialsInputState = ReturnType<typeof useCredentialsInput>;
type Params = {
schema: BlockIOCredentialsSubSchema;
selectedCredentials?: CredentialsMetaInput;
onSelectCredentials: (newValue?: CredentialsMetaInput) => void;
selectedCredential?: CredentialsMetaInput;
onSelectCredential: (newValue?: CredentialsMetaInput) => void;
siblingInputs?: Record<string, any>;
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
};
export function useCredentialsInputs({
export function useCredentialsInput({
schema,
selectedCredentials,
onSelectCredentials,
selectedCredential,
onSelectCredential,
siblingInputs,
onLoaded,
readOnly = false,
}: Args) {
}: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
const [
@@ -51,7 +52,6 @@ export function useCredentialsInputs({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const allProviders = useContext(CredentialsProvidersContext);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -63,57 +63,49 @@ export function useCredentialsInputs({
queryKey: [`/api/integrations/${credentials?.provider}/credentials`],
});
setCredentialToDelete(null);
if (selectedCredentials?.id === credentialToDelete?.id) {
onSelectCredentials(undefined);
if (selectedCredential?.id === credentialToDelete?.id) {
onSelectCredential(undefined);
}
},
},
});
const rawProvider = credentials
? allProviders?.[credentials.provider as keyof typeof allProviders]
: null;
useEffect(() => {
if (onLoaded) {
onLoaded(Boolean(credentials && credentials.isLoading === false));
}
}, [credentials, onLoaded]);
// Unselect credential if not available
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
if (
selectedCredentials &&
!credentials.savedCredentials.some((c) => c.id === selectedCredentials.id)
selectedCredential &&
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
) {
onSelectCredentials(undefined);
onSelectCredential(undefined);
}
}, [credentials, selectedCredentials, onSelectCredentials, readOnly]);
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
const { singleCredential } = useMemo(() => {
// The available credential, if there is only one
const singleCredential = useMemo(() => {
if (!credentials || !("savedCredentials" in credentials)) {
return {
singleCredential: null,
};
return null;
}
const single =
credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
return {
singleCredential: single,
};
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
// Auto-select the one available credential
useEffect(() => {
if (readOnly) return;
if (singleCredential && !selectedCredentials) {
onSelectCredentials(singleCredential);
if (singleCredential && !selectedCredential) {
onSelectCredential(singleCredential);
}
}, [singleCredential, selectedCredentials, onSelectCredentials, readOnly]);
}, [singleCredential, selectedCredential, onSelectCredential, readOnly]);
if (
!credentials ||
@@ -136,25 +128,6 @@ export function useCredentialsInputs({
oAuthCallback,
} = credentials;
const allSavedCredentials = rawProvider?.savedCredentials || savedCredentials;
const credentialsToShow = (() => {
const creds = [...allSavedCredentials];
if (
!readOnly &&
selectedCredentials &&
!creds.some((c) => c.id === selectedCredentials.id)
) {
creds.push({
id: selectedCredentials.id,
type: selectedCredentials.type,
title: selectedCredentials.title || "Selected credential",
provider: provider,
} as any);
}
return creds;
})();
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -207,7 +180,31 @@ export function useCredentialsInputs({
console.debug("Processing OAuth callback");
const credentials = await oAuthCallback(e.data.code, e.data.state);
console.debug("OAuth callback processed successfully");
onSelectCredentials({
// Check if the credential's scopes match the required scopes
const requiredScopes = schema.credentials_scopes;
if (requiredScopes && requiredScopes.length > 0) {
const grantedScopes = new Set(credentials.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
console.error(
`Newly created OAuth credential for ${providerName} has insufficient scopes. Required:`,
requiredScopes,
"Granted:",
credentials.scopes,
);
setOAuthError(
"Connection failed: the granted permissions don't match what's required. " +
"Please contact the application administrator.",
);
return;
}
}
onSelectCredential({
id: credentials.id,
type: "oauth2",
title: credentials.title,
@@ -253,9 +250,9 @@ export function useCredentialsInputs({
}
function handleCredentialSelect(credentialId: string) {
const selectedCreds = credentialsToShow.find((c) => c.id === credentialId);
const selectedCreds = savedCredentials.find((c) => c.id === credentialId);
if (selectedCreds) {
onSelectCredentials({
onSelectCredential({
id: selectedCreds.id,
type: selectedCreds.type,
provider: provider,
@@ -285,8 +282,8 @@ export function useCredentialsInputs({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow,
selectedCredentials,
credentialsToShow: savedCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
isUserPasswordCredentialsModalOpen,
@@ -300,7 +297,7 @@ export function useCredentialsInputs({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
credentialsToShow.length > 0,
savedCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
@@ -311,7 +308,7 @@ export function useCredentialsInputs({
handleDeleteCredential,
handleDeleteConfirm,
handleOAuthLogin,
onSelectCredentials,
onSelectCredential,
schema,
siblingInputs,
};

View File

@@ -9,6 +9,7 @@ import { Button } from "@/components/atoms/Button/Button";
import { FileInput } from "@/components/atoms/FileInput/FileInput";
import { Switch } from "@/components/atoms/Switch/Switch";
import { GoogleDrivePickerInput } from "@/components/contextual/GoogleDrivePicker/GoogleDrivePickerInput";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { TimePicker } from "@/components/molecules/TimePicker/TimePicker";
import {
BlockIOObjectSubSchema,
@@ -32,6 +33,7 @@ interface Props {
value?: any;
placeholder?: string;
onChange: (value: any) => void;
readOnly?: boolean;
}
/**
@@ -44,6 +46,7 @@ export function RunAgentInputs({
value,
placeholder,
onChange,
readOnly = false,
...props
}: Props & React.HTMLAttributes<HTMLElement>) {
const { handleUploadFile, uploadProgress } = useRunAgentInputs();
@@ -62,7 +65,6 @@ export function RunAgentInputs({
id={`${baseId}-number`}
label={schema.title ?? placeholder ?? "Number"}
hideLabel
size="small"
type="number"
value={value ?? ""}
placeholder={placeholder || "Enter number"}
@@ -80,7 +82,6 @@ export function RunAgentInputs({
id={`${baseId}-textarea`}
label={schema.title ?? placeholder ?? "Text"}
hideLabel
size="small"
type="textarea"
rows={3}
value={value ?? ""}
@@ -102,7 +103,7 @@ export function RunAgentInputs({
value={value}
onChange={onChange}
className="w-full"
showRemoveButton={false}
showRemoveButton={!readOnly}
/>
);
break;
@@ -130,7 +131,6 @@ export function RunAgentInputs({
id={`${baseId}-date`}
label={schema.title ?? placeholder ?? "Date"}
hideLabel
size="small"
type="date"
value={value ? format(value as Date, "yyyy-MM-dd") : ""}
onChange={(e) => {
@@ -159,7 +159,6 @@ export function RunAgentInputs({
id={`${baseId}-datetime`}
label={schema.title ?? placeholder ?? "Date time"}
hideLabel
size="small"
type="datetime-local"
value={value ?? ""}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
@@ -194,7 +193,6 @@ export function RunAgentInputs({
label={schema.title ?? placeholder ?? "Select"}
hideLabel
value={value ?? ""}
size="small"
onValueChange={(val: string) => onChange(val)}
placeholder={placeholder || "Select an option"}
options={schema.enum
@@ -217,7 +215,6 @@ export function RunAgentInputs({
items={allKeys.map((key) => ({
value: key,
label: _schema.properties[key]?.title ?? key,
size: "small",
}))}
selectedValues={selectedValues}
onChange={(values: string[]) =>
@@ -336,7 +333,6 @@ export function RunAgentInputs({
id={`${baseId}-text`}
label={schema.title ?? placeholder ?? "Text"}
hideLabel
size="small"
type="text"
value={value ?? ""}
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
@@ -347,6 +343,17 @@ export function RunAgentInputs({
}
return (
<div className="no-drag relative flex w-full">{innerInputElement}</div>
<div className="flex w-full flex-col gap-0 space-y-2">
<label className="large-medium flex items-center gap-1 font-medium">
{schema.title || placeholder}
<InformationTooltip description={schema.description} />
</label>
<div
className="no-drag relative flex w-full"
style={readOnly ? { pointerEvents: "none", opacity: 0.7 } : undefined}
>
{innerInputElement}
</div>
</div>
);
}

View File

@@ -73,22 +73,15 @@ export function ModalRunSection() {
title="Task Inputs"
subtitle="Enter the information you want to provide to the agent for this task"
>
{/* Regular inputs */}
{inputFields.map(([key, inputSubSchema]) => (
<div key={key} className="flex w-full flex-col gap-0 space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip description={inputSubSchema.description} />
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
</div>
<RunAgentInputs
key={key}
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
data-testid={`agent-input-${key}`}
/>
))}
</ModalSection>
) : null}

View File

@@ -4,20 +4,19 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
ScrollableTabs,
ScrollableTabsContent,
ScrollableTabsList,
ScrollableTabsTrigger,
} from "@/components/molecules/ScrollableTabs/ScrollableTabs";
import { PendingReviewsList } from "@/components/organisms/PendingReviewsList/PendingReviewsList";
import { usePendingReviewsForExecution } from "@/hooks/usePendingReviews";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { InfoIcon } from "@phosphor-icons/react";
import { useEffect } from "react";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -28,9 +27,6 @@ import { SelectedRunActions } from "./components/SelectedRunActions/SelectedRunA
import { WebhookTriggerSection } from "./components/WebhookTriggerSection";
import { useSelectedRunView } from "./useSelectedRunView";
const anchorStyles =
"border-b-2 border-transparent pb-1 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 hover:border-slate-900";
interface Props {
agent: LibraryAgent;
runId: string;
@@ -65,13 +61,6 @@ export function SelectedRunView({
const withSummary = run?.stats?.activity_status;
const withReviews = run?.status === AgentExecutionStatus.REVIEW;
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
if (responseError || httpError) {
return (
<ErrorCard
@@ -112,118 +101,116 @@ export function SelectedRunView({
/>
)}
{/* Navigation Links */}
<AnchorLinksWrap>
{withSummary && (
<button
onClick={() => scrollToSection("summary")}
className={anchorStyles}
>
Summary
</button>
)}
<button
onClick={() => scrollToSection("output")}
className={anchorStyles}
>
Output
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
{withReviews && (
<button
onClick={() => scrollToSection("reviews")}
className={anchorStyles}
>
Reviews ({pendingReviews.length})
</button>
)}
</AnchorLinksWrap>
{/* Summary Section */}
{withSummary && (
<div id="summary" className="scroll-mt-4">
<RunDetailCard
title={
<div className="flex items-center gap-2">
<Text variant="lead-semibold">Summary</Text>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon
size={16}
className="cursor-help text-neutral-500 hover:text-neutral-700"
/>
</TooltipTrigger>
<TooltipContent>
<p className="max-w-xs">
This AI-generated summary describes how the agent
handled your task. It&apos;s an experimental
feature and may occasionally be inaccurate.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
}
>
<RunSummary run={run} />
</RunDetailCard>
</div>
)}
{/* Output Section */}
<div id="output" className="scroll-mt-4">
<RunDetailCard title="Output">
{isLoading ? (
<div className="text-neutral-500">
<LoadingSpinner />
</div>
) : run && "outputs" in run ? (
<RunOutputs outputs={run.outputs as any} />
) : (
<Text variant="body" className="text-neutral-600">
No output from this run.
</Text>
<ScrollableTabs
defaultValue="output"
className="-mt-2 flex flex-col"
>
<ScrollableTabsList className="px-4">
{withSummary && (
<ScrollableTabsTrigger value="summary">
Summary
</ScrollableTabsTrigger>
)}
</RunDetailCard>
</div>
{/* Input Section */}
<div id="input" className="scroll-mt-4">
<RunDetailCard title="Your input">
<AgentInputsReadOnly
agent={agent}
inputs={run?.inputs}
credentialInputs={run?.credential_inputs}
/>
</RunDetailCard>
</div>
{/* Reviews Section */}
{withReviews && (
<div id="reviews" className="scroll-mt-4">
<RunDetailCard>
{reviewsLoading ? (
<div className="text-neutral-500">Loading reviews</div>
) : pendingReviews.length > 0 ? (
<PendingReviewsList
reviews={pendingReviews}
onReviewComplete={refetchReviews}
emptyMessage="No pending reviews for this execution"
/>
) : (
<div className="text-neutral-600">
No pending reviews for this execution
<ScrollableTabsTrigger value="output">
Output
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="input">
Your input
</ScrollableTabsTrigger>
{withReviews && (
<ScrollableTabsTrigger value="reviews">
Reviews ({pendingReviews.length})
</ScrollableTabsTrigger>
)}
</ScrollableTabsList>
<div className="my-6 flex flex-col gap-6">
{/* Summary Section */}
{withSummary && (
<ScrollableTabsContent value="summary">
<div className="scroll-mt-4">
<RunDetailCard
title={
<div className="flex items-center gap-1">
<Text variant="lead-semibold">Summary</Text>
<InformationTooltip
iconSize={20}
description="This AI-generated summary describes how the agent handled your task. It's an experimental feature and may occasionally be inaccurate."
/>
</div>
}
>
<RunSummary run={run} />
</RunDetailCard>
</div>
)}
</RunDetailCard>
</ScrollableTabsContent>
)}
{/* Output Section */}
<ScrollableTabsContent value="output">
<div className="scroll-mt-4">
<RunDetailCard title="Output">
{isLoading ? (
<div className="text-neutral-500">
<LoadingSpinner />
</div>
) : run && "outputs" in run ? (
<RunOutputs outputs={run.outputs as any} />
) : (
<Text variant="body" className="text-neutral-600">
No output from this run.
</Text>
)}
</RunDetailCard>
</div>
</ScrollableTabsContent>
{/* Input Section */}
<ScrollableTabsContent value="input">
<div id="input" className="scroll-mt-4">
<RunDetailCard
title={
<div className="flex items-center gap-1">
<Text variant="lead-semibold">Your input</Text>
<InformationTooltip
iconSize={20}
description="This is the input that was provided to the agent for running this task."
/>
</div>
}
>
<AgentInputsReadOnly
agent={agent}
inputs={run?.inputs}
credentialInputs={run?.credential_inputs}
/>
</RunDetailCard>
</div>
</ScrollableTabsContent>
{/* Reviews Section */}
{withReviews && (
<ScrollableTabsContent value="reviews">
<div className="scroll-mt-4">
<RunDetailCard>
{reviewsLoading ? (
<LoadingSpinner size="small" />
) : pendingReviews.length > 0 ? (
<PendingReviewsList
reviews={pendingReviews}
onReviewComplete={refetchReviews}
emptyMessage="No pending reviews for this execution"
/>
) : (
<Text variant="body" className="text-zinc-700">
No pending reviews for this execution
</Text>
)}
</RunDetailCard>
</div>
</ScrollableTabsContent>
)}
</div>
)}
</ScrollableTabs>
</div>
</SelectedViewLayout>
</div>

View File

@@ -9,7 +9,6 @@ import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { isLargeScreen, useBreakpoint } from "@/lib/hooks/useBreakpoint";
import { formatInTimezone, getTimezoneDisplayName } from "@/lib/timezone-utils";
import { AgentInputsReadOnly } from "../../modals/AgentInputsReadOnly/AgentInputsReadOnly";
import { AnchorLinksWrap } from "../AnchorLinksWrap";
import { LoadingSelectedContent } from "../LoadingSelectedContent";
import { RunDetailCard } from "../RunDetailCard/RunDetailCard";
import { RunDetailHeader } from "../RunDetailHeader/RunDetailHeader";
@@ -17,9 +16,6 @@ import { SelectedViewLayout } from "../SelectedViewLayout";
import { SelectedScheduleActions } from "./components/SelectedScheduleActions";
import { useSelectedScheduleView } from "./useSelectedScheduleView";
const anchorStyles =
"border-b-2 border-transparent pb-1 text-sm font-medium text-slate-600 transition-colors hover:text-slate-900 hover:border-slate-900";
interface Props {
agent: LibraryAgent;
scheduleId: string;
@@ -45,13 +41,6 @@ export function SelectedScheduleView({
const breakpoint = useBreakpoint();
const isLgScreenUp = isLargeScreen(breakpoint);
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
if (error) {
return (
<ErrorCard
@@ -108,22 +97,6 @@ export function SelectedScheduleView({
) : null}
</div>
{/* Navigation Links */}
<AnchorLinksWrap>
<button
onClick={() => scrollToSection("schedule")}
className={anchorStyles}
>
Schedule
</button>
<button
onClick={() => scrollToSection("input")}
className={anchorStyles}
>
Your input
</button>
</AnchorLinksWrap>
{/* Schedule Section */}
<div id="schedule" className="scroll-mt-4">
<RunDetailCard title="Schedule">

View File

@@ -1,84 +0,0 @@
"use client";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { RunAgentInputs } from "../../../../modals/RunAgentInputs/RunAgentInputs";
import { useEditInputsModal } from "./useEditInputsModal";
type Props = {
agent: LibraryAgent;
schedule: GraphExecutionJobInfo;
};
export function EditInputsModal({ agent, schedule }: Props) {
const {
isOpen,
setIsOpen,
inputFields,
values,
setValues,
handleSave,
isSaving,
} = useEditInputsModal(agent, schedule);
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "32rem" }}
>
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="absolute -right-2 -top-2"
>
<PencilSimpleIcon className="size-4" /> Edit inputs
</Button>
</Dialog.Trigger>
<Dialog.Content>
<div className="flex flex-col gap-4">
<Text variant="h3">Edit inputs</Text>
<div className="flex flex-col gap-4">
{Object.entries(inputFields).map(([key, fieldSchema]) => (
<div key={key} className="flex flex-col gap-1.5">
<label className="text-sm font-medium">
{fieldSchema?.title || key}
</label>
<RunAgentInputs
schema={fieldSchema as any}
value={values[key]}
onChange={(v) => setValues((prev) => ({ ...prev, [key]: v }))}
/>
</div>
))}
</div>
</div>
<Dialog.Footer>
<div className="flex w-full justify-end gap-2">
<Button
variant="secondary"
size="small"
onClick={() => setIsOpen(false)}
className="min-w-32"
>
Cancel
</Button>
<Button
variant="primary"
size="small"
onClick={handleSave}
loading={isSaving}
className="min-w-32"
>
{isSaving ? "Saving…" : "Save"}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -1,78 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getGetV1ListExecutionSchedulesForAGraphQueryKey } from "@/app/api/__generated__/endpoints/schedules/schedules";
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import type { GraphExecutionJobInfo } from "@/app/api/__generated__/models/graphExecutionJobInfo";
import { useToast } from "@/components/molecules/Toast/use-toast";
function getAgentInputFields(agent: LibraryAgent): Record<string, any> {
const schema = agent.input_schema as unknown as {
properties?: Record<string, any>;
} | null;
if (!schema || !schema.properties) return {};
const properties = schema.properties as Record<string, any>;
const visibleEntries = Object.entries(properties).filter(
([, sub]) => !sub?.hidden,
);
return Object.fromEntries(visibleEntries);
}
export function useEditInputsModal(
agent: LibraryAgent,
schedule: GraphExecutionJobInfo,
) {
const queryClient = useQueryClient();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const inputFields = useMemo(() => getAgentInputFields(agent), [agent]);
const [values, setValues] = useState<Record<string, any>>({
...(schedule.input_data as Record<string, any>),
});
async function handleSave() {
setIsSaving(true);
try {
const res = await fetch(`/api/schedules/${schedule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputs: values }),
});
if (!res.ok) {
let message = "Failed to update schedule inputs";
const data = await res.json();
message = data?.message || data?.detail || message;
throw new Error(message);
}
await queryClient.invalidateQueries({
queryKey: getGetV1ListExecutionSchedulesForAGraphQueryKey(
schedule.graph_id,
),
});
toast({
title: "Schedule inputs updated",
});
setIsOpen(false);
} catch (error: any) {
toast({
title: "Failed to update schedule inputs",
description: error?.message || "An unexpected error occurred.",
variant: "destructive",
});
}
setIsSaving(false);
}
return {
isOpen,
setIsOpen,
inputFields,
values,
setValues,
handleSave,
isSaving,
} as const;
}

View File

@@ -25,9 +25,10 @@ export function SelectedScheduleActions({ agent, scheduleId }: Props) {
<Button
variant="icon"
size="icon"
aria-label="Open in builder"
as="NextLink"
href={openInBuilderHref}
target="_blank"
aria-label="View scheduled task details"
>
<EyeIcon weight="bold" size={18} className="text-zinc-700" />
</Button>

View File

@@ -4,7 +4,6 @@ import type { GraphExecutionMeta } from "@/app/api/__generated__/models/graphExe
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
getAgentCredentialsFields,
getAgentInputFields,
@@ -138,25 +137,13 @@ export function SelectedTemplateView({
<RunDetailCard title="Your Input">
<div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => (
<div
<RunAgentInputs
key={key}
className="flex w-full flex-col gap-0 space-y-2"
>
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
{inputSubSchema.description && (
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
))}
</div>
</RunDetailCard>

View File

@@ -3,7 +3,6 @@
import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Input } from "@/components/atoms/Input/Input";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import {
getAgentCredentialsFields,
getAgentInputFields,
@@ -131,25 +130,13 @@ export function SelectedTriggerView({
<RunDetailCard title="Your Input">
<div className="flex flex-col gap-4">
{inputFields.map(([key, inputSubSchema]) => (
<div
<RunAgentInputs
key={key}
className="flex w-full flex-col gap-0 space-y-2"
>
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
{inputSubSchema.description && (
<InformationTooltip
description={inputSubSchema.description}
/>
)}
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
</div>
schema={inputSubSchema}
value={inputs[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => setInputValue(key, value)}
/>
))}
</div>
</RunDetailCard>

View File

@@ -680,28 +680,20 @@ export function AgentRunDraftView({
{/* Regular inputs */}
{Object.entries(agentInputFields).map(([key, inputSubSchema]) => (
<div key={key} className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">
{inputSubSchema.title || key}
<InformationTooltip
description={inputSubSchema.description}
/>
</label>
<RunAgentInputs
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => {
setInputValues((obj) => ({
...obj,
[key]: value,
}));
setChangedPresetAttributes((prev) => prev.add("inputs"));
}}
data-testid={`agent-input-${key}`}
/>
</div>
<RunAgentInputs
key={key}
schema={inputSubSchema}
value={inputValues[key] ?? inputSubSchema.default}
placeholder={inputSubSchema.description}
onChange={(value) => {
setInputValues((obj) => ({
...obj,
[key]: value,
}));
setChangedPresetAttributes((prev) => prev.add("inputs"));
}}
data-testid={`agent-input-${key}`}
/>
))}
</CardContent>
</Card>

View File

@@ -11,8 +11,16 @@ import { environment } from "@/services/environment";
import { LoadingLogin } from "./components/LoadingLogin";
import { useLoginPage } from "./useLoginPage";
import { MobileWarningBanner } from "@/components/auth/MobileWarningBanner";
import { useSearchParams } from "next/navigation";
export default function LoginPage() {
const searchParams = useSearchParams();
const nextUrl = searchParams.get("next");
// Preserve next parameter when switching between login/signup
const signupHref = nextUrl
? `/signup?next=${encodeURIComponent(nextUrl)}`
: "/signup";
const {
user,
form,
@@ -108,7 +116,7 @@ export default function LoginPage() {
</Form>
<AuthCard.BottomText
text="Don't have an account?"
link={{ text: "Sign up", href: "/signup" }}
link={{ text: "Sign up", href: signupHref }}
/>
</AuthCard>
<MobileWarningBanner />

View File

@@ -3,7 +3,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { loginFormSchema, LoginProvider } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
@@ -13,6 +13,7 @@ export function useLoginPage() {
const { supabase, user, isUserLoading, isLoggedIn } = useSupabase();
const [feedback, setFeedback] = useState<string | null>(null);
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isLoggingIn, setIsLoggingIn] = useState(false);
@@ -20,11 +21,14 @@ export function useLoginPage() {
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = environment.isCloud();
// Get redirect destination from 'next' query parameter
const nextUrl = searchParams.get("next");
useEffect(() => {
if (isLoggedIn && !isLoggingIn) {
router.push("/marketplace");
router.push(nextUrl || "/marketplace");
}
}, [isLoggedIn, isLoggingIn]);
}, [isLoggedIn, isLoggingIn, nextUrl, router]);
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
@@ -39,10 +43,16 @@ export function useLoginPage() {
setIsLoggingIn(true);
try {
// Include next URL in OAuth flow if present
const callbackUrl = nextUrl
? `/auth/callback?next=${encodeURIComponent(nextUrl)}`
: `/auth/callback`;
const fullCallbackUrl = `${window.location.origin}${callbackUrl}`;
const response = await fetch("/api/auth/provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, redirectTo: fullCallbackUrl }),
});
if (!response.ok) {
@@ -83,7 +93,9 @@ export function useLoginPage() {
throw new Error(result.error || "Login failed");
}
if (result.onboarding) {
if (nextUrl) {
router.replace(nextUrl);
} else if (result.onboarding) {
router.replace("/onboarding");
} else {
router.replace("/marketplace");

View File

@@ -1,5 +1,5 @@
import { Metadata } from "next/types";
import { APIKeysSection } from "@/app/(platform)/profile/(user)/api_keys/components/APIKeySection/APIKeySection";
import { APIKeysSection } from "@/app/(platform)/profile/(user)/api-keys/components/APIKeySection/APIKeySection";
import {
Card,
CardContent,

View File

@@ -3,13 +3,14 @@
import * as React from "react";
import { Sidebar } from "@/components/__legacy__/Sidebar";
import {
IconDashboardLayout,
IconIntegrations,
IconProfile,
IconSliders,
IconCoin,
} from "@/components/__legacy__/ui/icons";
import { KeyIcon } from "lucide-react";
AppWindowIcon,
CoinsIcon,
KeyIcon,
PlugsIcon,
SlidersHorizontalIcon,
StorefrontIcon,
UserCircleIcon,
} from "@phosphor-icons/react";
import { useGetFlag, Flag } from "@/services/feature-flags/use-get-flag";
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -18,39 +19,44 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const sidebarLinkGroups = [
{
links: [
{
text: "Profile",
href: "/profile",
icon: <UserCircleIcon className="size-5" />,
},
{
text: "Creator Dashboard",
href: "/profile/dashboard",
icon: <IconDashboardLayout className="h-6 w-6" />,
icon: <StorefrontIcon className="size-5" />,
},
...(isPaymentEnabled
...(isPaymentEnabled || true
? [
{
text: "Billing",
href: "/profile/credits",
icon: <IconCoin className="h-6 w-6" />,
icon: <CoinsIcon className="size-5" />,
},
]
: []),
{
text: "Integrations",
href: "/profile/integrations",
icon: <IconIntegrations className="h-6 w-6" />,
},
{
text: "API Keys",
href: "/profile/api_keys",
icon: <KeyIcon className="h-6 w-6" />,
},
{
text: "Profile",
href: "/profile",
icon: <IconProfile className="h-6 w-6" />,
icon: <PlugsIcon className="size-5" />,
},
{
text: "Settings",
href: "/profile/settings",
icon: <IconSliders className="h-6 w-6" />,
icon: <SlidersHorizontalIcon className="size-5" />,
},
{
text: "API Keys",
href: "/profile/api-keys",
icon: <KeyIcon className="size-5" />,
},
{
text: "OAuth Apps",
href: "/profile/oauth-apps",
icon: <AppWindowIcon className="size-5" />,
},
],
},

View File

@@ -0,0 +1,147 @@
"use client";
import { useRef } from "react";
import { UploadIcon, ImageIcon, PowerIcon } from "@phosphor-icons/react";
import { Button } from "@/components/atoms/Button/Button";
import { Badge } from "@/components/atoms/Badge/Badge";
import { useOAuthApps } from "./useOAuthApps";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
export function OAuthAppsSection() {
const {
oauthApps,
isLoading,
updatingAppId,
uploadingAppId,
handleToggleStatus,
handleUploadLogo,
} = useOAuthApps();
const fileInputRefs = useRef<{ [key: string]: HTMLInputElement | null }>({});
const handleFileChange = (
appId: string,
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (file) {
handleUploadLogo(appId, file);
}
// Reset the input so the same file can be selected again
event.target.value = "";
};
if (isLoading) {
return (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
</div>
);
}
if (oauthApps.length === 0) {
return (
<div className="py-8 text-center text-muted-foreground">
<p>You don&apos;t have any OAuth applications.</p>
<p className="mt-2 text-sm">
OAuth applications can currently <strong>not</strong> be registered
via the API. Contact the system administrator to request an OAuth app
registration.
</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-1 lg:grid-cols-2">
{oauthApps.map((app) => (
<div
key={app.id}
data-testid="oauth-app-card"
className="flex flex-col gap-4 rounded-xl border bg-card p-5"
>
{/* Header: Logo, Name, Status */}
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted">
{app.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={app.logo_url}
alt={`${app.name} logo`}
className="h-full w-full object-cover"
/>
) : (
<ImageIcon className="h-7 w-7 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-lg font-semibold">{app.name}</h3>
<Badge
className="ml-2"
variant={app.is_active ? "success" : "error"}
>
{app.is_active ? "Active" : "Disabled"}
</Badge>
</div>
{app.description && (
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{app.description}
</p>
)}
</div>
</div>
{/* Client ID */}
<div>
<span className="text-xs font-medium text-muted-foreground">
Client ID
</span>
<code
data-testid="oauth-app-client-id"
className="mt-1 block w-full truncate rounded-md border bg-muted px-3 py-2 text-xs"
>
{app.client_id}
</code>
</div>
{/* Footer: Created date and Actions */}
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<span className="text-xs text-muted-foreground">
Created {new Date(app.created_at).toLocaleDateString()}
</span>
<div className="flex items-center gap-3">
<Button
variant={app.is_active ? "outline" : "primary"}
size="small"
onClick={() => handleToggleStatus(app.id, app.is_active)}
loading={updatingAppId === app.id}
leftIcon={<PowerIcon className="h-4 w-4" />}
>
{app.is_active ? "Disable" : "Enable"}
</Button>
<input
type="file"
ref={(el) => {
fileInputRefs.current[app.id] = el;
}}
onChange={(e) => handleFileChange(app.id, e)}
accept="image/jpeg,image/png,image/webp"
className="hidden"
/>
<Button
variant="outline"
size="small"
onClick={() => fileInputRefs.current[app.id]?.click()}
loading={uploadingAppId === app.id}
leftIcon={<UploadIcon className="h-4 w-4" />}
>
{app.logo_url ? "Change " : "Upload "}Logo
</Button>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import {
useGetOauthListMyOauthApps,
usePatchOauthUpdateAppStatus,
usePostOauthUploadAppLogo,
getGetOauthListMyOauthAppsQueryKey,
} from "@/app/api/__generated__/endpoints/oauth/oauth";
import { OAuthApplicationInfo } from "@/app/api/__generated__/models/oAuthApplicationInfo";
import { okData } from "@/app/api/helpers";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { getQueryClient } from "@/lib/react-query/queryClient";
export const useOAuthApps = () => {
const queryClient = getQueryClient();
const { toast } = useToast();
const [updatingAppId, setUpdatingAppId] = useState<string | null>(null);
const [uploadingAppId, setUploadingAppId] = useState<string | null>(null);
const { data: oauthAppsResponse, isLoading } = useGetOauthListMyOauthApps({
query: { select: okData<OAuthApplicationInfo[]> },
});
const { mutateAsync: updateStatus } = usePatchOauthUpdateAppStatus({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthListMyOauthAppsQueryKey(),
});
},
},
});
const { mutateAsync: uploadLogo } = usePostOauthUploadAppLogo({
mutation: {
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: getGetOauthListMyOauthAppsQueryKey(),
});
},
},
});
const handleToggleStatus = async (appId: string, currentStatus: boolean) => {
try {
setUpdatingAppId(appId);
const result = await updateStatus({
appId,
data: { is_active: !currentStatus },
});
if (result.status === 200) {
toast({
title: "Success",
description: `Application ${result.data.is_active ? "enabled" : "disabled"} successfully`,
});
} else {
throw new Error("Failed to update status");
}
} catch {
toast({
title: "Error",
description: "Failed to update application status",
variant: "destructive",
});
} finally {
setUpdatingAppId(null);
}
};
const handleUploadLogo = async (appId: string, file: File) => {
try {
setUploadingAppId(appId);
const result = await uploadLogo({
appId,
data: { file },
});
if (result.status === 200) {
toast({
title: "Success",
description: "Logo uploaded successfully",
});
} else {
throw new Error("Failed to upload logo");
}
} catch (error) {
console.error("Failed to upload logo:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to upload logo";
toast({
title: "Error",
description: errorMessage,
variant: "destructive",
});
} finally {
setUploadingAppId(null);
}
};
return {
oauthApps: oauthAppsResponse ?? [],
isLoading,
updatingAppId,
uploadingAppId,
handleToggleStatus,
handleUploadLogo,
};
};

View File

@@ -0,0 +1,21 @@
import { Metadata } from "next/types";
import { Text } from "@/components/atoms/Text/Text";
import { OAuthAppsSection } from "./components/OAuthAppsSection";
export const metadata: Metadata = { title: "OAuth Apps - AutoGPT Platform" };
const OAuthAppsPage = () => {
return (
<div className="container space-y-6 py-10">
<div className="flex flex-col gap-2">
<Text variant="h3">OAuth Applications</Text>
<Text variant="large">
Manage your OAuth applications that use the AutoGPT Platform API
</Text>
</div>
<OAuthAppsSection />
</div>
);
};
export default OAuthAppsPage;

View File

@@ -21,8 +21,16 @@ import { WarningOctagonIcon } from "@phosphor-icons/react/dist/ssr";
import { LoadingSignup } from "./components/LoadingSignup";
import { useSignupPage } from "./useSignupPage";
import { MobileWarningBanner } from "@/components/auth/MobileWarningBanner";
import { useSearchParams } from "next/navigation";
export default function SignupPage() {
const searchParams = useSearchParams();
const nextUrl = searchParams.get("next");
// Preserve next parameter when switching between login/signup
const loginHref = nextUrl
? `/login?next=${encodeURIComponent(nextUrl)}`
: "/login";
const {
form,
feedback,
@@ -186,7 +194,7 @@ export default function SignupPage() {
<AuthCard.BottomText
text="Already a member?"
link={{ text: "Log in", href: "/login" }}
link={{ text: "Log in", href: loginHref }}
/>
</AuthCard>
<MobileWarningBanner />

View File

@@ -3,7 +3,7 @@ import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { environment } from "@/services/environment";
import { LoginProvider, signupFormSchema } from "@/types/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
@@ -14,17 +14,21 @@ export function useSignupPage() {
const [feedback, setFeedback] = useState<string | null>(null);
const { toast } = useToast();
const router = useRouter();
const searchParams = useSearchParams();
const [isLoading, setIsLoading] = useState(false);
const [isSigningUp, setIsSigningUp] = useState(false);
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
const isCloudEnv = environment.isCloud();
// Get redirect destination from 'next' query parameter
const nextUrl = searchParams.get("next");
useEffect(() => {
if (isLoggedIn && !isSigningUp) {
router.push("/marketplace");
router.push(nextUrl || "/marketplace");
}
}, [isLoggedIn, isSigningUp]);
}, [isLoggedIn, isSigningUp, nextUrl, router]);
const form = useForm<z.infer<typeof signupFormSchema>>({
resolver: zodResolver(signupFormSchema),
@@ -41,10 +45,16 @@ export function useSignupPage() {
setIsSigningUp(true);
try {
// Include next URL in OAuth flow if present
const callbackUrl = nextUrl
? `/auth/callback?next=${encodeURIComponent(nextUrl)}`
: `/auth/callback`;
const fullCallbackUrl = `${window.location.origin}${callbackUrl}`;
const response = await fetch("/api/auth/provider", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider }),
body: JSON.stringify({ provider, redirectTo: fullCallbackUrl }),
});
if (!response.ok) {
@@ -118,8 +128,9 @@ export function useSignupPage() {
return;
}
const next = result.next || "/";
if (next) router.replace(next);
// Prefer the URL's next parameter, then result.next (for onboarding), then default
const redirectTo = nextUrl || result.next || "/";
router.replace(redirectTo);
} catch (error) {
setIsLoading(false);
setIsSigningUp(false);

View File

@@ -113,6 +113,19 @@ export const customMutator = async <
body: data,
});
// Check if response is a redirect (3xx) and redirect is allowed
const allowRedirect = requestOptions.redirect !== "error";
const isRedirect = response.status >= 300 && response.status < 400;
// For redirect responses, return early without trying to parse body
if (allowRedirect && isRedirect) {
return {
status: response.status,
data: null,
headers: response.headers,
} as T;
}
if (!response.ok) {
let responseData: any = null;
try {

View File

@@ -5370,6 +5370,369 @@
}
}
},
"/api/oauth/app/{client_id}": {
"get": {
"tags": ["oauth"],
"summary": "Get Oauth App Info",
"description": "Get public information about an OAuth application.\n\nThis endpoint is used by the consent screen to display application details\nto the user before they authorize access.\n\nReturns:\n- name: Application name\n- description: Application description (if provided)\n- scopes: List of scopes the application is allowed to request",
"operationId": "getOauthGetOauthAppInfo",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "client_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Client Id" }
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationPublicInfo"
}
}
}
},
"404": { "description": "Application not found or disabled" },
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/oauth/authorize": {
"post": {
"tags": ["oauth"],
"summary": "Authorize",
"description": "OAuth 2.0 Authorization Endpoint\n\nUser must be logged in (authenticated with Supabase JWT).\nThis endpoint creates an authorization code and returns a redirect URL.\n\nPKCE (Proof Key for Code Exchange) is REQUIRED for all authorization requests.\n\nThe frontend consent screen should call this endpoint after the user approves,\nthen redirect the user to the returned `redirect_url`.\n\nRequest Body:\n- client_id: The OAuth application's client ID\n- redirect_uri: Where to redirect after authorization (must match registered URI)\n- scopes: List of permissions (e.g., \"EXECUTE_GRAPH READ_GRAPH\")\n- state: Anti-CSRF token provided by client (will be returned in redirect)\n- response_type: Must be \"code\" (for authorization code flow)\n- code_challenge: PKCE code challenge (required)\n- code_challenge_method: \"S256\" (recommended) or \"plain\"\n\nReturns:\n- redirect_url: The URL to redirect the user to (includes authorization code)\n\nError cases return a redirect_url with error parameters, or raise HTTPException\nfor critical errors (like invalid redirect_uri).",
"operationId": "postOauthAuthorize",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AuthorizeRequest" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/AuthorizeResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/oauth/token": {
"post": {
"tags": ["oauth"],
"summary": "Token",
"description": "OAuth 2.0 Token Endpoint\n\nExchanges authorization code or refresh token for access token.\n\nGrant Types:\n1. authorization_code: Exchange authorization code for tokens\n - Required: grant_type, code, redirect_uri, client_id, client_secret\n - Optional: code_verifier (required if PKCE was used)\n\n2. refresh_token: Exchange refresh token for new access token\n - Required: grant_type, refresh_token, client_id, client_secret\n\nReturns:\n- access_token: Bearer token for API access (1 hour TTL)\n- token_type: \"Bearer\"\n- expires_in: Seconds until access token expires\n- refresh_token: Token for refreshing access (30 days TTL)\n- scopes: List of scopes",
"operationId": "postOauthToken",
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{ "$ref": "#/components/schemas/TokenRequestByCode" },
{ "$ref": "#/components/schemas/TokenRequestByRefreshToken" }
],
"title": "Request"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/TokenResponse" }
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/oauth/introspect": {
"post": {
"tags": ["oauth"],
"summary": "Introspect",
"description": "OAuth 2.0 Token Introspection Endpoint (RFC 7662)\n\nAllows clients to check if a token is valid and get its metadata.\n\nReturns:\n- active: Whether the token is currently active\n- scopes: List of authorized scopes (if active)\n- client_id: The client the token was issued to (if active)\n- user_id: The user the token represents (if active)\n- exp: Expiration timestamp (if active)\n- token_type: \"access_token\" or \"refresh_token\" (if active)",
"operationId": "postOauthIntrospect",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_postOauthIntrospect"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TokenIntrospectionResult"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/oauth/revoke": {
"post": {
"tags": ["oauth"],
"summary": "Revoke",
"description": "OAuth 2.0 Token Revocation Endpoint (RFC 7009)\n\nAllows clients to revoke an access or refresh token.\n\nNote: Revoking a refresh token does NOT revoke associated access tokens.\nRevoking an access token does NOT revoke the associated refresh token.",
"operationId": "postOauthRevoke",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Body_postOauthRevoke" }
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/oauth/apps/mine": {
"get": {
"tags": ["oauth"],
"summary": "List My Oauth Apps",
"description": "List all OAuth applications owned by the current user.\n\nReturns a list of OAuth applications with their details including:\n- id, name, description, logo_url\n- client_id (public identifier)\n- redirect_uris, grant_types, scopes\n- is_active status\n- created_at, updated_at timestamps\n\nNote: client_secret is never returned for security reasons.",
"operationId": "getOauthListMyOauthApps",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
},
"type": "array",
"title": "Response Getoauthlistmyoauthapps"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
},
"/api/oauth/apps/{app_id}/status": {
"patch": {
"tags": ["oauth"],
"summary": "Update App Status",
"description": "Enable or disable an OAuth application.\n\nOnly the application owner can update the status.\nWhen disabled, the application cannot be used for new authorizations\nand existing access tokens will fail validation.\n\nReturns the updated application info.",
"operationId": "patchOauthUpdateAppStatus",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "App Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Body_patchOauthUpdateAppStatus"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/oauth/apps/{app_id}/logo": {
"patch": {
"tags": ["oauth"],
"summary": "Update App Logo",
"description": "Update the logo URL for an OAuth application.\n\nOnly the application owner can update the logo.\nThe logo should be uploaded first using the media upload endpoint,\nthen this endpoint is called with the resulting URL.\n\nLogo requirements:\n- Must be square (1:1 aspect ratio)\n- Minimum 512x512 pixels\n- Maximum 2048x2048 pixels\n\nReturns the updated application info.",
"operationId": "patchOauthUpdateAppLogo",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "App Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/UpdateAppLogoRequest" }
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/api/oauth/apps/{app_id}/logo/upload": {
"post": {
"tags": ["oauth"],
"summary": "Upload App Logo",
"description": "Upload a logo image for an OAuth application.\n\nRequirements:\n- Image must be square (1:1 aspect ratio)\n- Minimum 512x512 pixels\n- Maximum 2048x2048 pixels\n- Allowed formats: JPEG, PNG, WebP\n- Maximum file size: 3MB\n\nThe image is uploaded to cloud storage and the app's logoUrl is updated.\nReturns the updated application info.",
"operationId": "postOauthUploadAppLogo",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "app_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "App Id" }
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/Body_postOauthUploadAppLogo"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthApplicationInfo"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
}
}
},
"/health": {
"get": {
"tags": ["health"],
@@ -5418,29 +5781,30 @@
},
"APIKeyInfo": {
"properties": {
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"head": {
"type": "string",
"title": "Head",
"description": "The first 8 characters of the key"
},
"tail": {
"type": "string",
"title": "Tail",
"description": "The last 8 characters of the key"
},
"status": { "$ref": "#/components/schemas/APIKeyStatus" },
"permissions": {
"user_id": { "type": "string", "title": "User Id" },
"scopes": {
"items": { "$ref": "#/components/schemas/APIKeyPermission" },
"type": "array",
"title": "Permissions"
"title": "Scopes"
},
"type": {
"type": "string",
"const": "api_key",
"title": "Type",
"default": "api_key"
},
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"expires_at": {
"anyOf": [
{ "type": "string", "format": "date-time" },
{ "type": "null" }
],
"title": "Expires At"
},
"last_used_at": {
"anyOf": [
{ "type": "string", "format": "date-time" },
@@ -5455,28 +5819,41 @@
],
"title": "Revoked At"
},
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"head": {
"type": "string",
"title": "Head",
"description": "The first 8 characters of the key"
},
"tail": {
"type": "string",
"title": "Tail",
"description": "The last 8 characters of the key"
},
"status": { "$ref": "#/components/schemas/APIKeyStatus" },
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"user_id": { "type": "string", "title": "User Id" }
}
},
"type": "object",
"required": [
"user_id",
"scopes",
"created_at",
"id",
"name",
"head",
"tail",
"status",
"permissions",
"created_at",
"user_id"
"status"
],
"title": "APIKeyInfo"
},
"APIKeyPermission": {
"type": "string",
"enum": [
"IDENTITY",
"EXECUTE_GRAPH",
"READ_GRAPH",
"EXECUTE_BLOCK",
@@ -5614,6 +5991,72 @@
"required": ["answer", "documents", "success"],
"title": "ApiResponse"
},
"AuthorizeRequest": {
"properties": {
"client_id": {
"type": "string",
"title": "Client Id",
"description": "Client identifier"
},
"redirect_uri": {
"type": "string",
"title": "Redirect Uri",
"description": "Redirect URI"
},
"scopes": {
"items": { "type": "string" },
"type": "array",
"title": "Scopes",
"description": "List of scopes"
},
"state": {
"type": "string",
"title": "State",
"description": "Anti-CSRF token from client"
},
"response_type": {
"type": "string",
"title": "Response Type",
"description": "Must be 'code' for authorization code flow",
"default": "code"
},
"code_challenge": {
"type": "string",
"title": "Code Challenge",
"description": "PKCE code challenge (required)"
},
"code_challenge_method": {
"type": "string",
"enum": ["S256", "plain"],
"title": "Code Challenge Method",
"description": "PKCE code challenge method (S256 recommended)",
"default": "S256"
}
},
"type": "object",
"required": [
"client_id",
"redirect_uri",
"scopes",
"state",
"code_challenge"
],
"title": "AuthorizeRequest",
"description": "OAuth 2.0 authorization request"
},
"AuthorizeResponse": {
"properties": {
"redirect_url": {
"type": "string",
"title": "Redirect Url",
"description": "URL to redirect the user to"
}
},
"type": "object",
"required": ["redirect_url"],
"title": "AuthorizeResponse",
"description": "OAuth 2.0 authorization response with redirect URL"
},
"AutoTopUpConfig": {
"properties": {
"amount": { "type": "integer", "title": "Amount" },
@@ -5863,6 +6306,86 @@
"required": ["blocks", "pagination"],
"title": "BlockResponse"
},
"Body_patchOauthUpdateAppStatus": {
"properties": {
"is_active": {
"type": "boolean",
"title": "Is Active",
"description": "Whether the app should be active"
}
},
"type": "object",
"required": ["is_active"],
"title": "Body_patchOauthUpdateAppStatus"
},
"Body_postOauthIntrospect": {
"properties": {
"token": {
"type": "string",
"title": "Token",
"description": "Token to introspect"
},
"token_type_hint": {
"anyOf": [
{ "type": "string", "enum": ["access_token", "refresh_token"] },
{ "type": "null" }
],
"title": "Token Type Hint",
"description": "Hint about token type ('access_token' or 'refresh_token')"
},
"client_id": {
"type": "string",
"title": "Client Id",
"description": "Client identifier"
},
"client_secret": {
"type": "string",
"title": "Client Secret",
"description": "Client secret"
}
},
"type": "object",
"required": ["token", "client_id", "client_secret"],
"title": "Body_postOauthIntrospect"
},
"Body_postOauthRevoke": {
"properties": {
"token": {
"type": "string",
"title": "Token",
"description": "Token to revoke"
},
"token_type_hint": {
"anyOf": [
{ "type": "string", "enum": ["access_token", "refresh_token"] },
{ "type": "null" }
],
"title": "Token Type Hint",
"description": "Hint about token type ('access_token' or 'refresh_token')"
},
"client_id": {
"type": "string",
"title": "Client Id",
"description": "Client identifier"
},
"client_secret": {
"type": "string",
"title": "Client Secret",
"description": "Client secret"
}
},
"type": "object",
"required": ["token", "client_id", "client_secret"],
"title": "Body_postOauthRevoke"
},
"Body_postOauthUploadAppLogo": {
"properties": {
"file": { "type": "string", "format": "binary", "title": "File" }
},
"type": "object",
"required": ["file"],
"title": "Body_postOauthUploadAppLogo"
},
"Body_postV1Exchange_oauth_code_for_tokens": {
"properties": {
"code": {
@@ -7855,6 +8378,85 @@
"required": ["provider", "access_token", "scopes"],
"title": "OAuth2Credentials"
},
"OAuthApplicationInfo": {
"properties": {
"id": { "type": "string", "title": "Id" },
"name": { "type": "string", "title": "Name" },
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"logo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Logo Url"
},
"client_id": { "type": "string", "title": "Client Id" },
"redirect_uris": {
"items": { "type": "string" },
"type": "array",
"title": "Redirect Uris"
},
"grant_types": {
"items": { "type": "string" },
"type": "array",
"title": "Grant Types"
},
"scopes": {
"items": { "$ref": "#/components/schemas/APIKeyPermission" },
"type": "array",
"title": "Scopes"
},
"owner_id": { "type": "string", "title": "Owner Id" },
"is_active": { "type": "boolean", "title": "Is Active" },
"created_at": {
"type": "string",
"format": "date-time",
"title": "Created At"
},
"updated_at": {
"type": "string",
"format": "date-time",
"title": "Updated At"
}
},
"type": "object",
"required": [
"id",
"name",
"client_id",
"redirect_uris",
"grant_types",
"scopes",
"owner_id",
"is_active",
"created_at",
"updated_at"
],
"title": "OAuthApplicationInfo",
"description": "OAuth application information (without client secret hash)"
},
"OAuthApplicationPublicInfo": {
"properties": {
"name": { "type": "string", "title": "Name" },
"description": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Description"
},
"logo_url": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Logo Url"
},
"scopes": {
"items": { "type": "string" },
"type": "array",
"title": "Scopes"
}
},
"type": "object",
"required": ["name", "scopes"],
"title": "OAuthApplicationPublicInfo",
"description": "Public information about an OAuth application (for consent screen)"
},
"OnboardingStep": {
"type": "string",
"enum": [
@@ -9892,6 +10494,134 @@
"required": ["timezone"],
"title": "TimezoneResponse"
},
"TokenIntrospectionResult": {
"properties": {
"active": { "type": "boolean", "title": "Active" },
"scopes": {
"anyOf": [
{ "items": { "type": "string" }, "type": "array" },
{ "type": "null" }
],
"title": "Scopes"
},
"client_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Client Id"
},
"user_id": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "User Id"
},
"exp": {
"anyOf": [{ "type": "integer" }, { "type": "null" }],
"title": "Exp"
},
"token_type": {
"anyOf": [
{ "type": "string", "enum": ["access_token", "refresh_token"] },
{ "type": "null" }
],
"title": "Token Type"
}
},
"type": "object",
"required": ["active"],
"title": "TokenIntrospectionResult",
"description": "Result of token introspection (RFC 7662)"
},
"TokenRequestByCode": {
"properties": {
"grant_type": {
"type": "string",
"const": "authorization_code",
"title": "Grant Type"
},
"code": {
"type": "string",
"title": "Code",
"description": "Authorization code"
},
"redirect_uri": {
"type": "string",
"title": "Redirect Uri",
"description": "Redirect URI (must match authorization request)"
},
"client_id": { "type": "string", "title": "Client Id" },
"client_secret": { "type": "string", "title": "Client Secret" },
"code_verifier": {
"type": "string",
"title": "Code Verifier",
"description": "PKCE code verifier"
}
},
"type": "object",
"required": [
"grant_type",
"code",
"redirect_uri",
"client_id",
"client_secret",
"code_verifier"
],
"title": "TokenRequestByCode"
},
"TokenRequestByRefreshToken": {
"properties": {
"grant_type": {
"type": "string",
"const": "refresh_token",
"title": "Grant Type"
},
"refresh_token": { "type": "string", "title": "Refresh Token" },
"client_id": { "type": "string", "title": "Client Id" },
"client_secret": { "type": "string", "title": "Client Secret" }
},
"type": "object",
"required": [
"grant_type",
"refresh_token",
"client_id",
"client_secret"
],
"title": "TokenRequestByRefreshToken"
},
"TokenResponse": {
"properties": {
"token_type": {
"type": "string",
"const": "Bearer",
"title": "Token Type",
"default": "Bearer"
},
"access_token": { "type": "string", "title": "Access Token" },
"access_token_expires_at": {
"type": "string",
"format": "date-time",
"title": "Access Token Expires At"
},
"refresh_token": { "type": "string", "title": "Refresh Token" },
"refresh_token_expires_at": {
"type": "string",
"format": "date-time",
"title": "Refresh Token Expires At"
},
"scopes": {
"items": { "type": "string" },
"type": "array",
"title": "Scopes"
}
},
"type": "object",
"required": [
"access_token",
"access_token_expires_at",
"refresh_token",
"refresh_token_expires_at",
"scopes"
],
"title": "TokenResponse",
"description": "OAuth 2.0 token response"
},
"TransactionHistory": {
"properties": {
"transactions": {
@@ -9938,6 +10668,18 @@
"required": ["name", "graph_id", "graph_version", "trigger_config"],
"title": "TriggeredPresetSetupRequest"
},
"UpdateAppLogoRequest": {
"properties": {
"logo_url": {
"type": "string",
"title": "Logo Url",
"description": "URL of the uploaded logo image"
}
},
"type": "object",
"required": ["logo_url"],
"title": "UpdateAppLogoRequest"
},
"UpdatePermissionsRequest": {
"properties": {
"permissions": {

View File

@@ -6,6 +6,10 @@ import {
import { environment } from "@/services/environment";
import { NextRequest, NextResponse } from "next/server";
// Increase body size limit to 256MB to match backend file upload limit
export const maxDuration = 300; // 5 minutes timeout for large uploads
export const dynamic = "force-dynamic";
function buildBackendUrl(path: string[], queryString: string): string {
const backendPath = path.join("/");
return `${environment.getAGPTServerBaseUrl()}/${backendPath}${queryString}`;

View File

@@ -1,36 +1,33 @@
"use client";
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import { SentryUserTracker } from "@/components/monitor/SentryUserTracker";
import { BackendAPIProvider } from "@/lib/autogpt-server-api/context";
import { getQueryClient } from "@/lib/react-query/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
import {
ThemeProvider as NextThemesProvider,
ThemeProviderProps,
} from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import CredentialsProvider from "@/providers/agent-credentials/credentials-provider";
import { SentryUserTracker } from "@/components/monitor/SentryUserTracker";
import OnboardingProvider from "@/providers/onboarding/onboarding-provider";
import { LaunchDarklyProvider } from "@/services/feature-flags/feature-flag-provider";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider, ThemeProviderProps } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/next/app";
export function Providers({ children, ...props }: ThemeProviderProps) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NuqsAdapter>
<NextThemesProvider {...props}>
<BackendAPIProvider>
<SentryUserTracker />
<CredentialsProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<BackendAPIProvider>
<SentryUserTracker />
<CredentialsProvider>
<LaunchDarklyProvider>
<OnboardingProvider>
<ThemeProvider forcedTheme="light" {...props}>
<TooltipProvider>{children}</TooltipProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</NextThemesProvider>
</ThemeProvider>
</OnboardingProvider>
</LaunchDarklyProvider>
</CredentialsProvider>
</BackendAPIProvider>
</NuqsAdapter>
</QueryClientProvider>
);

View File

@@ -0,0 +1,157 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import { OverflowText } from "./OverflowText";
const meta: Meta<typeof OverflowText> = {
title: "Atoms/OverflowText",
component: OverflowText,
tags: ["autodocs"],
parameters: {
layout: "centered",
docs: {
description: {
component:
"Text component that automatically truncates overflowing content with ellipsis and shows a tooltip on hover when truncated. Supports both string and ReactNode values.",
},
},
},
argTypes: {
value: {
control: "text",
description: "The text content to display (string or ReactNode)",
},
className: {
control: "text",
description: "Additional CSS classes to customize styling",
},
},
args: {
value: "This is a sample text that may overflow",
className: "",
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: function DefaultOverflowText(args) {
return (
<div className="w-64">
<OverflowText {...args} />
</div>
);
},
};
export const ShortText: Story = {
args: {
value: "Short text",
},
render: function ShortTextStory(args) {
return (
<div className="w-64">
<OverflowText {...args} />
</div>
);
},
};
export const LongText: Story = {
args: {
value:
"This is a very long text that will definitely overflow and show a tooltip when you hover over it",
},
render: function LongTextStory(args) {
return (
<div className="w-64">
<OverflowText {...args} />
</div>
);
},
};
export const CustomStyling: Story = {
args: {
value: "Text with custom styling",
className: "text-lg font-semibold text-indigo-600",
},
render: function CustomStylingStory(args) {
return (
<div className="w-64">
<OverflowText {...args} />
</div>
);
},
};
export const WithReactNode: Story = {
args: {
value: (
<span>
Text with <strong>bold</strong> and <em>italic</em> content
</span>
),
},
render: function WithReactNodeStory(args) {
return (
<div className="w-64">
<OverflowText {...args} />
</div>
);
},
};
export const DifferentWidths: Story = {
render: function DifferentWidthsStory() {
const longText =
"This text will truncate differently depending on the container width";
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-500">Width: 200px</span>
<div className="w-[200px]">
<OverflowText value={longText} variant="body" />
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-500">Width: 300px</span>
<div className="w-[300px]">
<OverflowText value={longText} variant="body" />
</div>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs text-zinc-500">Width: 400px</span>
<div className="w-[400px]">
<OverflowText value={longText} variant="body" />
</div>
</div>
</div>
);
},
};
export const FilePathExample: Story = {
args: {
value: "/very/long/path/to/a/file/that/might/overflow/in/the/ui.tsx",
},
render: function FilePathExampleStory(args) {
return (
<div className="w-64">
<OverflowText {...args} className="font-mono text-sm" />
</div>
);
},
};
export const URLExample: Story = {
args: {
value: "https://example.com/very/long/url/path/that/might/overflow",
},
render: function URLExampleStory(args) {
return (
<div className="w-64">
<OverflowText {...args} className="text-blue-600" />
</div>
);
},
};

View File

@@ -0,0 +1,100 @@
import { Text, type TextProps } from "@/components/atoms/Text/Text";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
interface Props extends Omit<TextProps, "children"> {
value: string | ReactNode;
}
export function OverflowText(props: Props) {
const elementRef = useRef<HTMLSpanElement | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
function updateTruncation() {
const element = elementRef.current;
if (!element) {
return;
}
const hasOverflow = element.scrollWidth > element.clientWidth;
setIsTruncated(hasOverflow);
}
function setupResizeListener() {
function handleResize() {
updateTruncation();
}
window.addEventListener("resize", handleResize);
return function cleanupResizeListener() {
window.removeEventListener("resize", handleResize);
};
}
function setupObserver() {
const element = elementRef.current;
if (!element || typeof ResizeObserver === "undefined") {
return undefined;
}
function handleResizeObserver() {
updateTruncation();
}
const observer = new ResizeObserver(handleResizeObserver);
observer.observe(element);
return function disconnectObserver() {
observer.disconnect();
};
}
useEffect(() => {
if (typeof props.value === "string") updateTruncation();
}, [props.value]);
useEffect(setupResizeListener, []);
useEffect(setupObserver, []);
const { value, className, variant = "body", ...restProps } = props;
const content = (
<span
ref={elementRef}
className={cn(
"block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap",
)}
>
<Text variant={variant} className={className} {...restProps}>
{value}
</Text>
</span>
);
if (isTruncated) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>
{typeof value === "string" ? <p>{value}</p> : value}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return content;
}

View File

@@ -5,7 +5,7 @@ import { Cross2Icon } from "@radix-ui/react-icons";
import React, { useCallback } from "react";
import { GoogleDrivePicker } from "./GoogleDrivePicker";
export interface GoogleDrivePickerInputProps {
export interface Props {
config: GoogleDrivePickerConfig;
value: any;
onChange: (value: any) => void;
@@ -21,7 +21,7 @@ export function GoogleDrivePickerInput({
error,
className,
showRemoveButton = true,
}: GoogleDrivePickerInputProps) {
}: Props) {
const [pickerError, setPickerError] = React.useState<string | null>(null);
const isMultiSelect = config.multiselect || false;
const hasAutoCredentials = !!config.auto_credentials;

View File

@@ -19,7 +19,7 @@ export function MobileNavbarMenuItem({
onClick,
}: Props) {
const content = (
<div className="inline-flex w-full items-center justify-start gap-4 hover:rounded hover:bg-[#e0e0e0]">
<div className="inline-flex w-full items-center justify-start gap-4 py-2 hover:rounded hover:bg-[#e0e0e0]">
{getAccountMenuOptionIcon(icon)}
<div className="relative">
<div

View File

@@ -3,8 +3,7 @@ const commonStyles = {
title: "font-poppins text-md md:text-lg leading-none",
overlay:
"fixed inset-0 z-50 bg-stone-500/20 dark:bg-black/50 backdrop-blur-md animate-fade-in",
content:
"overflow-y-hidden bg-white p-6 fixed rounded-2xlarge flex flex-col z-50 w-full",
content: "bg-white p-6 fixed rounded-2xlarge flex flex-col z-50 w-full",
};
// Modal specific styles

View File

@@ -7,6 +7,7 @@ import { ActionButtons } from "./components/ActionButtons";
export interface ErrorCardProps {
isSuccess?: boolean;
isOurProblem?: boolean;
responseError?: {
detail?: Array<{ msg: string }> | string;
message?: string;
@@ -17,15 +18,18 @@ export interface ErrorCardProps {
message?: string;
};
context?: string;
hint?: string;
onRetry?: () => void;
className?: string;
}
export function ErrorCard({
isSuccess = false,
isOurProblem = true,
responseError,
httpError,
context = "data",
hint,
onRetry,
className = "",
}: ErrorCardProps) {
@@ -50,13 +54,19 @@ export function ErrorCard({
<CardWrapper className={className}>
<div className="relative space-y-4 p-6">
<ErrorHeader />
<ErrorMessage errorMessage={errorMessage} context={context} />
<ActionButtons
onRetry={onRetry}
responseError={responseError}
httpError={httpError}
<ErrorMessage
errorMessage={errorMessage}
context={context}
hint={hint}
/>
{isOurProblem && (
<ActionButtons
onRetry={onRetry}
responseError={responseError}
httpError={httpError}
context={context}
/>
)}
</div>
</CardWrapper>
);

View File

@@ -4,9 +4,10 @@ import { Text } from "@/components/atoms/Text/Text";
interface Props {
errorMessage: string;
context: string;
hint?: string;
}
export function ErrorMessage({ errorMessage, context }: Props) {
export function ErrorMessage({ errorMessage, context, hint }: Props) {
return (
<div className="space-y-2">
<Text variant="body" className="text-zinc-700">
@@ -17,6 +18,13 @@ export function ErrorMessage({ errorMessage, context }: Props) {
{errorMessage}
</Text>
</div>
{hint && (
<div className="!mt-4">
<Text variant="body" className="text-zinc-700">
{hint}
</Text>
</div>
)}
</div>
);
}

View File

@@ -9,16 +9,20 @@ import ReactMarkdown from "react-markdown";
type Props = {
description?: string;
iconSize?: number;
};
export function InformationTooltip({ description }: Props) {
export function InformationTooltip({ description, iconSize = 24 }: Props) {
if (!description) return null;
return (
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="rounded-full p-1 hover:bg-slate-50" size={24} />
<Info
className="rounded-full p-1 hover:bg-slate-50"
size={iconSize}
/>
</TooltipTrigger>
<TooltipContent>
<ReactMarkdown

View File

@@ -0,0 +1,437 @@
import type { Meta, StoryObj } from "@storybook/nextjs";
import {
ScrollableTabs,
ScrollableTabsContent,
ScrollableTabsList,
ScrollableTabsTrigger,
} from "./ScrollableTabs";
const meta = {
title: "Molecules/ScrollableTabs",
component: ScrollableTabs,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof ScrollableTabs>;
export default meta;
type Story = StoryObj<typeof meta>;
function ScrollableTabsDemo() {
return (
<div className="flex flex-col gap-8 p-8">
<h2 className="text-2xl font-bold">ScrollableTabs Examples</h2>
<div className="space-y-6">
<div>
<h3 className="mb-4 text-lg font-semibold">
Short Content (Tabs Hidden)
</h3>
<div className="h-[300px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="tab1" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="tab1">
Account
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab2">
Password
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab3">
Settings
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="tab1">
<div className="p-4 text-sm">
Make changes to your account here. Click save when you&apos;re
done.
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab2">
<div className="p-4 text-sm">
Change your password here. After saving, you&apos;ll be logged
out.
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab3">
<div className="p-4 text-sm">
Update your preferences and settings here.
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">
Long Content (Tabs Visible)
</h3>
<div className="h-[400px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="tab1" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="tab1">
Account
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab2">
Password
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab3">
Settings
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="tab1">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
Account Settings
</h4>
<p className="mb-4">
Make changes to your account here. Click save when
you&apos;re done.
</p>
<p className="mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna
aliqua. Ut enim ad minim veniam, quis nostrud exercitation
ullamco laboris.
</p>
<p className="mb-4">
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur. Excepteur sint
occaecat cupidatat non proident.
</p>
<p>
Sed ut perspiciatis unde omnis iste natus error sit
voluptatem accusantium doloremque laudantium, totam rem
aperiam.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab2">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
Password Settings
</h4>
<p className="mb-4">
Change your password here. After saving, you&apos;ll be
logged out.
</p>
<p className="mb-4">
At vero eos et accusamus et iusto odio dignissimos ducimus
qui blanditiis praesentium voluptatum deleniti atque
corrupti quos dolores et quas molestias excepturi sint
occaecati cupiditate.
</p>
<p className="mb-4">
Et harum quidem rerum facilis est et expedita distinctio.
Nam libero tempore, cum soluta nobis est eligendi optio
cumque nihil impedit quo minus.
</p>
<p>
Temporibus autem quibusdam et aut officiis debitis aut rerum
necessitatibus saepe eveniet ut et voluptates repudiandae
sint.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab3">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
General Settings
</h4>
<p className="mb-4">
Update your preferences and settings here.
</p>
<p className="mb-4">
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut
odit aut fugit, sed quia consequuntur magni dolores eos qui
ratione voluptatem sequi nesciunt.
</p>
<p className="mb-4">
Neque porro quisquam est, qui dolorem ipsum quia dolor sit
amet, consectetur, adipisci velit, sed quia non numquam eius
modi tempora incidunt ut labore et dolore magnam aliquam
quaerat voluptatem.
</p>
<p>
Ut enim ad minima veniam, quis nostrum exercitationem ullam
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi
consequatur.
</p>
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
<div>
<h3 className="mb-4 text-lg font-semibold">Many Tabs</h3>
<div className="h-[500px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="overview" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="overview">
Overview
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="analytics">
Analytics
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="reports">
Reports
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="notifications">
Notifications
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="integrations">
Integrations
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="billing">
Billing
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="overview">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">
Dashboard Overview
</h4>
<p className="mb-4">
Dashboard overview with key metrics and recent activity.
</p>
<p className="mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed
do eiusmod tempor incididunt ut labore et dolore magna
aliqua.
</p>
<p>
Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="analytics">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Analytics</h4>
<p className="mb-4">
Detailed analytics and performance metrics.
</p>
<p className="mb-4">
Duis aute irure dolor in reprehenderit in voluptate velit
esse cillum dolore eu fugiat nulla pariatur.
</p>
<p>
Excepteur sint occaecat cupidatat non proident, sunt in
culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="reports">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Reports</h4>
<p className="mb-4">
Generate and view reports for your account.
</p>
<p className="mb-4">
Sed ut perspiciatis unde omnis iste natus error sit
voluptatem accusantium doloremque laudantium.
</p>
<p>
Totam rem aperiam, eaque ipsa quae ab illo inventore
veritatis et quasi architecto beatae vitae dicta sunt
explicabo.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="notifications">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Notifications</h4>
<p className="mb-4">Manage your notification preferences.</p>
<p className="mb-4">
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut
odit aut fugit.
</p>
<p>
Sed quia consequuntur magni dolores eos qui ratione
voluptatem sequi nesciunt.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="integrations">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Integrations</h4>
<p className="mb-4">
Connect and manage third-party integrations.
</p>
<p className="mb-4">
Neque porro quisquam est, qui dolorem ipsum quia dolor sit
amet.
</p>
<p>
Consectetur, adipisci velit, sed quia non numquam eius modi
tempora incidunt.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="billing">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Billing</h4>
<p className="mb-4">
View and manage your billing information.
</p>
<p className="mb-4">
Ut enim ad minima veniam, quis nostrum exercitationem ullam
corporis suscipit laboriosam.
</p>
<p>
Nisi ut aliquid ex ea commodi consequatur? Quis autem vel
eum iure reprehenderit qui in ea voluptate velit esse.
</p>
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
</div>
</div>
);
}
export const Default = {
render: () => <ScrollableTabsDemo />,
} satisfies Story;
export const ShortContent = {
render: () => (
<div className="p-8">
<div className="h-[200px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="account" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="account">
Account
</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="password">
Password
</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="account">
<div className="p-4 text-sm">
Make changes to your account here. Click save when you&apos;re
done.
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="password">
<div className="p-4 text-sm">
Change your password here. After saving, you&apos;ll be logged
out.
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
),
} satisfies Story;
export const LongContent = {
render: () => (
<div className="p-8">
<div className="h-[600px] overflow-y-auto border border-zinc-200">
<ScrollableTabs defaultValue="tab1" className="h-full">
<ScrollableTabsList>
<ScrollableTabsTrigger value="tab1">Account</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab2">Password</ScrollableTabsTrigger>
<ScrollableTabsTrigger value="tab3">Settings</ScrollableTabsTrigger>
</ScrollableTabsList>
<ScrollableTabsContent value="tab1">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Account Settings</h4>
<p className="mb-4">
Make changes to your account here. Click save when you&apos;re
done.
</p>
<p className="mb-4">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat.
</p>
<p className="mb-4">
Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt
mollit anim id est laborum.
</p>
<p className="mb-4">
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
accusantium doloremque laudantium, totam rem aperiam, eaque ipsa
quae ab illo inventore veritatis et quasi architecto beatae
vitae dicta sunt explicabo.
</p>
<p>
Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit
aut fugit, sed quia consequuntur magni dolores eos qui ratione
voluptatem sequi nesciunt.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab2">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">Password Settings</h4>
<p className="mb-4">
Change your password here. After saving, you&apos;ll be logged
out.
</p>
<p className="mb-4">
At vero eos et accusamus et iusto odio dignissimos ducimus qui
blanditiis praesentium voluptatum deleniti atque corrupti quos
dolores et quas molestias excepturi sint occaecati cupiditate
non provident.
</p>
<p className="mb-4">
Similique sunt in culpa qui officia deserunt mollitia animi, id
est laborum et dolorum fuga. Et harum quidem rerum facilis est
et expedita distinctio.
</p>
<p className="mb-4">
Nam libero tempore, cum soluta nobis est eligendi optio cumque
nihil impedit quo minus id quod maxime placeat facere possimus,
omnis voluptas assumenda est, omnis dolor repellendus.
</p>
<p>
Temporibus autem quibusdam et aut officiis debitis aut rerum
necessitatibus saepe eveniet ut et voluptates repudiandae sint
et molestiae non recusandae.
</p>
</div>
</ScrollableTabsContent>
<ScrollableTabsContent value="tab3">
<div className="p-8 text-sm">
<h4 className="mb-4 text-lg font-semibold">General Settings</h4>
<p className="mb-4">Update your preferences and settings here.</p>
<p className="mb-4">
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet,
consectetur, adipisci velit, sed quia non numquam eius modi
tempora incidunt ut labore et dolore magnam aliquam quaerat
voluptatem.
</p>
<p className="mb-4">
Ut enim ad minima veniam, quis nostrum exercitationem ullam
corporis suscipit laboriosam, nisi ut aliquid ex ea commodi
consequatur? Quis autem vel eum iure reprehenderit qui in ea
voluptate velit esse quam nihil molestiae consequatur.
</p>
<p className="mb-4">
Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur? At
vero eos et accusamus et iusto odio dignissimos ducimus qui
blanditiis praesentium voluptatum deleniti atque corrupti quos
dolores.
</p>
<p>
Et quas molestias excepturi sint occaecati cupiditate non
provident, similique sunt in culpa qui officia deserunt mollitia
animi, id est laborum et dolorum fuga.
</p>
</div>
</ScrollableTabsContent>
</ScrollableTabs>
</div>
</div>
),
} satisfies Story;

View File

@@ -0,0 +1,59 @@
"use client";
import { cn } from "@/lib/utils";
import { Children } from "react";
import { ScrollableTabsContent } from "./components/ScrollableTabsContent";
import { ScrollableTabsList } from "./components/ScrollableTabsList";
import { ScrollableTabsTrigger } from "./components/ScrollableTabsTrigger";
import { ScrollableTabsContext } from "./context";
import { findContentElements, findListElement } from "./helpers";
import { useScrollableTabsInternal } from "./useScrollableTabs";
interface Props {
children?: React.ReactNode;
className?: string;
defaultValue?: string;
}
export function ScrollableTabs({ children, className, defaultValue }: Props) {
const {
activeValue,
setActiveValue,
registerContent,
scrollToSection,
scrollContainer,
contentContainerRef,
} = useScrollableTabsInternal({ defaultValue });
const childrenArray = Children.toArray(children);
const listElement = findListElement(childrenArray);
const contentElements = findContentElements(childrenArray);
return (
<ScrollableTabsContext.Provider
value={{
activeValue,
setActiveValue,
registerContent,
scrollToSection,
scrollContainer,
}}
>
<div className={cn("relative flex flex-col", className)}>
{listElement}
<div
ref={(node) => {
if (contentContainerRef) {
contentContainerRef.current = node;
}
}}
className="max-h-[64rem] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700"
>
<div className="min-h-full pb-[200px]">{contentElements}</div>
</div>
</div>
</ScrollableTabsContext.Provider>
);
}
export { ScrollableTabsContent, ScrollableTabsList, ScrollableTabsTrigger };

View File

@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useScrollableTabs } from "../context";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
export const ScrollableTabsContent = React.forwardRef<HTMLDivElement, Props>(
function ScrollableTabsContent(
{ className, value, children, ...props },
ref,
) {
const { registerContent } = useScrollableTabs();
const contentRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (contentRef.current) {
registerContent(value, contentRef.current);
}
return () => {
registerContent(value, null);
};
}, [value, registerContent]);
return (
<div
ref={(node) => {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
contentRef.current = node;
}}
data-scrollable-tab-content
data-value={value}
className={cn("focus-visible:outline-none", className)}
{...props}
>
{children}
</div>
);
},
);
ScrollableTabsContent.displayName = "ScrollableTabsContent";

View File

@@ -0,0 +1,52 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useScrollableTabs } from "../context";
export const ScrollableTabsList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function ScrollableTabsList({ className, children, ...props }, ref) {
const { activeValue } = useScrollableTabs();
const [activeTabElement, setActiveTabElement] =
React.useState<HTMLElement | null>(null);
React.useEffect(() => {
const activeButton = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-scrollable-tab-trigger][data-value="' + activeValue + '"]',
),
)[0];
if (activeButton) {
setActiveTabElement(activeButton);
}
}, [activeValue]);
return (
<div className="relative" ref={ref}>
<div
className={cn(
"inline-flex w-full items-center justify-start border-b border-zinc-100",
className,
)}
{...props}
>
{children}
</div>
{activeTabElement && (
<div
className="transition-left transition-right absolute bottom-0 h-0.5 bg-purple-600 duration-200 ease-in-out"
style={{
left: activeTabElement.offsetLeft,
width: activeTabElement.offsetWidth,
willChange: "left, width",
}}
/>
)}
</div>
);
});
ScrollableTabsList.displayName = "ScrollableTabsList";

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@/lib/utils";
import * as React from "react";
import { useScrollableTabs } from "../context";
interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
export const ScrollableTabsTrigger = React.forwardRef<HTMLButtonElement, Props>(
function ScrollableTabsTrigger(
{ className, value, children, ...props },
ref,
) {
const { activeValue, scrollToSection } = useScrollableTabs();
const elementRef = React.useRef<HTMLButtonElement>(null);
const isActive = activeValue === value;
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
e.stopPropagation();
scrollToSection(value);
props.onClick?.(e);
}
return (
<button
type="button"
ref={(node) => {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
elementRef.current = node;
}}
data-scrollable-tab-trigger
data-value={value}
onClick={handleClick}
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-3 font-sans text-[0.875rem] font-medium leading-[1.5rem] text-zinc-700 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive && "text-purple-600",
className,
)}
{...props}
>
{children}
</button>
);
},
);
ScrollableTabsTrigger.displayName = "ScrollableTabsTrigger";

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { createContext, useContext } from "react";
interface ScrollableTabsContextValue {
activeValue: string | null;
setActiveValue: React.Dispatch<React.SetStateAction<string | null>>;
registerContent: (value: string, element: HTMLElement | null) => void;
scrollToSection: (value: string) => void;
scrollContainer: HTMLElement | null;
}
export const ScrollableTabsContext = createContext<
ScrollableTabsContextValue | undefined
>(undefined);
export function useScrollableTabs() {
const context = useContext(ScrollableTabsContext);
if (!context) {
throw new Error("useScrollableTabs must be used within a ScrollableTabs");
}
return context;
}

View File

@@ -0,0 +1,48 @@
import * as React from "react";
const HEADER_OFFSET = 100;
export function calculateScrollPosition(
elementRect: DOMRect,
containerRect: DOMRect,
currentScrollTop: number,
): number {
const elementTopRelativeToContainer =
elementRect.top - containerRect.top + currentScrollTop - HEADER_OFFSET;
return Math.max(0, elementTopRelativeToContainer);
}
function hasDisplayName(
type: unknown,
displayName: string,
): type is { displayName: string } {
return (
typeof type === "object" &&
type !== null &&
"displayName" in type &&
(type as { displayName: unknown }).displayName === displayName
);
}
export function findListElement(
children: React.ReactNode[],
): React.ReactElement | undefined {
return children.find(
(child) =>
React.isValidElement(child) &&
hasDisplayName(child.type, "ScrollableTabsList"),
) as React.ReactElement | undefined;
}
export function findContentElements(
children: React.ReactNode[],
): React.ReactNode[] {
return children.filter(
(child) =>
!(
React.isValidElement(child) &&
hasDisplayName(child.type, "ScrollableTabsList")
),
);
}

Some files were not shown because too many files have changed in this diff Show More