Merge branch 'dev' into update-exa

This commit is contained in:
Nicholas Tindle
2025-11-04 21:27:26 -06:00
committed by GitHub
39 changed files with 2638 additions and 957 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -1,2 +1,3 @@
# Configure pnpm to save exact versions
save-exact=true
save-exact=true
engine-strict=true

View File

@@ -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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- "@vercel/speed-insights"
- esbuild
- msw

View File

@@ -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,
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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>
);
}

View File

@@ -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",

View File

@@ -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>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
}

View File

@@ -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,
};
};

View File

@@ -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"
/>
</>
);

View File

@@ -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}
/>
</>
);
};

View File

@@ -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,
};
};

View File

@@ -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={{}}
/>
</>
);
};

View File

@@ -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,
};
};

View File

@@ -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();

View File

@@ -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>
);
};

View File

@@ -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"

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
</>
);
};

View File

@@ -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: [] }),
}));

View File

@@ -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: {

View File

@@ -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>
);

View File

@@ -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)}`,

View File

@@ -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,

View File

@@ -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")) {

View File

@@ -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}

View File

@@ -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",
});

View File

@@ -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, {

View 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";