mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-06 22:03:59 -05:00
Merge branch 'dev' into update-exa
This commit is contained in:
8
.github/workflows/platform-frontend-ci.yml
vendored
8
.github/workflows/platform-frontend-ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "21"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "21"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "21"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "21"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
4
.github/workflows/platform-fullstack-ci.yml
vendored
4
.github/workflows/platform-fullstack-ci.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "21"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "21"
|
||||
node-version: "22.18.0"
|
||||
|
||||
- name: Enable corepack
|
||||
run: corepack enable
|
||||
|
||||
@@ -4,11 +4,18 @@ FastAPI dependency functions for JWT-based authentication and authorization.
|
||||
These are the high-level dependency functions used in route definitions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import fastapi
|
||||
|
||||
from .jwt_utils import get_jwt_payload, verify_user
|
||||
from .models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Header name for admin impersonation
|
||||
IMPERSONATION_HEADER_NAME = "X-Act-As-User-Id"
|
||||
|
||||
|
||||
async def requires_user(jwt_payload: dict = fastapi.Security(get_jwt_payload)) -> User:
|
||||
"""
|
||||
@@ -32,16 +39,44 @@ async def requires_admin_user(
|
||||
return verify_user(jwt_payload, admin_only=True)
|
||||
|
||||
|
||||
async def get_user_id(jwt_payload: dict = fastapi.Security(get_jwt_payload)) -> str:
|
||||
async def get_user_id(
|
||||
request: fastapi.Request, jwt_payload: dict = fastapi.Security(get_jwt_payload)
|
||||
) -> str:
|
||||
"""
|
||||
FastAPI dependency that returns the ID of the authenticated user.
|
||||
|
||||
Supports admin impersonation via X-Act-As-User-Id header:
|
||||
- If the header is present and user is admin, returns the impersonated user ID
|
||||
- Otherwise returns the authenticated user's own ID
|
||||
- Logs all impersonation actions for audit trail
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 for authentication failures or missing user ID
|
||||
HTTPException: 403 if non-admin tries to use impersonation
|
||||
"""
|
||||
# Get the authenticated user's ID from JWT
|
||||
user_id = jwt_payload.get("sub")
|
||||
if not user_id:
|
||||
raise fastapi.HTTPException(
|
||||
status_code=401, detail="User ID not found in token"
|
||||
)
|
||||
|
||||
# Check for admin impersonation header
|
||||
impersonate_header = request.headers.get(IMPERSONATION_HEADER_NAME, "").strip()
|
||||
if impersonate_header:
|
||||
# Verify the authenticated user is an admin
|
||||
authenticated_user = verify_user(jwt_payload, admin_only=False)
|
||||
if authenticated_user.role != "admin":
|
||||
raise fastapi.HTTPException(
|
||||
status_code=403, detail="Only admin users can impersonate other users"
|
||||
)
|
||||
|
||||
# Log the impersonation for audit trail
|
||||
logger.info(
|
||||
f"Admin impersonation: {authenticated_user.user_id} ({authenticated_user.email}) "
|
||||
f"acting as user {impersonate_header} for requesting {request.method} {request.url}"
|
||||
)
|
||||
|
||||
return impersonate_header
|
||||
|
||||
return user_id
|
||||
|
||||
@@ -4,9 +4,10 @@ Tests the full authentication flow from HTTP requests to user validation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException, Security
|
||||
from fastapi import FastAPI, HTTPException, Request, Security
|
||||
from fastapi.testclient import TestClient
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
@@ -45,6 +46,7 @@ class TestAuthDependencies:
|
||||
"""Create a test client."""
|
||||
return TestClient(app)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_user_with_valid_jwt_payload(self, mocker: MockerFixture):
|
||||
"""Test requires_user with valid JWT payload."""
|
||||
jwt_payload = {"sub": "user-123", "role": "user", "email": "user@example.com"}
|
||||
@@ -58,6 +60,7 @@ class TestAuthDependencies:
|
||||
assert user.user_id == "user-123"
|
||||
assert user.role == "user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_user_with_admin_jwt_payload(self, mocker: MockerFixture):
|
||||
"""Test requires_user accepts admin users."""
|
||||
jwt_payload = {
|
||||
@@ -73,6 +76,7 @@ class TestAuthDependencies:
|
||||
assert user.user_id == "admin-456"
|
||||
assert user.role == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_user_missing_sub(self):
|
||||
"""Test requires_user with missing user ID."""
|
||||
jwt_payload = {"role": "user", "email": "user@example.com"}
|
||||
@@ -82,6 +86,7 @@ class TestAuthDependencies:
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "User ID not found" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_user_empty_sub(self):
|
||||
"""Test requires_user with empty user ID."""
|
||||
jwt_payload = {"sub": "", "role": "user"}
|
||||
@@ -90,6 +95,7 @@ class TestAuthDependencies:
|
||||
await requires_user(jwt_payload)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_admin_user_with_admin(self, mocker: MockerFixture):
|
||||
"""Test requires_admin_user with admin role."""
|
||||
jwt_payload = {
|
||||
@@ -105,6 +111,7 @@ class TestAuthDependencies:
|
||||
assert user.user_id == "admin-789"
|
||||
assert user.role == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_admin_user_with_regular_user(self):
|
||||
"""Test requires_admin_user rejects regular users."""
|
||||
jwt_payload = {"sub": "user-123", "role": "user", "email": "user@example.com"}
|
||||
@@ -114,6 +121,7 @@ class TestAuthDependencies:
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Admin access required" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_admin_user_missing_role(self):
|
||||
"""Test requires_admin_user with missing role."""
|
||||
jwt_payload = {"sub": "user-123", "email": "user@example.com"}
|
||||
@@ -121,31 +129,40 @@ class TestAuthDependencies:
|
||||
with pytest.raises(KeyError):
|
||||
await requires_admin_user(jwt_payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_id_with_valid_payload(self, mocker: MockerFixture):
|
||||
"""Test get_user_id extracts user ID correctly."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
jwt_payload = {"sub": "user-id-xyz", "role": "user"}
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
user_id = await get_user_id(jwt_payload)
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
assert user_id == "user-id-xyz"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_id_missing_sub(self):
|
||||
"""Test get_user_id with missing user ID."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
jwt_payload = {"role": "user"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_user_id(jwt_payload)
|
||||
await get_user_id(request, jwt_payload)
|
||||
assert exc_info.value.status_code == 401
|
||||
assert "User ID not found" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_id_none_sub(self):
|
||||
"""Test get_user_id with None user ID."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {}
|
||||
jwt_payload = {"sub": None, "role": "user"}
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_user_id(jwt_payload)
|
||||
await get_user_id(request, jwt_payload)
|
||||
assert exc_info.value.status_code == 401
|
||||
|
||||
|
||||
@@ -170,6 +187,7 @@ class TestAuthDependenciesIntegration:
|
||||
|
||||
return _create_token
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_auth_enabled_no_token(self):
|
||||
"""Test endpoints require token when auth is enabled."""
|
||||
app = FastAPI()
|
||||
@@ -184,6 +202,7 @@ class TestAuthDependenciesIntegration:
|
||||
response = client.get("/test")
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_endpoint_with_valid_token(self, create_token):
|
||||
"""Test endpoint with valid JWT token."""
|
||||
app = FastAPI()
|
||||
@@ -203,6 +222,7 @@ class TestAuthDependenciesIntegration:
|
||||
assert response.status_code == 200
|
||||
assert response.json()["user_id"] == "test-user"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_endpoint_requires_admin_role(self, create_token):
|
||||
"""Test admin endpoint rejects non-admin users."""
|
||||
app = FastAPI()
|
||||
@@ -240,6 +260,7 @@ class TestAuthDependenciesIntegration:
|
||||
class TestAuthDependenciesEdgeCases:
|
||||
"""Edge case tests for authentication dependencies."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_with_complex_payload(self):
|
||||
"""Test dependencies handle complex JWT payloads."""
|
||||
complex_payload = {
|
||||
@@ -263,6 +284,7 @@ class TestAuthDependenciesEdgeCases:
|
||||
admin = await requires_admin_user(complex_payload)
|
||||
assert admin.role == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_with_unicode_in_payload(self):
|
||||
"""Test dependencies handle unicode in JWT payloads."""
|
||||
unicode_payload = {
|
||||
@@ -276,6 +298,7 @@ class TestAuthDependenciesEdgeCases:
|
||||
assert "😀" in user.user_id
|
||||
assert user.email == "测试@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_with_null_values(self):
|
||||
"""Test dependencies handle null values in payload."""
|
||||
null_payload = {
|
||||
@@ -290,6 +313,7 @@ class TestAuthDependenciesEdgeCases:
|
||||
assert user.user_id == "user-123"
|
||||
assert user.email is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_requests_isolation(self):
|
||||
"""Test that concurrent requests don't interfere with each other."""
|
||||
payload1 = {"sub": "user-1", "role": "user"}
|
||||
@@ -314,6 +338,7 @@ class TestAuthDependenciesEdgeCases:
|
||||
({"sub": "user", "role": "user"}, "Admin access required", True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_error_cases(
|
||||
self, payload, expected_error: str, admin_only: bool
|
||||
):
|
||||
@@ -325,6 +350,7 @@ class TestAuthDependenciesEdgeCases:
|
||||
verify_user(payload, admin_only=admin_only)
|
||||
assert expected_error in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dependency_valid_user(self):
|
||||
"""Test valid user case for dependency."""
|
||||
# Import verify_user to test it directly since dependencies use FastAPI Security
|
||||
@@ -333,3 +359,196 @@ class TestAuthDependenciesEdgeCases:
|
||||
# Valid case
|
||||
user = verify_user({"sub": "user", "role": "user"}, admin_only=False)
|
||||
assert user.user_id == "user"
|
||||
|
||||
|
||||
class TestAdminImpersonation:
|
||||
"""Test suite for admin user impersonation functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_impersonation_success(self, mocker: MockerFixture):
|
||||
"""Test admin successfully impersonating another user."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"X-Act-As-User-Id": "target-user-123"}
|
||||
jwt_payload = {
|
||||
"sub": "admin-456",
|
||||
"role": "admin",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
# Mock verify_user to return admin user data
|
||||
mock_verify_user = mocker.patch("autogpt_libs.auth.dependencies.verify_user")
|
||||
mock_verify_user.return_value = Mock(
|
||||
user_id="admin-456", email="admin@example.com", role="admin"
|
||||
)
|
||||
|
||||
# Mock logger to verify audit logging
|
||||
mock_logger = mocker.patch("autogpt_libs.auth.dependencies.logger")
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
|
||||
# Should return the impersonated user ID
|
||||
assert user_id == "target-user-123"
|
||||
|
||||
# Should log the impersonation attempt
|
||||
mock_logger.info.assert_called_once()
|
||||
log_call = mock_logger.info.call_args[0][0]
|
||||
assert "Admin impersonation:" in log_call
|
||||
assert "admin@example.com" in log_call
|
||||
assert "target-user-123" in log_call
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_impersonation_attempt(self, mocker: MockerFixture):
|
||||
"""Test non-admin user attempting impersonation returns 403."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"X-Act-As-User-Id": "target-user-123"}
|
||||
jwt_payload = {
|
||||
"sub": "regular-user",
|
||||
"role": "user",
|
||||
"email": "user@example.com",
|
||||
}
|
||||
|
||||
# Mock verify_user to return regular user data
|
||||
mock_verify_user = mocker.patch("autogpt_libs.auth.dependencies.verify_user")
|
||||
mock_verify_user.return_value = Mock(
|
||||
user_id="regular-user", email="user@example.com", role="user"
|
||||
)
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_user_id(request, jwt_payload)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Only admin users can impersonate other users" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_impersonation_empty_header(self, mocker: MockerFixture):
|
||||
"""Test impersonation with empty header falls back to regular user ID."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"X-Act-As-User-Id": ""}
|
||||
jwt_payload = {
|
||||
"sub": "admin-456",
|
||||
"role": "admin",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
|
||||
# Should fall back to the admin's own user ID
|
||||
assert user_id == "admin-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_impersonation_missing_header(self, mocker: MockerFixture):
|
||||
"""Test normal behavior when impersonation header is missing."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {} # No impersonation header
|
||||
jwt_payload = {
|
||||
"sub": "admin-456",
|
||||
"role": "admin",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
|
||||
# Should return the admin's own user ID
|
||||
assert user_id == "admin-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_impersonation_audit_logging_details(self, mocker: MockerFixture):
|
||||
"""Test that impersonation audit logging includes all required details."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"X-Act-As-User-Id": "victim-user-789"}
|
||||
jwt_payload = {
|
||||
"sub": "admin-999",
|
||||
"role": "admin",
|
||||
"email": "superadmin@company.com",
|
||||
}
|
||||
|
||||
# Mock verify_user to return admin user data
|
||||
mock_verify_user = mocker.patch("autogpt_libs.auth.dependencies.verify_user")
|
||||
mock_verify_user.return_value = Mock(
|
||||
user_id="admin-999", email="superadmin@company.com", role="admin"
|
||||
)
|
||||
|
||||
# Mock logger to capture audit trail
|
||||
mock_logger = mocker.patch("autogpt_libs.auth.dependencies.logger")
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
|
||||
# Verify all audit details are logged
|
||||
assert user_id == "victim-user-789"
|
||||
mock_logger.info.assert_called_once()
|
||||
|
||||
log_message = mock_logger.info.call_args[0][0]
|
||||
assert "Admin impersonation:" in log_message
|
||||
assert "superadmin@company.com" in log_message
|
||||
assert "victim-user-789" in log_message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_impersonation_header_case_sensitivity(self, mocker: MockerFixture):
|
||||
"""Test that impersonation header is case-sensitive."""
|
||||
request = Mock(spec=Request)
|
||||
# Use wrong case - should not trigger impersonation
|
||||
request.headers = {"x-act-as-user-id": "target-user-123"}
|
||||
jwt_payload = {
|
||||
"sub": "admin-456",
|
||||
"role": "admin",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
|
||||
# Should fall back to admin's own ID (header case mismatch)
|
||||
assert user_id == "admin-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_impersonation_with_whitespace_header(self, mocker: MockerFixture):
|
||||
"""Test impersonation with whitespace in header value."""
|
||||
request = Mock(spec=Request)
|
||||
request.headers = {"X-Act-As-User-Id": " target-user-123 "}
|
||||
jwt_payload = {
|
||||
"sub": "admin-456",
|
||||
"role": "admin",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
# Mock verify_user to return admin user data
|
||||
mock_verify_user = mocker.patch("autogpt_libs.auth.dependencies.verify_user")
|
||||
mock_verify_user.return_value = Mock(
|
||||
user_id="admin-456", email="admin@example.com", role="admin"
|
||||
)
|
||||
|
||||
# Mock logger
|
||||
mock_logger = mocker.patch("autogpt_libs.auth.dependencies.logger")
|
||||
|
||||
mocker.patch(
|
||||
"autogpt_libs.auth.dependencies.get_jwt_payload", return_value=jwt_payload
|
||||
)
|
||||
|
||||
user_id = await get_user_id(request, jwt_payload)
|
||||
|
||||
# Should strip whitespace and impersonate successfully
|
||||
assert user_id == "target-user-123"
|
||||
mock_logger.info.assert_called_once()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
# Configure pnpm to save exact versions
|
||||
save-exact=true
|
||||
save-exact=true
|
||||
engine-strict=true
|
||||
@@ -2,6 +2,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.3.4",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "pnpm run generate:api:force && next dev --turbo",
|
||||
"build": "next build",
|
||||
@@ -26,7 +29,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "10.0.0",
|
||||
"@hookform/resolvers": "5.2.1",
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
"@marsidev/react-turnstile": "1.3.1",
|
||||
"@next/third-parties": "15.4.6",
|
||||
"@phosphor-icons/react": "2.1.10",
|
||||
@@ -52,49 +55,49 @@
|
||||
"@rjsf/core": "5.24.13",
|
||||
"@rjsf/utils": "5.24.13",
|
||||
"@rjsf/validator-ajv8": "5.24.13",
|
||||
"@sentry/nextjs": "10.15.0",
|
||||
"@supabase/ssr": "0.6.1",
|
||||
"@supabase/supabase-js": "2.55.0",
|
||||
"@tanstack/react-query": "5.87.1",
|
||||
"@sentry/nextjs": "10.22.0",
|
||||
"@supabase/ssr": "0.7.0",
|
||||
"@supabase/supabase-js": "2.78.0",
|
||||
"@tanstack/react-query": "5.90.6",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@types/jaro-winkler": "0.2.4",
|
||||
"@vercel/analytics": "1.5.0",
|
||||
"@vercel/speed-insights": "1.2.0",
|
||||
"@xyflow/react": "12.8.3",
|
||||
"@xyflow/react": "12.9.2",
|
||||
"boring-avatars": "1.11.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"cookie": "1.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
"dotenv": "17.2.1",
|
||||
"dotenv": "17.2.3",
|
||||
"elliptic": "6.6.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"framer-motion": "12.23.12",
|
||||
"geist": "1.4.2",
|
||||
"framer-motion": "12.23.24",
|
||||
"geist": "1.5.1",
|
||||
"highlight.js": "11.11.1",
|
||||
"jaro-winkler": "0.2.8",
|
||||
"katex": "0.16.22",
|
||||
"launchdarkly-react-client-sdk": "3.8.1",
|
||||
"katex": "0.16.25",
|
||||
"launchdarkly-react-client-sdk": "3.9.0",
|
||||
"lodash": "4.17.21",
|
||||
"lucide-react": "0.539.0",
|
||||
"lucide-react": "0.552.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.4.7",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.4.3",
|
||||
"nuqs": "2.7.2",
|
||||
"party-js": "2.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-currency-input-field": "4.0.3",
|
||||
"react-day-picker": "9.8.1",
|
||||
"react-day-picker": "9.11.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-drag-drop-files": "2.4.0",
|
||||
"react-hook-form": "7.62.0",
|
||||
"react-hook-form": "7.66.0",
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "9.0.3",
|
||||
"react-modal": "3.16.3",
|
||||
"react-shepherd": "6.1.9",
|
||||
"react-window": "1.8.11",
|
||||
"recharts": "3.1.2",
|
||||
"recharts": "3.3.0",
|
||||
"rehype-autolink-headings": "7.1.0",
|
||||
"rehype-highlight": "7.0.2",
|
||||
"rehype-katex": "7.0.1",
|
||||
@@ -112,47 +115,47 @@
|
||||
"zustand": "5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "4.1.1",
|
||||
"@playwright/test": "1.55.0",
|
||||
"@chromatic-com/storybook": "4.1.2",
|
||||
"@playwright/test": "1.56.1",
|
||||
"@storybook/addon-a11y": "9.1.5",
|
||||
"@storybook/addon-docs": "9.1.5",
|
||||
"@storybook/addon-links": "9.1.5",
|
||||
"@storybook/addon-onboarding": "9.1.5",
|
||||
"@storybook/nextjs": "9.1.5",
|
||||
"@tanstack/eslint-plugin-query": "5.86.0",
|
||||
"@tanstack/react-query-devtools": "5.87.3",
|
||||
"@tanstack/eslint-plugin-query": "5.91.2",
|
||||
"@tanstack/react-query-devtools": "5.90.2",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/negotiator": "0.6.4",
|
||||
"@types/node": "24.3.1",
|
||||
"@types/node": "24.10.0",
|
||||
"@types/react": "18.3.17",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/react-modal": "3.16.3",
|
||||
"@types/react-window": "1.8.8",
|
||||
"axe-playwright": "2.1.0",
|
||||
"chromatic": "13.1.4",
|
||||
"axe-playwright": "2.2.2",
|
||||
"chromatic": "13.3.3",
|
||||
"concurrently": "9.2.1",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-config-next": "15.5.2",
|
||||
"eslint-plugin-storybook": "9.1.5",
|
||||
"import-in-the-middle": "1.14.2",
|
||||
"msw": "2.11.1",
|
||||
"msw-storybook-addon": "2.0.5",
|
||||
"orval": "7.11.2",
|
||||
"pbkdf2": "3.1.3",
|
||||
"msw": "2.11.6",
|
||||
"msw-storybook-addon": "2.0.6",
|
||||
"orval": "7.13.0",
|
||||
"pbkdf2": "3.1.5",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-tailwindcss": "0.6.14",
|
||||
"prettier-plugin-tailwindcss": "0.7.1",
|
||||
"require-in-the-middle": "7.5.2",
|
||||
"storybook": "9.1.5",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.9.2"
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.11.1+sha256.211e9990148495c9fc30b7e58396f7eeda83d9243eb75407ea4f8650fb161f7c"
|
||||
"packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd"
|
||||
}
|
||||
|
||||
1797
autogpt_platform/frontend/pnpm-lock.yaml
generated
1797
autogpt_platform/frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
4
autogpt_platform/frontend/pnpm-workspace.yaml
Normal file
4
autogpt_platform/frontend/pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- "@vercel/speed-insights"
|
||||
- esbuild
|
||||
- msw
|
||||
@@ -5,24 +5,23 @@
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
* - Please do NOT serve this file on production.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.7.0'
|
||||
const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f'
|
||||
const PACKAGE_VERSION = '2.11.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
self.addEventListener('install', function () {
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('message', async function (event) {
|
||||
const clientId = event.source.id
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
@@ -72,11 +71,6 @@ self.addEventListener('message', async function (event) {
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_DEACTIVATE': {
|
||||
activeClientIds.delete(clientId)
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
@@ -94,69 +88,92 @@ self.addEventListener('message', async function (event) {
|
||||
}
|
||||
})
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
const { request } = event
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (request.mode === 'navigate') {
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been deleted (still remains active until the next reload).
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate unique request ID.
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId))
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
async function handleRequest(event, requestId) {
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const response = await getResponse(event, client, requestId)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
;(async function () {
|
||||
const responseClone = response.clone()
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
requestId,
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
body: responseClone.body,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
[responseClone.body],
|
||||
)
|
||||
})()
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// Resolve the main client for the given event.
|
||||
// Client that issues a request doesn't necessarily equal the client
|
||||
// that registered the worker. It's with the latter the worker should
|
||||
// communicate with during the response resolving phase.
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
@@ -184,12 +201,17 @@ async function resolveMainClient(event) {
|
||||
})
|
||||
}
|
||||
|
||||
async function getResponse(event, client, requestId) {
|
||||
const { request } = event
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = request.clone()
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
@@ -230,29 +252,18 @@ async function getResponse(event, client, requestId) {
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const requestBuffer = await request.arrayBuffer()
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: requestBuffer,
|
||||
keepalive: request.keepalive,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[requestBuffer],
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
@@ -268,6 +279,12 @@ async function getResponse(event, client, requestId) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
@@ -280,14 +297,18 @@ function sendToClient(client, message, transferrables = []) {
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(
|
||||
message,
|
||||
[channel.port2].concat(transferrables.filter(Boolean)),
|
||||
)
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
async function respondWithMock(response) {
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
@@ -305,3 +326,24 @@ async function respondWithMock(response) {
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useAdminImpersonation } from "./useAdminImpersonation";
|
||||
|
||||
export function AdminImpersonationBanner() {
|
||||
const { isImpersonating, impersonatedUserId, stopImpersonating } =
|
||||
useAdminImpersonation();
|
||||
|
||||
if (!isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-md border border-amber-500 bg-amber-50 p-4 text-amber-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<strong className="font-semibold">
|
||||
⚠️ ADMIN IMPERSONATION ACTIVE
|
||||
</strong>
|
||||
<span>
|
||||
You are currently acting as user:{" "}
|
||||
<code className="rounded bg-amber-100 px-1 font-mono text-sm">
|
||||
{impersonatedUserId}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={stopImpersonating}
|
||||
className="ml-4 flex h-8 items-center rounded-md border border-amber-300 bg-transparent px-3 text-sm hover:bg-amber-100"
|
||||
>
|
||||
Stop Impersonation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserMinus, UserCheck, CreditCard } from "@phosphor-icons/react";
|
||||
import { Card } from "@/components/atoms/Card/Card";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Alert, AlertDescription } from "@/components/molecules/Alert/Alert";
|
||||
import { useAdminImpersonation } from "./useAdminImpersonation";
|
||||
import { useGetV1GetUserCredits } from "@/app/api/__generated__/endpoints/credits/credits";
|
||||
|
||||
export function AdminImpersonationPanel() {
|
||||
const [userIdInput, setUserIdInput] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const {
|
||||
isImpersonating,
|
||||
impersonatedUserId,
|
||||
startImpersonating,
|
||||
stopImpersonating,
|
||||
} = useAdminImpersonation();
|
||||
|
||||
// Demo: Use existing credits API - it will automatically use impersonation if active
|
||||
const {
|
||||
data: creditsResponse,
|
||||
isLoading: creditsLoading,
|
||||
error: creditsError,
|
||||
} = useGetV1GetUserCredits();
|
||||
|
||||
function handleStartImpersonation() {
|
||||
setError("");
|
||||
|
||||
if (!userIdInput.trim()) {
|
||||
setError("Please enter a valid user ID");
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic UUID validation
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(userIdInput.trim())) {
|
||||
setError("Please enter a valid UUID format user ID");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
startImpersonating(userIdInput.trim());
|
||||
setUserIdInput("");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to start impersonation",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStopImpersonation() {
|
||||
stopImpersonating();
|
||||
setError("");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-4">
|
||||
<div className="mb-2 flex items-center space-x-2">
|
||||
<UserCheck className="h-5 w-5" />
|
||||
<h2 className="text-xl font-semibold">Admin User Impersonation</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Act on behalf of another user for debugging and support purposes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Warning */}
|
||||
<Alert variant="error">
|
||||
<AlertDescription>
|
||||
<strong>Security Notice:</strong> This feature is for admin
|
||||
debugging and support only. All impersonation actions are logged for
|
||||
audit purposes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Current Status */}
|
||||
{isImpersonating && (
|
||||
<Alert variant="warning">
|
||||
<AlertDescription>
|
||||
<strong>Currently impersonating:</strong>{" "}
|
||||
<code className="rounded bg-amber-100 px-1 font-mono text-sm">
|
||||
{impersonatedUserId}
|
||||
</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Impersonation Controls */}
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label="User ID to Impersonate"
|
||||
id="user-id-input"
|
||||
placeholder="e.g., 2e7ea138-2097-425d-9cad-c660f29cc251"
|
||||
value={userIdInput}
|
||||
onChange={(e) => setUserIdInput(e.target.value)}
|
||||
disabled={isImpersonating}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={handleStartImpersonation}
|
||||
disabled={isImpersonating || !userIdInput.trim()}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{isImpersonating ? "Active" : "Start"}
|
||||
</Button>
|
||||
|
||||
{isImpersonating && (
|
||||
<Button
|
||||
onClick={handleStopImpersonation}
|
||||
variant="secondary"
|
||||
leftIcon={<UserMinus className="h-4 w-4" />}
|
||||
>
|
||||
Stop Impersonation
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo: Live Credits Display */}
|
||||
<Card className="bg-gray-50">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">Live Demo: User Credits</h3>
|
||||
</div>
|
||||
|
||||
{creditsLoading ? (
|
||||
<p className="text-sm text-gray-600">Loading credits...</p>
|
||||
) : creditsError ? (
|
||||
<Alert variant="error">
|
||||
<AlertDescription className="text-sm">
|
||||
Error loading credits:{" "}
|
||||
{creditsError &&
|
||||
typeof creditsError === "object" &&
|
||||
"message" in creditsError
|
||||
? String(creditsError.message)
|
||||
: "Unknown error"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : creditsResponse?.data ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">
|
||||
<strong>
|
||||
{creditsResponse.data &&
|
||||
typeof creditsResponse.data === "object" &&
|
||||
"credits" in creditsResponse.data
|
||||
? String(creditsResponse.data.credits)
|
||||
: "N/A"}
|
||||
</strong>{" "}
|
||||
credits available
|
||||
{isImpersonating && (
|
||||
<span className="ml-2 text-amber-600">
|
||||
(via impersonation)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{isImpersonating
|
||||
? `Showing credits for user ${impersonatedUserId}`
|
||||
: "Showing your own credits"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">No credits data available</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<p>
|
||||
<strong>Instructions:</strong>
|
||||
</p>
|
||||
<ul className="ml-2 list-inside list-disc space-y-1">
|
||||
<li>Enter the UUID of the user you want to impersonate</li>
|
||||
<li>
|
||||
All existing API endpoints automatically work with impersonation
|
||||
</li>
|
||||
<li>A warning banner will appear while impersonation is active</li>
|
||||
<li>
|
||||
Impersonation persists across page refreshes in this session
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { environment } from "@/services/environment";
|
||||
import { IMPERSONATION_STORAGE_KEY } from "@/lib/constants";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
|
||||
interface AdminImpersonationState {
|
||||
isImpersonating: boolean;
|
||||
impersonatedUserId: string | null;
|
||||
}
|
||||
|
||||
interface AdminImpersonationActions {
|
||||
startImpersonating: (userId: string) => void;
|
||||
stopImpersonating: () => void;
|
||||
}
|
||||
|
||||
type AdminImpersonationHook = AdminImpersonationState &
|
||||
AdminImpersonationActions;
|
||||
|
||||
function getInitialImpersonationState(): string | null {
|
||||
if (!environment.isClientSide()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return sessionStorage.getItem(IMPERSONATION_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error("Failed to read initial impersonation state:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useAdminImpersonation(): AdminImpersonationHook {
|
||||
const [impersonatedUserId, setImpersonatedUserId] = useState<string | null>(
|
||||
getInitialImpersonationState,
|
||||
);
|
||||
const { toast } = useToast();
|
||||
|
||||
const isImpersonating = Boolean(impersonatedUserId);
|
||||
|
||||
const startImpersonating = useCallback(
|
||||
(userId: string) => {
|
||||
if (!userId.trim()) {
|
||||
toast({
|
||||
title: "User ID is required for impersonation",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (environment.isClientSide()) {
|
||||
try {
|
||||
sessionStorage.setItem(IMPERSONATION_STORAGE_KEY, userId);
|
||||
setImpersonatedUserId(userId);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to start impersonation:", error);
|
||||
toast({
|
||||
title: "Failed to start impersonation",
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const stopImpersonating = useCallback(() => {
|
||||
if (environment.isClientSide()) {
|
||||
try {
|
||||
sessionStorage.removeItem(IMPERSONATION_STORAGE_KEY);
|
||||
setImpersonatedUserId(null);
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Failed to stop impersonation:", error);
|
||||
toast({
|
||||
title: "Failed to stop impersonation",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
return {
|
||||
isImpersonating,
|
||||
impersonatedUserId,
|
||||
startImpersonating,
|
||||
stopImpersonating,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AdminImpersonationPanel } from "../components/AdminImpersonationPanel";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
|
||||
export default function AdminImpersonationPage() {
|
||||
return (
|
||||
<div className="container mx-auto space-y-6 py-6">
|
||||
<div className="space-y-2">
|
||||
<Text variant="h1" className="text-3xl font-bold tracking-tight">
|
||||
User Impersonation
|
||||
</Text>
|
||||
<Text variant="body" className="text-gray-600">
|
||||
Manage admin user impersonation for debugging and support purposes
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<AdminImpersonationPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Sidebar } from "@/components/__legacy__/Sidebar";
|
||||
import { Users, DollarSign } from "lucide-react";
|
||||
import { Users, DollarSign, UserSearch } from "lucide-react";
|
||||
|
||||
import { IconSliders } from "@/components/__legacy__/ui/icons";
|
||||
|
||||
@@ -16,6 +16,11 @@ const sidebarLinkGroups = [
|
||||
href: "/admin/spending",
|
||||
icon: <DollarSign className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "User Impersonation",
|
||||
href: "/admin/impersonation",
|
||||
icon: <UserSearch className="h-6 w-6" />,
|
||||
},
|
||||
{
|
||||
text: "Admin User Management",
|
||||
href: "/admin/settings",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AgentOutputs } from "./components/AgentOutputs/AgentOutputs";
|
||||
import { RunGraph } from "./components/RunGraph/RunGraph";
|
||||
import { ScheduleGraph } from "./components/ScheduleGraph/ScheduleGraph";
|
||||
|
||||
export const BuilderActions = () => {
|
||||
return (
|
||||
<div className="absolute bottom-4 left-[50%] z-[100] -translate-x-1/2">
|
||||
{/* TODO: Add Agent Output */}
|
||||
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-2 gap-4">
|
||||
<AgentOutputs />
|
||||
<RunGraph />
|
||||
{/* TODO: Add Schedule run button */}
|
||||
<ScheduleGraph />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { LogOutIcon } from "lucide-react";
|
||||
|
||||
export const AgentOutputs = () => {
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Todo: Implement Agent Outputs */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={"relative min-w-0 border-none text-lg"}
|
||||
>
|
||||
<LogOutIcon className="size-6" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Agent Outputs</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Dialog } from "@/components/molecules/Dialog/Dialog";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { CronScheduler } from "@/app/(platform)/library/agents/[id]/components/AgentRunsView/components/ScheduleAgentModal/components/CronScheduler/CronScheduler";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useCronSchedulerDialog } from "./useCronSchedulerDialog";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
|
||||
type CronSchedulerDialogProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
inputs: Record<string, any>;
|
||||
credentials: Record<string, any>;
|
||||
defaultCronExpression?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function CronSchedulerDialog({
|
||||
open,
|
||||
setOpen,
|
||||
|
||||
defaultCronExpression = "",
|
||||
title = "Schedule Graph",
|
||||
inputs,
|
||||
credentials,
|
||||
}: CronSchedulerDialogProps) {
|
||||
const {
|
||||
setCronExpression,
|
||||
userTimezone,
|
||||
timezoneDisplay,
|
||||
handleCreateSchedule,
|
||||
scheduleName,
|
||||
setScheduleName,
|
||||
isCreatingSchedule,
|
||||
} = useCronSchedulerDialog({
|
||||
open,
|
||||
setOpen,
|
||||
inputs,
|
||||
credentials,
|
||||
defaultCronExpression,
|
||||
});
|
||||
return (
|
||||
<Dialog
|
||||
controlled={{ isOpen: open, set: setOpen }}
|
||||
title={title}
|
||||
styling={{ maxWidth: "600px", minWidth: "600px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
id="schedule-name"
|
||||
label="Schedule Name"
|
||||
placeholder="Enter schedule name"
|
||||
size="small"
|
||||
className="max-w-80"
|
||||
value={scheduleName}
|
||||
onChange={(e) => setScheduleName(e.target.value)}
|
||||
/>
|
||||
|
||||
<CronScheduler
|
||||
onCronExpressionChange={setCronExpression}
|
||||
initialCronExpression={defaultCronExpression}
|
||||
key={`${open}-${defaultCronExpression}`}
|
||||
/>
|
||||
|
||||
{/* Timezone info */}
|
||||
{userTimezone === "not-set" ? (
|
||||
<div className="flex items-center gap-2 rounded-xlarge border border-amber-200 bg-amber-50 p-3">
|
||||
<InfoIcon className="h-4 w-4 text-amber-600" />
|
||||
<Text variant="body" className="text-amber-800">
|
||||
No timezone set. Schedule will run in UTC.
|
||||
<a href="/profile/settings" className="ml-1 underline">
|
||||
Set your timezone
|
||||
</a>
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-xlarge bg-muted/50 p-3">
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
<Text variant="body">
|
||||
Schedule will run in your timezone:{" "}
|
||||
<Text variant="body-medium" as="span">
|
||||
{timezoneDisplay}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8 flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="h-fit"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={isCreatingSchedule}
|
||||
disabled={isCreatingSchedule}
|
||||
onClick={handleCreateSchedule}
|
||||
className="h-fit"
|
||||
>
|
||||
{isCreatingSchedule ? "Creating schedule..." : "Done"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useGetV1GetUserTimezone } from "@/app/api/__generated__/endpoints/auth/auth";
|
||||
import { usePostV1CreateExecutionSchedule } from "@/app/api/__generated__/endpoints/schedules/schedules";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { getTimezoneDisplayName } from "@/lib/timezone-utils";
|
||||
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const useCronSchedulerDialog = ({
|
||||
open,
|
||||
setOpen,
|
||||
inputs,
|
||||
credentials,
|
||||
defaultCronExpression = "",
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
inputs: Record<string, any>;
|
||||
credentials: Record<string, any>;
|
||||
defaultCronExpression?: string;
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [cronExpression, setCronExpression] = useState<string>("");
|
||||
const [scheduleName, setScheduleName] = useState<string>("");
|
||||
|
||||
const [{ flowID, flowVersion }] = useQueryStates({
|
||||
flowID: parseAsString,
|
||||
flowVersion: parseAsInteger,
|
||||
flowExecutionID: parseAsString,
|
||||
});
|
||||
|
||||
const { data: userTimezone } = useGetV1GetUserTimezone({
|
||||
query: {
|
||||
select: (res) => (res.status === 200 ? res.data.timezone : undefined),
|
||||
},
|
||||
});
|
||||
const timezoneDisplay = getTimezoneDisplayName(userTimezone || "UTC");
|
||||
|
||||
const { mutateAsync: createSchedule, isPending: isCreatingSchedule } =
|
||||
usePostV1CreateExecutionSchedule({
|
||||
mutation: {
|
||||
onSuccess: (response) => {
|
||||
if (response.status === 200) {
|
||||
setOpen(false);
|
||||
toast({
|
||||
title: "Schedule created",
|
||||
description: "Schedule created successfully",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create schedule",
|
||||
description:
|
||||
(error.detail as string) ?? "An unexpected error occurred.",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCronExpression(defaultCronExpression);
|
||||
}
|
||||
}, [open, defaultCronExpression]);
|
||||
|
||||
const handleCreateSchedule = async () => {
|
||||
if (!cronExpression || cronExpression.trim() === "") {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid schedule",
|
||||
description: "Please enter a valid cron expression",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createSchedule({
|
||||
graphId: flowID || "",
|
||||
data: {
|
||||
name: scheduleName,
|
||||
graph_version: flowID ? flowVersion : undefined,
|
||||
cron: cronExpression,
|
||||
inputs: inputs,
|
||||
credentials: credentials,
|
||||
},
|
||||
});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return {
|
||||
cronExpression,
|
||||
setCronExpression,
|
||||
userTimezone,
|
||||
timezoneDisplay,
|
||||
handleCreateSchedule,
|
||||
setScheduleName,
|
||||
scheduleName,
|
||||
isCreatingSchedule,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,11 @@ import { useShallow } from "zustand/react/shallow";
|
||||
import { StopIcon } from "@phosphor-icons/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
|
||||
export const RunGraph = () => {
|
||||
const {
|
||||
@@ -21,24 +26,31 @@ export const RunGraph = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={cn(
|
||||
"relative min-w-44 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
|
||||
)}
|
||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||
>
|
||||
{!isGraphRunning && !isSaving ? (
|
||||
<PlayIcon className="mr-1 size-5" />
|
||||
) : (
|
||||
<StopIcon className="mr-1 size-5" />
|
||||
)}
|
||||
{isGraphRunning || isSaving ? "Stop Agent" : "Run Agent"}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={cn(
|
||||
"relative min-w-0 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
|
||||
)}
|
||||
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
|
||||
>
|
||||
{!isGraphRunning && !isSaving ? (
|
||||
<PlayIcon className="size-6" />
|
||||
) : (
|
||||
<StopIcon className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isGraphRunning ? "Stop agent" : "Run agent"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<RunInputDialog
|
||||
isOpen={openRunInputDialog}
|
||||
setIsOpen={setOpenRunInputDialog}
|
||||
purpose="run"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,17 +3,20 @@ import { RJSFSchema } from "@rjsf/utils";
|
||||
import { uiSchema } from "../../../FlowEditor/nodes/uiSchema";
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { PlayIcon } from "@phosphor-icons/react";
|
||||
import { ClockIcon, PlayIcon } from "@phosphor-icons/react";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { FormRenderer } from "@/components/renderers/input-renderer/FormRenderer";
|
||||
import { useRunInputDialog } from "./useRunInputDialog";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
|
||||
export const RunInputDialog = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
purpose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
purpose: "run" | "schedule";
|
||||
}) => {
|
||||
const hasInputs = useGraphStore((state) => state.hasInputs);
|
||||
const hasCredentials = useGraphStore((state) => state.hasCredentials);
|
||||
@@ -26,82 +29,107 @@ export const RunInputDialog = ({
|
||||
credentialsUiSchema,
|
||||
handleManualRun,
|
||||
handleInputChange,
|
||||
openCronSchedulerDialog,
|
||||
setOpenCronSchedulerDialog,
|
||||
inputValues,
|
||||
credentialValues,
|
||||
handleCredentialChange,
|
||||
isExecutingGraph,
|
||||
} = useRunInputDialog({ setIsOpen });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title="Run Agent"
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
styling={{ maxWidth: "700px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="space-y-6 p-1">
|
||||
{/* Credentials Section */}
|
||||
{hasCredentials() && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Credentials
|
||||
</Text>
|
||||
<>
|
||||
<Dialog
|
||||
title={purpose === "run" ? "Run Agent" : "Schedule Run"}
|
||||
controlled={{
|
||||
isOpen,
|
||||
set: setIsOpen,
|
||||
}}
|
||||
styling={{ maxWidth: "600px", minWidth: "600px" }}
|
||||
>
|
||||
<Dialog.Content>
|
||||
<div className="space-y-6 p-1">
|
||||
{/* Credentials Section */}
|
||||
{hasCredentials() && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Credentials
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<FormRenderer
|
||||
jsonSchema={credentialsSchema as RJSFSchema}
|
||||
handleChange={(v) => handleCredentialChange(v.formData)}
|
||||
uiSchema={credentialsUiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<FormRenderer
|
||||
jsonSchema={credentialsSchema as RJSFSchema}
|
||||
handleChange={(v) => handleCredentialChange(v.formData)}
|
||||
uiSchema={credentialsUiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Inputs Section */}
|
||||
{hasInputs() && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Inputs
|
||||
</Text>
|
||||
{/* Inputs Section */}
|
||||
{hasInputs() && (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Text variant="h4" className="text-gray-900">
|
||||
Inputs
|
||||
</Text>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2">
|
||||
<FormRenderer
|
||||
jsonSchema={inputSchema as RJSFSchema}
|
||||
handleChange={(v) => handleInputChange(v.formData)}
|
||||
uiSchema={uiSchema}
|
||||
initialValues={{}}
|
||||
formContext={{
|
||||
showHandles: false,
|
||||
size: "large",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="group h-fit min-w-0 gap-2 border-none bg-gradient-to-r from-blue-600 to-purple-600 px-8 transition-all"
|
||||
onClick={handleManualRun}
|
||||
loading={isExecutingGraph}
|
||||
>
|
||||
<PlayIcon className="size-5 transition-transform group-hover:scale-110" />
|
||||
<span className="font-semibold">Manual Run</span>
|
||||
</Button>
|
||||
{/* Action Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
{purpose === "run" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="group h-fit min-w-0 gap-2"
|
||||
onClick={handleManualRun}
|
||||
loading={isExecutingGraph}
|
||||
>
|
||||
<PlayIcon className="size-5 transition-transform group-hover:scale-110" />
|
||||
<span className="font-semibold">Manual Run</span>
|
||||
</Button>
|
||||
)}
|
||||
{purpose === "schedule" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className="group h-fit min-w-0 gap-2"
|
||||
onClick={() => setOpenCronSchedulerDialog(true)}
|
||||
>
|
||||
<ClockIcon className="size-5 transition-transform group-hover:scale-110" />
|
||||
<span className="font-semibold">Schedule Run</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
</Dialog.Content>
|
||||
</Dialog>
|
||||
<CronSchedulerDialog
|
||||
open={openCronSchedulerDialog}
|
||||
setOpen={setOpenCronSchedulerDialog}
|
||||
inputs={inputValues}
|
||||
credentials={credentialValues}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ export const useRunInputDialog = ({
|
||||
const credentialsSchema = useGraphStore(
|
||||
(state) => state.credentialsInputSchema,
|
||||
);
|
||||
|
||||
const [openCronSchedulerDialog, setOpenCronSchedulerDialog] = useState(false);
|
||||
const [inputValues, setInputValues] = useState<Record<string, any>>({});
|
||||
const [credentialValues, setCredentialValues] = useState<
|
||||
Record<string, CredentialsMetaInput>
|
||||
@@ -104,5 +106,7 @@ export const useRunInputDialog = ({
|
||||
handleInputChange,
|
||||
handleCredentialChange,
|
||||
handleManualRun,
|
||||
openCronSchedulerDialog,
|
||||
setOpenCronSchedulerDialog,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
|
||||
import { useScheduleGraph } from "./useScheduleGraph";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
|
||||
|
||||
export const ScheduleGraph = () => {
|
||||
const {
|
||||
openScheduleInputDialog,
|
||||
setOpenScheduleInputDialog,
|
||||
handleScheduleGraph,
|
||||
openCronSchedulerDialog,
|
||||
setOpenCronSchedulerDialog,
|
||||
} = useScheduleGraph();
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
className={"relative min-w-0 border-none text-lg"}
|
||||
onClick={handleScheduleGraph}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Schedule Graph</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<RunInputDialog
|
||||
isOpen={openScheduleInputDialog}
|
||||
setIsOpen={setOpenScheduleInputDialog}
|
||||
purpose="schedule"
|
||||
/>
|
||||
<CronSchedulerDialog
|
||||
open={openCronSchedulerDialog}
|
||||
setOpen={setOpenCronSchedulerDialog}
|
||||
inputs={{}}
|
||||
credentials={{}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useNewSaveControl } from "../../../NewControlPanel/NewSaveControl/useNewSaveControl";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useScheduleGraph = () => {
|
||||
const { onSubmit: onSaveGraph } = useNewSaveControl({
|
||||
showToast: false,
|
||||
});
|
||||
const hasInputs = useGraphStore(useShallow((state) => state.hasInputs));
|
||||
const hasCredentials = useGraphStore(
|
||||
useShallow((state) => state.hasCredentials),
|
||||
);
|
||||
const [openScheduleInputDialog, setOpenScheduleInputDialog] = useState(false);
|
||||
const [openCronSchedulerDialog, setOpenCronSchedulerDialog] = useState(false);
|
||||
|
||||
const handleScheduleGraph = async () => {
|
||||
await onSaveGraph(undefined);
|
||||
if (hasInputs() || hasCredentials()) {
|
||||
setOpenScheduleInputDialog(true);
|
||||
} else {
|
||||
setOpenCronSchedulerDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
openScheduleInputDialog,
|
||||
setOpenScheduleInputDialog,
|
||||
handleScheduleGraph,
|
||||
openCronSchedulerDialog,
|
||||
setOpenCronSchedulerDialog,
|
||||
};
|
||||
};
|
||||
@@ -23,6 +23,8 @@ export const Flow = () => {
|
||||
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
useFlow();
|
||||
|
||||
// This hook is used for websocket realtime updates.
|
||||
useFlowRealtime();
|
||||
|
||||
const { isFlowContentLoading } = useFlow();
|
||||
|
||||
@@ -1,35 +1,38 @@
|
||||
// BLOCK MENU TODO: We need a disable state in this, currently it's not in design.
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLDivElement> {
|
||||
interface Props extends React.HTMLAttributes<HTMLElement> {
|
||||
selected?: boolean;
|
||||
children?: React.ReactNode; // For icon purpose
|
||||
children?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
as?: "div" | "button";
|
||||
}
|
||||
|
||||
export const ControlPanelButton: React.FC<Props> = ({
|
||||
selected = false,
|
||||
children,
|
||||
disabled,
|
||||
as = "div",
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
const Component = as;
|
||||
|
||||
return (
|
||||
// Using div instead of button, because it's only for design purposes. We are using this to give design to PopoverTrigger.
|
||||
<div
|
||||
role="button"
|
||||
// Why div - because on some places we are only using this for design purposes.
|
||||
<Component
|
||||
role={as === "div" ? "button" : undefined}
|
||||
disabled={as === "button" ? disabled : undefined}
|
||||
className={cn(
|
||||
"flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
|
||||
selected &&
|
||||
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
|
||||
disabled && "cursor-not-allowed",
|
||||
disabled && "cursor-not-allowed opacity-50 hover:cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,23 +8,32 @@ import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
|
||||
import { ControlPanelButton } from "../../ControlPanelButton";
|
||||
import { LegoIcon } from "@phosphor-icons/react";
|
||||
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
|
||||
export const BlockMenu = () => {
|
||||
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
|
||||
return (
|
||||
// pinBlocksPopover ? true : open
|
||||
<Popover onOpenChange={setBlockMenuOpen}>
|
||||
<PopoverTrigger className="hover:cursor-pointer">
|
||||
<ControlPanelButton
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
selected={blockMenuOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
{/* Need to find phosphor icon alternative for this lucide icon */}
|
||||
<LegoIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger className="hover:cursor-pointer">
|
||||
<ControlPanelButton
|
||||
data-id="blocks-control-popover-trigger"
|
||||
data-testid="blocks-control-blocks-button"
|
||||
selected={blockMenuOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
<LegoIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Blocks</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<PopoverContent
|
||||
side="right"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
|
||||
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { UndoRedoButtons } from "./UndoRedoButtons";
|
||||
|
||||
export type Control = {
|
||||
icon: React.ReactNode;
|
||||
@@ -106,6 +107,8 @@ export const NewControlPanel = ({
|
||||
))} */}
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<NewSaveControl />
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<UndoRedoButtons />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export const NewSaveControl = () => {
|
||||
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
|
||||
return (
|
||||
<Popover onOpenChange={setSaveControlOpen}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<ControlPanelButton
|
||||
@@ -34,7 +34,6 @@ export const NewSaveControl = () => {
|
||||
selected={saveControlOpen}
|
||||
className="rounded-none"
|
||||
>
|
||||
{/* Need to find phosphor icon alternative for this lucide icon */}
|
||||
<FloppyDiskIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { ControlPanelButton } from "./ControlPanelButton";
|
||||
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/atoms/Tooltip/BaseTooltip";
|
||||
import { useHistoryStore } from "../../stores/historyStore";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const UndoRedoButtons = () => {
|
||||
const { undo, redo, canUndo, canRedo } = useHistoryStore();
|
||||
|
||||
// Keyboard shortcuts for undo and redo
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isMac = /Mac/i.test(navigator.userAgent);
|
||||
const isCtrlOrCmd = isMac ? event.metaKey : event.ctrlKey;
|
||||
|
||||
if (isCtrlOrCmd && event.key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (canUndo()) {
|
||||
undo();
|
||||
}
|
||||
} else if (isCtrlOrCmd && event.key === "y") {
|
||||
event.preventDefault();
|
||||
if (canRedo()) {
|
||||
redo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [undo, redo, canUndo, canRedo]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canUndo()} onClick={undo}>
|
||||
<ArrowUUpLeftIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Undo</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator className="text-[#E1E1E1]" />
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<ControlPanelButton as="button" disabled={!canRedo()} onClick={redo}>
|
||||
<ArrowUUpRightIcon className="h-6 w-6" />
|
||||
</ControlPanelButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">Redo</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from "zustand";
|
||||
import isEqual from "lodash/isEqual";
|
||||
|
||||
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
|
||||
import { Connection, useEdgeStore } from "./edgeStore";
|
||||
import { useNodeStore } from "./nodeStore";
|
||||
|
||||
type HistoryState = {
|
||||
nodes: CustomNode[];
|
||||
connections: Connection[];
|
||||
};
|
||||
|
||||
type HistoryStore = {
|
||||
past: HistoryState[];
|
||||
future: HistoryState[];
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
pushState: (state: HistoryState) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
export const useHistoryStore = create<HistoryStore>((set, get) => ({
|
||||
past: [{ nodes: [], connections: [] }],
|
||||
future: [],
|
||||
|
||||
pushState: (state: HistoryState) => {
|
||||
const { past } = get();
|
||||
const lastState = past[past.length - 1];
|
||||
|
||||
if (lastState && isEqual(lastState, state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((prev) => ({
|
||||
past: [...prev.past.slice(-MAX_HISTORY + 1), state],
|
||||
future: [],
|
||||
}));
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { past, future } = get();
|
||||
if (past.length <= 1) return;
|
||||
|
||||
const currentState = past[past.length - 1];
|
||||
|
||||
const previousState = past[past.length - 2];
|
||||
|
||||
useNodeStore.getState().setNodes(previousState.nodes);
|
||||
useEdgeStore.getState().setConnections(previousState.connections);
|
||||
|
||||
set({
|
||||
past: past.slice(0, -1),
|
||||
future: [currentState, ...future],
|
||||
});
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { past, future } = get();
|
||||
if (future.length === 0) return;
|
||||
|
||||
const nextState = future[0];
|
||||
|
||||
useNodeStore.getState().setNodes(nextState.nodes);
|
||||
useEdgeStore.getState().setConnections(nextState.connections);
|
||||
|
||||
set({
|
||||
past: [...past, nextState],
|
||||
future: future.slice(1),
|
||||
});
|
||||
},
|
||||
|
||||
canUndo: () => get().past.length > 1,
|
||||
canRedo: () => get().future.length > 0,
|
||||
|
||||
clear: () => set({ past: [{ nodes: [], connections: [] }], future: [] }),
|
||||
}));
|
||||
@@ -6,6 +6,8 @@ import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
|
||||
import { Node } from "@/app/api/__generated__/models/node";
|
||||
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
|
||||
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
|
||||
import { useHistoryStore } from "./historyStore";
|
||||
import { useEdgeStore } from "./edgeStore";
|
||||
|
||||
type NodeStore = {
|
||||
nodes: CustomNode[];
|
||||
@@ -44,10 +46,26 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
set((state) => ({
|
||||
nodeCounter: state.nodeCounter + 1,
|
||||
})),
|
||||
onNodesChange: (changes) =>
|
||||
onNodesChange: (changes) => {
|
||||
const prevState = {
|
||||
nodes: get().nodes,
|
||||
connections: useEdgeStore.getState().connections,
|
||||
};
|
||||
const shouldTrack = changes.some(
|
||||
(change) =>
|
||||
change.type === "remove" ||
|
||||
change.type === "add" ||
|
||||
(change.type === "position" && change.dragging === false),
|
||||
);
|
||||
set((state) => ({
|
||||
nodes: applyNodeChanges(changes, state.nodes),
|
||||
})),
|
||||
}));
|
||||
|
||||
if (shouldTrack) {
|
||||
useHistoryStore.getState().pushState(prevState);
|
||||
}
|
||||
},
|
||||
|
||||
addNode: (node) =>
|
||||
set((state) => ({
|
||||
nodes: [...state.nodes, node],
|
||||
@@ -66,12 +84,20 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
nodes: [...state.nodes, customNode],
|
||||
}));
|
||||
},
|
||||
updateNodeData: (nodeId, data) =>
|
||||
updateNodeData: (nodeId, data) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
|
||||
),
|
||||
})),
|
||||
}));
|
||||
|
||||
const newState = {
|
||||
nodes: get().nodes,
|
||||
connections: useEdgeStore.getState().connections,
|
||||
};
|
||||
|
||||
useHistoryStore.getState().pushState(newState);
|
||||
},
|
||||
toggleAdvanced: (nodeId: string) =>
|
||||
set((state) => ({
|
||||
nodeAdvancedStates: {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Navbar } from "@/components/layout/Navbar/Navbar";
|
||||
import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function PlatformLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<main className="flex h-screen w-full flex-col">
|
||||
<Navbar />
|
||||
<AdminImpersonationBanner />
|
||||
<section className="flex-1">{children}</section>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { useMainAgentPage } from "./useMainAgentPage";
|
||||
import { MarketplaceAgentPageParams } from "../../agent/[creator]/[slug]/page";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { MarketplaceAgentPageParams } from "../../agent/[creator]/[slug]/page";
|
||||
import { AgentImages } from "../AgentImages/AgentImage";
|
||||
import { AgentInfo } from "../AgentInfo/AgentInfo";
|
||||
import { AgentPageLoading } from "../AgentPageLoading";
|
||||
import { AgentsSection } from "../AgentsSection/AgentsSection";
|
||||
import { BecomeACreator } from "../BecomeACreator/BecomeACreator";
|
||||
import { AgentPageLoading } from "../AgentPageLoading";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { AgentInfo } from "../AgentInfo/AgentInfo";
|
||||
import { AgentImages } from "../AgentImages/AgentImage";
|
||||
import { useMainAgentPage } from "./useMainAgentPage";
|
||||
|
||||
type MainAgentPageProps = {
|
||||
params: MarketplaceAgentPageParams;
|
||||
@@ -65,7 +65,7 @@ export const MainAgentPage = ({ params }: MainAgentPageProps) => {
|
||||
}
|
||||
|
||||
const breadcrumbs = [
|
||||
{ name: "Markertplace", link: "/marketplace" },
|
||||
{ name: "Marketplace", link: "/marketplace" },
|
||||
{
|
||||
name: agent.creator,
|
||||
link: `/marketplace/creator/${encodeURIComponent(agent.creator)}`,
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
|
||||
import { transformDates } from "./date-transformer";
|
||||
import { environment } from "@/services/environment";
|
||||
import {
|
||||
IMPERSONATION_HEADER_NAME,
|
||||
IMPERSONATION_STORAGE_KEY,
|
||||
} from "@/lib/constants";
|
||||
|
||||
const FRONTEND_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_FRONTEND_BASE_URL || "http://localhost:3000";
|
||||
@@ -53,6 +57,22 @@ export const customMutator = async <
|
||||
...((requestOptions.headers as Record<string, string>) || {}),
|
||||
};
|
||||
|
||||
if (environment.isClientSide()) {
|
||||
try {
|
||||
const impersonatedUserId = sessionStorage.getItem(
|
||||
IMPERSONATION_STORAGE_KEY,
|
||||
);
|
||||
if (impersonatedUserId) {
|
||||
headers[IMPERSONATION_HEADER_NAME] = impersonatedUserId;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Admin impersonation: Failed to access sessionStorage:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isFormData = data instanceof FormData;
|
||||
const contentType = isFormData ? "multipart/form-data" : "application/json";
|
||||
|
||||
@@ -94,22 +114,38 @@ export const customMutator = async <
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const response_data = await getBody<any>(response);
|
||||
let responseData: any = null;
|
||||
try {
|
||||
responseData = await getBody<any>(response);
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse error response body:", error);
|
||||
responseData = { error: "Failed to parse response" };
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
response_data?.detail || response_data?.message || response.statusText;
|
||||
responseData?.detail ||
|
||||
responseData?.message ||
|
||||
response.statusText ||
|
||||
`HTTP ${response.status}`;
|
||||
|
||||
console.error(
|
||||
`Request failed ${environment.isServerSide() ? "on server" : "on client"}`,
|
||||
{ status: response.status, url: fullUrl, data: response_data },
|
||||
{
|
||||
status: response.status,
|
||||
method,
|
||||
url: fullUrl.replace(baseUrl, ""), // Show relative URL for cleaner logs
|
||||
errorMessage,
|
||||
responseData: responseData || "No response data",
|
||||
},
|
||||
);
|
||||
|
||||
throw new ApiError(errorMessage, response.status, response_data);
|
||||
throw new ApiError(errorMessage, response.status, responseData);
|
||||
}
|
||||
|
||||
const response_data = await getBody<T["data"]>(response);
|
||||
const responseData = await getBody<T["data"]>(response);
|
||||
|
||||
// Transform ISO date strings to Date objects in the response data
|
||||
const transformedData = transformDates(response_data);
|
||||
const transformedData = transformDates(responseData);
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
|
||||
@@ -31,6 +31,7 @@ async function handleJsonRequest(
|
||||
backendUrl,
|
||||
payload,
|
||||
"application/json",
|
||||
req,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +40,7 @@ async function handleFormDataRequest(
|
||||
backendUrl: string,
|
||||
): Promise<any> {
|
||||
const formData = await req.formData();
|
||||
return await makeAuthenticatedFileUpload(backendUrl, formData);
|
||||
return await makeAuthenticatedFileUpload(backendUrl, formData, req);
|
||||
}
|
||||
|
||||
async function handleUrlEncodedRequest(
|
||||
@@ -55,14 +56,22 @@ async function handleUrlEncodedRequest(
|
||||
backendUrl,
|
||||
payload,
|
||||
"application/x-www-form-urlencoded",
|
||||
req,
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRequestWithoutBody(
|
||||
async function handleGetDeleteRequest(
|
||||
method: string,
|
||||
backendUrl: string,
|
||||
req: NextRequest,
|
||||
): Promise<any> {
|
||||
return await makeAuthenticatedRequest(method, backendUrl);
|
||||
return await makeAuthenticatedRequest(
|
||||
method,
|
||||
backendUrl,
|
||||
undefined,
|
||||
"application/json",
|
||||
req,
|
||||
);
|
||||
}
|
||||
|
||||
function createUnsupportedContentTypeResponse(
|
||||
@@ -168,7 +177,7 @@ async function handler(
|
||||
|
||||
try {
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
responseBody = await handleRequestWithoutBody(method, backendUrl);
|
||||
responseBody = await handleGetDeleteRequest(method, backendUrl, req);
|
||||
} else if (contentType?.includes("application/json")) {
|
||||
responseBody = await handleJsonRequest(req, method, backendUrl);
|
||||
} else if (contentType?.includes("multipart/form-data")) {
|
||||
|
||||
@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,6 +3,10 @@ import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { createBrowserClient } from "@supabase/ssr";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import {
|
||||
IMPERSONATION_HEADER_NAME,
|
||||
IMPERSONATION_STORAGE_KEY,
|
||||
} from "@/lib/constants";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type {
|
||||
AddUserCreditsResponse,
|
||||
@@ -1013,11 +1017,30 @@ export default class BackendAPI {
|
||||
url = buildUrlWithQuery(url, payload);
|
||||
}
|
||||
|
||||
// Prepare headers with admin impersonation support
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (environment.isClientSide()) {
|
||||
try {
|
||||
const impersonatedUserId = sessionStorage.getItem(
|
||||
IMPERSONATION_STORAGE_KEY,
|
||||
);
|
||||
if (impersonatedUserId) {
|
||||
headers[IMPERSONATION_HEADER_NAME] = impersonatedUserId;
|
||||
}
|
||||
} catch (_error) {
|
||||
console.error(
|
||||
"Admin impersonation: Failed to access sessionStorage:",
|
||||
_error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers,
|
||||
body: !payloadAsQuery && payload ? JSON.stringify(payload) : undefined,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { environment } from "@/services/environment";
|
||||
import { IMPERSONATION_HEADER_NAME } from "@/lib/constants";
|
||||
|
||||
import { GraphValidationErrorResponse } from "./types";
|
||||
|
||||
@@ -133,6 +134,7 @@ export function createRequestHeaders(
|
||||
token: string,
|
||||
hasRequestBody: boolean,
|
||||
contentType: string = "application/json",
|
||||
originalRequest?: Request,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
@@ -144,6 +146,16 @@ export function createRequestHeaders(
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Forward admin impersonation header if present
|
||||
if (originalRequest) {
|
||||
const impersonationHeader = originalRequest.headers.get(
|
||||
IMPERSONATION_HEADER_NAME,
|
||||
);
|
||||
if (impersonationHeader) {
|
||||
headers[IMPERSONATION_HEADER_NAME] = impersonationHeader;
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -249,6 +261,7 @@ export async function makeAuthenticatedRequest(
|
||||
url: string,
|
||||
payload?: Record<string, any>,
|
||||
contentType: string = "application/json",
|
||||
originalRequest?: Request,
|
||||
): Promise<any> {
|
||||
const token = await getServerAuthToken();
|
||||
const payloadAsQuery = ["GET", "DELETE"].includes(method);
|
||||
@@ -262,7 +275,12 @@ export async function makeAuthenticatedRequest(
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
method,
|
||||
headers: createRequestHeaders(token, hasRequestBody, contentType),
|
||||
headers: createRequestHeaders(
|
||||
token,
|
||||
hasRequestBody,
|
||||
contentType,
|
||||
originalRequest,
|
||||
),
|
||||
body: hasRequestBody
|
||||
? serializeRequestBody(payload, contentType)
|
||||
: undefined,
|
||||
@@ -300,13 +318,17 @@ export async function makeAuthenticatedRequest(
|
||||
export async function makeAuthenticatedFileUpload(
|
||||
url: string,
|
||||
formData: FormData,
|
||||
originalRequest?: Request,
|
||||
): Promise<string> {
|
||||
const token = await getServerAuthToken();
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token && token !== "no-token-found") {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
// Reuse existing header creation logic but exclude Content-Type for FormData
|
||||
const headers = createRequestHeaders(
|
||||
token,
|
||||
false,
|
||||
"application/json",
|
||||
originalRequest,
|
||||
);
|
||||
|
||||
// Don't set Content-Type for FormData - let the browser set it with boundary
|
||||
const response = await fetch(url, {
|
||||
|
||||
7
autogpt_platform/frontend/src/lib/constants.ts
Normal file
7
autogpt_platform/frontend/src/lib/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Shared constants for the frontend application
|
||||
*/
|
||||
|
||||
// Admin impersonation
|
||||
export const IMPERSONATION_HEADER_NAME = "X-Act-As-User-Id";
|
||||
export const IMPERSONATION_STORAGE_KEY = "admin-impersonate-user-id";
|
||||
Reference in New Issue
Block a user