mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
fix: fix superadmin impersonation
This commit is contained in:
parent
d1339970a5
commit
e405457676
16 changed files with 593 additions and 172 deletions
|
|
@ -8,7 +8,11 @@ from pydantic import BaseModel
|
||||||
from api.db import db_client
|
from api.db import db_client
|
||||||
from api.db.models import UserModel
|
from api.db.models import UserModel
|
||||||
from api.services.auth.depends import get_superuser
|
from api.services.auth.depends import get_superuser
|
||||||
from api.services.auth.stack_auth import stackauth
|
from api.services.auth.stack_auth import (
|
||||||
|
StackAuthSessionError,
|
||||||
|
StackAuthUserSearchError,
|
||||||
|
stackauth,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/superuser", tags=["superuser"])
|
router = APIRouter(prefix="/superuser", tags=["superuser"])
|
||||||
|
|
||||||
|
|
@ -16,12 +20,14 @@ router = APIRouter(prefix="/superuser", tags=["superuser"])
|
||||||
class ImpersonateRequest(BaseModel):
|
class ImpersonateRequest(BaseModel):
|
||||||
"""Request payload for superadmin impersonation.
|
"""Request payload for superadmin impersonation.
|
||||||
|
|
||||||
Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
|
``provider_user_id``, ``user_id``, or ``email`` may be supplied. If more
|
||||||
provided, ``provider_user_id`` takes precedence.
|
than one is provided, ``provider_user_id`` takes precedence, followed by
|
||||||
|
``user_id`` and then ``email``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider_user_id: str | None = None
|
provider_user_id: str | None = None
|
||||||
user_id: int | None = None
|
user_id: int | None = None
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ImpersonateResponse(BaseModel):
|
class ImpersonateResponse(BaseModel):
|
||||||
|
|
@ -65,32 +71,79 @@ async def impersonate(
|
||||||
to create an impersonation session.
|
to create an impersonation session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
provider_user_id: str | None = request.provider_user_id
|
provider_user_id = (
|
||||||
|
request.provider_user_id.strip() if request.provider_user_id else None
|
||||||
|
) or None
|
||||||
|
email = request.email.strip().lower() if request.email else None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Fallback: resolve provider_user_id from internal ``user_id``
|
# Fallback: resolve provider_user_id from internal ``user_id`` or email.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
if provider_user_id is None:
|
if provider_user_id is None:
|
||||||
if request.user_id is None:
|
if request.user_id is not None:
|
||||||
|
db_user = await db_client.get_user_by_id(request.user_id)
|
||||||
|
|
||||||
|
if db_user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"User with ID {request.user_id} not found.",
|
||||||
|
)
|
||||||
|
|
||||||
|
provider_user_id = db_user.provider_id
|
||||||
|
elif email:
|
||||||
|
db_user = await db_client.get_user_by_email(email)
|
||||||
|
|
||||||
|
if db_user is not None:
|
||||||
|
provider_user_id = db_user.provider_id
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
stack_users = await stackauth.find_users_by_email(email)
|
||||||
|
except StackAuthUserSearchError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Failed to search Stack Auth users.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if len(stack_users) == 1 and isinstance(stack_users[0].get("id"), str):
|
||||||
|
provider_user_id = stack_users[0]["id"]
|
||||||
|
elif len(stack_users) > 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Multiple Stack Auth users matched that email.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"User with email {email} not found.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Either 'provider_user_id' or 'user_id' must be provided.",
|
detail=(
|
||||||
|
"One of 'provider_user_id', 'user_id', or 'email' must be provided."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
db_user = await db_client.get_user_by_id(request.user_id)
|
|
||||||
|
|
||||||
if db_user is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"User with ID {request.user_id} not found.",
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_user_id = db_user.provider_id
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Call Stack Auth to create the impersonation session
|
# Call Stack Auth to create the impersonation session
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
session = await stackauth.impersonate(provider_user_id)
|
try:
|
||||||
|
session = await stackauth.impersonate(provider_user_id)
|
||||||
|
except StackAuthSessionError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Failed to create Stack Auth impersonation session.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if (
|
||||||
|
not isinstance(session, dict)
|
||||||
|
or "refresh_token" not in session
|
||||||
|
or "access_token" not in session
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="Failed to create Stack Auth impersonation session.",
|
||||||
|
)
|
||||||
|
|
||||||
return ImpersonateResponse(
|
return ImpersonateResponse(
|
||||||
refresh_token=session["refresh_token"],
|
refresh_token=session["refresh_token"],
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ class TransferCallConfig(BaseModel):
|
||||||
description=(
|
description=(
|
||||||
"Phone number, SIP endpoint, or template to transfer the call to, e.g. "
|
"Phone number, SIP endpoint, or template to transfer the call to, e.g. "
|
||||||
"+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}."
|
"+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}."
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
messageType: Literal["none", "custom", "audio"] = Field(
|
messageType: Literal["none", "custom", "audio"] = Field(
|
||||||
default="none", description="Type of message to play before transfer."
|
default="none", description="Type of message to play before transfer."
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,17 @@
|
||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
|
||||||
|
class StackAuthUserSearchError(Exception):
|
||||||
|
"""Raised when Stack Auth user search fails unexpectedly."""
|
||||||
|
|
||||||
|
|
||||||
|
class StackAuthSessionError(Exception):
|
||||||
|
"""Raised when Stack Auth cannot create an impersonation session."""
|
||||||
|
|
||||||
|
|
||||||
class StackAuth:
|
class StackAuth:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.project_id = os.environ.get("STACK_AUTH_PROJECT_ID")
|
self.project_id = os.environ.get("STACK_AUTH_PROJECT_ID")
|
||||||
|
|
@ -56,10 +65,56 @@ class StackAuth:
|
||||||
"is_impersonation": True,
|
"is_impersonation": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
try:
|
||||||
async with session.post(url, headers=headers, json=data) as response:
|
async with aiohttp.ClientSession() as session:
|
||||||
response = await response.json()
|
async with session.post(url, headers=headers, json=data) as response:
|
||||||
return response
|
if response.status >= 400:
|
||||||
|
raise StackAuthSessionError(
|
||||||
|
"Stack Auth session creation failed"
|
||||||
|
)
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
except (aiohttp.ClientError, ValueError) as exc:
|
||||||
|
raise StackAuthSessionError("Stack Auth session creation failed") from exc
|
||||||
|
|
||||||
|
async def find_users_by_email(self, email: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return Stack Auth users whose primary email exactly matches."""
|
||||||
|
normalized_email = email.strip().lower()
|
||||||
|
url = os.environ.get("STACK_AUTH_API_URL") + "/api/v1/users"
|
||||||
|
headers = {
|
||||||
|
"x-stack-access-type": "server",
|
||||||
|
"x-stack-project-id": self.project_id,
|
||||||
|
"x-stack-secret-server-key": self.secret_server_key,
|
||||||
|
}
|
||||||
|
params = {
|
||||||
|
"query": normalized_email,
|
||||||
|
"limit": "10",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, params=params) as response:
|
||||||
|
if response.status >= 400:
|
||||||
|
raise StackAuthUserSearchError("Stack Auth user search failed")
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
except (aiohttp.ClientError, ValueError) as exc:
|
||||||
|
raise StackAuthUserSearchError("Stack Auth user search failed") from exc
|
||||||
|
|
||||||
|
users = payload.get("items", []) if isinstance(payload, dict) else []
|
||||||
|
if not isinstance(users, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
user
|
||||||
|
for user in users
|
||||||
|
if isinstance(user, dict)
|
||||||
|
and self._stack_user_has_email(user, normalized_email)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _stack_user_has_email(self, user: dict[str, Any], email: str) -> bool:
|
||||||
|
primary_email = user.get("primary_email")
|
||||||
|
return isinstance(primary_email, str) and primary_email.lower() == email
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Team & user management helpers
|
# Team & user management helpers
|
||||||
|
|
|
||||||
|
|
@ -329,7 +329,7 @@ class CustomToolManager:
|
||||||
return handler, timeout_secs
|
return handler, timeout_secs
|
||||||
|
|
||||||
def _transfer_handler_timeout_secs(self, tool: Any) -> float:
|
def _transfer_handler_timeout_secs(self, tool: Any) -> float:
|
||||||
config = ((tool.definition or {}).get("config", {}) or {})
|
config = (tool.definition or {}).get("config", {}) or {}
|
||||||
try:
|
try:
|
||||||
transfer_timeout = int(config.get("timeout", 30))
|
transfer_timeout = int(config.get("timeout", 30))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
|
|
@ -338,9 +338,8 @@ class CustomToolManager:
|
||||||
|
|
||||||
resolver_timeout = 0.0
|
resolver_timeout = 0.0
|
||||||
resolver = config.get("resolver")
|
resolver = config.get("resolver")
|
||||||
if (
|
if config.get("destination_source", "static") == "dynamic" and isinstance(
|
||||||
config.get("destination_source", "static") == "dynamic"
|
resolver, dict
|
||||||
and isinstance(resolver, dict)
|
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
resolver_timeout = float(resolver.get("timeout_ms", 3000)) / 1000.0
|
resolver_timeout = float(resolver.get("timeout_ms", 3000)) / 1000.0
|
||||||
|
|
@ -602,10 +601,9 @@ class CustomToolManager:
|
||||||
return
|
return
|
||||||
|
|
||||||
resolver = config.get("resolver") if isinstance(config, dict) else None
|
resolver = config.get("resolver") if isinstance(config, dict) else None
|
||||||
is_dynamic_transfer = (
|
is_dynamic_transfer = config.get(
|
||||||
config.get("destination_source", "static") == "dynamic"
|
"destination_source", "static"
|
||||||
and isinstance(resolver, dict)
|
) == "dynamic" and isinstance(resolver, dict)
|
||||||
)
|
|
||||||
resolver_phase_muted = False
|
resolver_phase_muted = False
|
||||||
|
|
||||||
def clear_transfer_setup_mute_state() -> None:
|
def clear_transfer_setup_mute_state() -> None:
|
||||||
|
|
|
||||||
|
|
@ -935,7 +935,9 @@ class TestTransferResolver:
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_http_resolver_resolves_transfer_context_destination(self):
|
async def test_http_resolver_resolves_transfer_context_destination(self):
|
||||||
from api.services.workflow.tools.transfer_resolver import resolve_transfer_config
|
from api.services.workflow.tools.transfer_resolver import (
|
||||||
|
resolve_transfer_config,
|
||||||
|
)
|
||||||
|
|
||||||
tool = MockToolModel(
|
tool = MockToolModel(
|
||||||
tool_uuid="transfer-tool-uuid",
|
tool_uuid="transfer-tool-uuid",
|
||||||
|
|
@ -1661,7 +1663,9 @@ class TestCustomToolManagerUnit:
|
||||||
assert transfer_kwargs["destination"] == "+14155550123"
|
assert transfer_kwargs["destination"] == "+14155550123"
|
||||||
assert transfer_kwargs["timeout"] == 30
|
assert transfer_kwargs["timeout"] == 30
|
||||||
assert result_received["status"] == "transfer_failed"
|
assert result_received["status"] == "transfer_failed"
|
||||||
assert [call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list] == [
|
assert [
|
||||||
|
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
|
||||||
|
] == [
|
||||||
True,
|
True,
|
||||||
True,
|
True,
|
||||||
False,
|
False,
|
||||||
|
|
@ -1751,7 +1755,9 @@ class TestCustomToolManagerUnit:
|
||||||
assert result_received["status"] == "transfer_failed"
|
assert result_received["status"] == "transfer_failed"
|
||||||
assert result_received["reason"] == "no_destination"
|
assert result_received["reason"] == "no_destination"
|
||||||
assert mock_engine._queued_speech_mute_state == "idle"
|
assert mock_engine._queued_speech_mute_state == "idle"
|
||||||
assert [call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list] == [
|
assert [
|
||||||
|
call.args[0] for call in mock_engine.set_mute_pipeline.call_args_list
|
||||||
|
] == [
|
||||||
True,
|
True,
|
||||||
False,
|
False,
|
||||||
]
|
]
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,6 +1,6 @@
|
||||||
# generated by datamodel-codegen:
|
# generated by datamodel-codegen:
|
||||||
# filename: dograh-openapi-XXXXXX.json.XNTNNgR8o4
|
# filename: dograh-openapi-XXXXXX.json.73JcjTo19T
|
||||||
# timestamp: 2026-07-09T13:05:30+00:00
|
# timestamp: 2026-07-11T10:21:09+00:00
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -534,6 +534,15 @@ class ToolResponse(BaseModel):
|
||||||
created_by: CreatedByResponse | None = None
|
created_by: CreatedByResponse | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DestinationSource(Enum):
|
||||||
|
"""
|
||||||
|
Whether transfer destination is static/template or resolved by HTTP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
static = 'static'
|
||||||
|
dynamic = 'dynamic'
|
||||||
|
|
||||||
|
|
||||||
class MessageType1(Enum):
|
class MessageType1(Enum):
|
||||||
"""
|
"""
|
||||||
Type of message to play before transfer.
|
Type of message to play before transfer.
|
||||||
|
|
@ -544,52 +553,6 @@ class MessageType1(Enum):
|
||||||
audio = 'audio'
|
audio = 'audio'
|
||||||
|
|
||||||
|
|
||||||
class TransferCallConfig(BaseModel):
|
|
||||||
"""
|
|
||||||
Configuration for Transfer Call tools.
|
|
||||||
"""
|
|
||||||
|
|
||||||
destination: Annotated[str, Field(title='Destination')]
|
|
||||||
"""
|
|
||||||
Phone number, SIP endpoint, or template to transfer the call to, e.g. +1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}.
|
|
||||||
"""
|
|
||||||
messageType: Annotated[MessageType1 | None, Field(title='Messagetype')] = 'none'
|
|
||||||
"""
|
|
||||||
Type of message to play before transfer.
|
|
||||||
"""
|
|
||||||
customMessage: Annotated[str | None, Field(title='Custommessage')] = None
|
|
||||||
"""
|
|
||||||
Custom message to play before transferring.
|
|
||||||
"""
|
|
||||||
audioRecordingId: Annotated[str | None, Field(title='Audiorecordingid')] = None
|
|
||||||
"""
|
|
||||||
Recording ID for audio message before transfer.
|
|
||||||
"""
|
|
||||||
timeout: Annotated[int | None, Field(ge=5, le=120, title='Timeout')] = 30
|
|
||||||
"""
|
|
||||||
Maximum seconds to wait for the destination to answer.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class TransferCallToolDefinition(BaseModel):
|
|
||||||
"""
|
|
||||||
Tool definition for Transfer Call tools.
|
|
||||||
"""
|
|
||||||
|
|
||||||
schema_version: Annotated[int | None, Field(title='Schema Version')] = 1
|
|
||||||
"""
|
|
||||||
Schema version.
|
|
||||||
"""
|
|
||||||
type: Annotated[Literal['transfer_call'], Field(title='Type')]
|
|
||||||
"""
|
|
||||||
Tool type.
|
|
||||||
"""
|
|
||||||
config: TransferCallConfig
|
|
||||||
"""
|
|
||||||
Transfer Call configuration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(BaseModel):
|
class ValidationError(BaseModel):
|
||||||
loc: Annotated[list[str | int], Field(title='Location')]
|
loc: Annotated[list[str | int], Field(title='Location')]
|
||||||
msg: Annotated[str, Field(title='Message')]
|
msg: Annotated[str, Field(title='Message')]
|
||||||
|
|
@ -762,6 +725,47 @@ class HttpApiToolDefinition(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HttpTransferResolverConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
HTTP endpoint used to resolve transfer destination at call time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: Annotated[Literal['http'], Field(title='Type')] = 'http'
|
||||||
|
"""
|
||||||
|
Resolver type.
|
||||||
|
"""
|
||||||
|
url: Annotated[str, Field(title='Url')]
|
||||||
|
"""
|
||||||
|
HTTP or HTTPS endpoint for transfer resolution.
|
||||||
|
"""
|
||||||
|
headers: Annotated[dict[str, str] | None, Field(title='Headers')] = None
|
||||||
|
"""
|
||||||
|
Static headers to include with every resolver request.
|
||||||
|
"""
|
||||||
|
credential_uuid: Annotated[str | None, Field(title='Credential Uuid')] = None
|
||||||
|
"""
|
||||||
|
Reference to an external credential for resolver authentication.
|
||||||
|
"""
|
||||||
|
timeout_ms: Annotated[int | None, Field(ge=500, le=5000, title='Timeout Ms')] = 3000
|
||||||
|
"""
|
||||||
|
Resolver request timeout in milliseconds.
|
||||||
|
"""
|
||||||
|
wait_message: Annotated[str | None, Field(title='Wait Message')] = None
|
||||||
|
"""
|
||||||
|
Optional short message played while Dograh resolves routing.
|
||||||
|
"""
|
||||||
|
parameters: Annotated[list[ToolParameter] | None, Field(title='Parameters')] = None
|
||||||
|
"""
|
||||||
|
Parameters the model may provide when calling this transfer tool.
|
||||||
|
"""
|
||||||
|
preset_parameters: Annotated[
|
||||||
|
list[PresetToolParameter] | None, Field(title='Preset Parameters')
|
||||||
|
] = None
|
||||||
|
"""
|
||||||
|
Parameters injected by Dograh from fixed values or workflow context templates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class PropertySpec(BaseModel):
|
class PropertySpec(BaseModel):
|
||||||
"""
|
"""
|
||||||
Single field on a node.
|
Single field on a node.
|
||||||
|
|
@ -814,6 +818,66 @@ class RecordingListResponseSchema(BaseModel):
|
||||||
total: Annotated[int, Field(title='Total')]
|
total: Annotated[int, Field(title='Total')]
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCallConfig(BaseModel):
|
||||||
|
"""
|
||||||
|
Configuration for Transfer Call tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
destination_source: Annotated[
|
||||||
|
DestinationSource | None, Field(title='Destination Source')
|
||||||
|
] = 'static'
|
||||||
|
"""
|
||||||
|
Whether transfer destination is static/template or resolved by HTTP.
|
||||||
|
"""
|
||||||
|
destination: Annotated[str | None, Field(title='Destination')] = ''
|
||||||
|
"""
|
||||||
|
Phone number, SIP endpoint, or template to transfer the call to, e.g. +1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}.
|
||||||
|
"""
|
||||||
|
messageType: Annotated[MessageType1 | None, Field(title='Messagetype')] = 'none'
|
||||||
|
"""
|
||||||
|
Type of message to play before transfer.
|
||||||
|
"""
|
||||||
|
customMessage: Annotated[str | None, Field(title='Custommessage')] = None
|
||||||
|
"""
|
||||||
|
Custom message to play before transferring.
|
||||||
|
"""
|
||||||
|
audioRecordingId: Annotated[str | None, Field(title='Audiorecordingid')] = None
|
||||||
|
"""
|
||||||
|
Recording ID for audio message before transfer.
|
||||||
|
"""
|
||||||
|
timeout: Annotated[int | None, Field(ge=5, le=120, title='Timeout')] = 30
|
||||||
|
"""
|
||||||
|
Maximum seconds to wait for the destination to answer.
|
||||||
|
"""
|
||||||
|
parameters: Annotated[list[ToolParameter] | None, Field(title='Parameters')] = None
|
||||||
|
"""
|
||||||
|
Parameters the model may provide when calling this transfer tool, for example state, department, or transfer reason.
|
||||||
|
"""
|
||||||
|
resolver: HttpTransferResolverConfig | None = None
|
||||||
|
"""
|
||||||
|
Optional resolver that determines transfer routing at call time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TransferCallToolDefinition(BaseModel):
|
||||||
|
"""
|
||||||
|
Tool definition for Transfer Call tools.
|
||||||
|
"""
|
||||||
|
|
||||||
|
schema_version: Annotated[int | None, Field(title='Schema Version')] = 1
|
||||||
|
"""
|
||||||
|
Schema version.
|
||||||
|
"""
|
||||||
|
type: Annotated[Literal['transfer_call'], Field(title='Type')]
|
||||||
|
"""
|
||||||
|
Tool type.
|
||||||
|
"""
|
||||||
|
config: TransferCallConfig
|
||||||
|
"""
|
||||||
|
Transfer Call configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class UpdateWorkflowRequest(BaseModel):
|
class UpdateWorkflowRequest(BaseModel):
|
||||||
name: Annotated[str | None, Field(title='Name')] = None
|
name: Annotated[str | None, Field(title='Name')] = None
|
||||||
workflow_definition: Annotated[
|
workflow_definition: Annotated[
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,57 @@ export interface components {
|
||||||
/** @description HTTP API configuration. */
|
/** @description HTTP API configuration. */
|
||||||
config: components["schemas"]["HttpApiConfig"];
|
config: components["schemas"]["HttpApiConfig"];
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* HttpTransferResolverConfig
|
||||||
|
* @description HTTP endpoint used to resolve transfer destination at call time.
|
||||||
|
*/
|
||||||
|
HttpTransferResolverConfig: {
|
||||||
|
/**
|
||||||
|
* Type
|
||||||
|
* @description Resolver type.
|
||||||
|
* @default http
|
||||||
|
* @constant
|
||||||
|
*/
|
||||||
|
type: "http";
|
||||||
|
/**
|
||||||
|
* Url
|
||||||
|
* @description HTTP or HTTPS endpoint for transfer resolution.
|
||||||
|
*/
|
||||||
|
url: string;
|
||||||
|
/**
|
||||||
|
* Headers
|
||||||
|
* @description Static headers to include with every resolver request.
|
||||||
|
*/
|
||||||
|
headers?: {
|
||||||
|
[key: string]: string;
|
||||||
|
} | null;
|
||||||
|
/**
|
||||||
|
* Credential Uuid
|
||||||
|
* @description Reference to an external credential for resolver authentication.
|
||||||
|
*/
|
||||||
|
credential_uuid?: string | null;
|
||||||
|
/**
|
||||||
|
* Timeout Ms
|
||||||
|
* @description Resolver request timeout in milliseconds.
|
||||||
|
* @default 3000
|
||||||
|
*/
|
||||||
|
timeout_ms: number;
|
||||||
|
/**
|
||||||
|
* Wait Message
|
||||||
|
* @description Optional short message played while Dograh resolves routing.
|
||||||
|
*/
|
||||||
|
wait_message?: string | null;
|
||||||
|
/**
|
||||||
|
* Parameters
|
||||||
|
* @description Parameters the model may provide when calling this transfer tool.
|
||||||
|
*/
|
||||||
|
parameters?: components["schemas"]["ToolParameter"][] | null;
|
||||||
|
/**
|
||||||
|
* Preset Parameters
|
||||||
|
* @description Parameters injected by Dograh from fixed values or workflow context templates.
|
||||||
|
*/
|
||||||
|
preset_parameters?: components["schemas"]["PresetToolParameter"][] | null;
|
||||||
|
};
|
||||||
/** InitiateCallRequest */
|
/** InitiateCallRequest */
|
||||||
InitiateCallRequest: {
|
InitiateCallRequest: {
|
||||||
/** Workflow Id */
|
/** Workflow Id */
|
||||||
|
|
@ -1034,9 +1085,17 @@ export interface components {
|
||||||
* @description Configuration for Transfer Call tools.
|
* @description Configuration for Transfer Call tools.
|
||||||
*/
|
*/
|
||||||
TransferCallConfig: {
|
TransferCallConfig: {
|
||||||
|
/**
|
||||||
|
* Destination Source
|
||||||
|
* @description Whether transfer destination is static/template or resolved by HTTP.
|
||||||
|
* @default static
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
destination_source: "static" | "dynamic";
|
||||||
/**
|
/**
|
||||||
* Destination
|
* Destination
|
||||||
* @description Phone number, SIP endpoint, or template to transfer the call to, e.g. +1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}.
|
* @description Phone number, SIP endpoint, or template to transfer the call to, e.g. +1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}.
|
||||||
|
* @default
|
||||||
*/
|
*/
|
||||||
destination: string;
|
destination: string;
|
||||||
/**
|
/**
|
||||||
|
|
@ -1062,6 +1121,13 @@ export interface components {
|
||||||
* @default 30
|
* @default 30
|
||||||
*/
|
*/
|
||||||
timeout: number;
|
timeout: number;
|
||||||
|
/**
|
||||||
|
* Parameters
|
||||||
|
* @description Parameters the model may provide when calling this transfer tool, for example state, department, or transfer reason.
|
||||||
|
*/
|
||||||
|
parameters?: components["schemas"]["ToolParameter"][] | null;
|
||||||
|
/** @description Optional resolver that determines transfer routing at call time. */
|
||||||
|
resolver?: components["schemas"]["HttpTransferResolverConfig"] | null;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* TransferCallToolDefinition
|
* TransferCallToolDefinition
|
||||||
|
|
@ -1245,6 +1311,7 @@ export type GraphConstraints = components['schemas']['GraphConstraints'];
|
||||||
export type HttpValidationError = components['schemas']['HTTPValidationError'];
|
export type HttpValidationError = components['schemas']['HTTPValidationError'];
|
||||||
export type HttpApiConfig = components['schemas']['HttpApiConfig'];
|
export type HttpApiConfig = components['schemas']['HttpApiConfig'];
|
||||||
export type HttpApiToolDefinition = components['schemas']['HttpApiToolDefinition'];
|
export type HttpApiToolDefinition = components['schemas']['HttpApiToolDefinition'];
|
||||||
|
export type HttpTransferResolverConfig = components['schemas']['HttpTransferResolverConfig'];
|
||||||
export type InitiateCallRequest = components['schemas']['InitiateCallRequest'];
|
export type InitiateCallRequest = components['schemas']['InitiateCallRequest'];
|
||||||
export type McpToolConfig = components['schemas']['McpToolConfig'];
|
export type McpToolConfig = components['schemas']['McpToolConfig'];
|
||||||
export type McpToolDefinition = components['schemas']['McpToolDefinition'];
|
export type McpToolDefinition = components['schemas']['McpToolDefinition'];
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,12 @@ import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getStackConfig } from "@/lib/auth/config";
|
import { getStackConfig } from "@/lib/auth/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper route that receives a refresh token via query parameters, stores it as
|
* Helper route that receives a Stack refresh token via query parameters, stores
|
||||||
* the regular Stack cookie *for the current sub-domain only* and finally
|
* it as the regular Stack SDK cookie *for the current sub-domain only* and finally
|
||||||
* redirects the user to the requested path.
|
* redirects the user to the requested path.
|
||||||
*
|
*
|
||||||
* Example usage (client side):
|
* Example usage (client side):
|
||||||
* /impersonate?refresh_token=<TOKEN>&redirect_path=/workflow/123
|
* /impersonate?refresh_token=<REFRESH>&redirect_path=/workflow/123
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -20,31 +20,89 @@ export async function GET(request: NextRequest) {
|
||||||
return new Response("Missing refresh_token", { status: 400 });
|
return new Response("Missing refresh_token", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Stack session cookie is named `stack-refresh-<projectId>`. The project
|
// The project id comes from the backend at runtime, so no inlined
|
||||||
// id comes from the backend at runtime, so no inlined NEXT_PUBLIC_* is needed.
|
// NEXT_PUBLIC_* is needed.
|
||||||
const stackConfig = await getStackConfig();
|
const stackConfig = await getStackConfig();
|
||||||
if (!stackConfig) {
|
if (!stackConfig) {
|
||||||
return new Response("Stack auth is not configured", { status: 400 });
|
return new Response("Stack auth is not configured", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare redirect – if the supplied redirect path is an absolute URL we use
|
const requestedRedirectUrl = new URL(redirectPath, request.url);
|
||||||
// it as-is, otherwise we resolve it relative to the current request.
|
const fallbackRedirectUrl = new URL("/workflow/create", request.url);
|
||||||
const redirectUrl = redirectPath.startsWith("http")
|
const redirectUrl =
|
||||||
? redirectPath
|
requestedRedirectUrl.origin === request.nextUrl.origin
|
||||||
: new URL(redirectPath, request.url).toString();
|
? requestedRedirectUrl.toString()
|
||||||
|
: fallbackRedirectUrl.toString();
|
||||||
|
|
||||||
const response = NextResponse.redirect(redirectUrl);
|
const response = NextResponse.redirect(redirectUrl);
|
||||||
|
|
||||||
// One day in seconds
|
const isSecure =
|
||||||
const maxAge = 60 * 60 * 24;
|
request.nextUrl.protocol === "https:" ||
|
||||||
|
request.headers.get("x-forwarded-proto") === "https";
|
||||||
|
const refreshCookieName = `${isSecure ? "__Host-" : ""}hexclave-refresh-${stackConfig.projectId}--default`;
|
||||||
|
const accessCookieName = "hexclave-access";
|
||||||
|
const refreshMaxAge = 60 * 60 * 24 * 365;
|
||||||
|
|
||||||
// Store the refresh token cookie without an explicit domain so that it is
|
// Store the refresh token using the cookie name/shape Stack's current
|
||||||
// scoped to the current (sub-)domain. This avoids collisions between the
|
// nextjs-cookie token store reads. The old route only set the legacy
|
||||||
// admin (superadmin.*) and the regular app (app.*) domains.
|
// refresh cookie, which let a stale access cookie keep the browser on the
|
||||||
|
// previous app-domain session.
|
||||||
|
response.cookies.set(
|
||||||
|
refreshCookieName,
|
||||||
|
JSON.stringify({
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
updated_at_millis: Date.now(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
maxAge: refreshMaxAge,
|
||||||
|
secure: isSecure,
|
||||||
|
httpOnly: false, // Must be accessible from the browser for Stack SDK
|
||||||
|
sameSite: "lax",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const staleCookieNames = new Set([
|
||||||
|
accessCookieName,
|
||||||
|
`stack-refresh-${stackConfig.projectId}`,
|
||||||
|
"stack-refresh",
|
||||||
|
`stack-refresh-${stackConfig.projectId}--default`,
|
||||||
|
`__Host-stack-refresh-${stackConfig.projectId}--default`,
|
||||||
|
`hexclave-refresh-${stackConfig.projectId}--default`,
|
||||||
|
`__Host-hexclave-refresh-${stackConfig.projectId}--default`,
|
||||||
|
"stack-access",
|
||||||
|
]);
|
||||||
|
staleCookieNames.delete(refreshCookieName);
|
||||||
|
|
||||||
|
for (const cookie of request.cookies.getAll()) {
|
||||||
|
const name = cookie.name;
|
||||||
|
if (name !== refreshCookieName && (
|
||||||
|
name.startsWith(`hexclave-refresh-${stackConfig.projectId}--custom-`) ||
|
||||||
|
name.startsWith(`stack-refresh-${stackConfig.projectId}--custom-`) ||
|
||||||
|
name.startsWith(`__Host-hexclave-refresh-${stackConfig.projectId}--`) ||
|
||||||
|
name.startsWith(`__Host-stack-refresh-${stackConfig.projectId}--`)
|
||||||
|
)) {
|
||||||
|
staleCookieNames.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of staleCookieNames) {
|
||||||
|
response.cookies.set(name, "", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
secure: name.startsWith("__Host-") || isSecure,
|
||||||
|
httpOnly: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep writing the legacy project refresh cookie for compatibility with
|
||||||
|
// older Stack SDK builds, but the structured Hexclave cookies above are the
|
||||||
|
// source of truth for the current app.
|
||||||
response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
|
response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, {
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge,
|
maxAge: refreshMaxAge,
|
||||||
secure: true,
|
secure: isSecure,
|
||||||
httpOnly: false, // Must be accessible from the browser for Stack SDK
|
httpOnly: false, // Must be accessible from the browser for Stack SDK
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,38 @@ import { Label } from "@/components/ui/label";
|
||||||
import { useAuth } from '@/lib/auth';
|
import { useAuth } from '@/lib/auth';
|
||||||
import { impersonateAsSuperadmin } from "@/lib/utils";
|
import { impersonateAsSuperadmin } from "@/lib/utils";
|
||||||
|
|
||||||
|
type ImpersonationTarget = "provider" | "email";
|
||||||
|
|
||||||
export default function SuperadminPage() {
|
export default function SuperadminPage() {
|
||||||
const [userId, setUserId] = useState("");
|
const [providerUserId, setProviderUserId] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [error, setError] = useState<{ target: ImpersonationTarget; message: string } | null>(null);
|
||||||
|
const [loadingTarget, setLoadingTarget] = useState<ImpersonationTarget | null>(null);
|
||||||
const { user, getAccessToken } = useAuth();
|
const { user, getAccessToken } = useAuth();
|
||||||
|
|
||||||
const handleImpersonate = async (e: React.FormEvent) => {
|
const handleImpersonate = async (target: ImpersonationTarget, value: string) => {
|
||||||
e.preventDefault();
|
const trimmedValue = value.trim();
|
||||||
setError("");
|
setError(null);
|
||||||
setIsLoading(true);
|
|
||||||
|
if (!trimmedValue) {
|
||||||
|
setError({
|
||||||
|
target,
|
||||||
|
message: target === "provider" ? "Enter a provider user ID." : "Enter an email address.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingTarget(target);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setError("User not authenticated. Please log in and try again.");
|
setError({
|
||||||
setIsLoading(false);
|
target,
|
||||||
|
message: "User not authenticated. Please log in and try again.",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = await getAccessToken();
|
const accessToken = await getAccessToken();
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
throw new Error('Missing admin access token');
|
throw new Error('Missing admin access token');
|
||||||
|
|
@ -35,74 +50,132 @@ export default function SuperadminPage() {
|
||||||
|
|
||||||
await impersonateAsSuperadmin({
|
await impersonateAsSuperadmin({
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
providerUserId: userId,
|
...(target === "provider"
|
||||||
|
? { providerUserId: trimmedValue }
|
||||||
|
: { email: trimmedValue }),
|
||||||
redirectPath: '/workflow',
|
redirectPath: '/workflow',
|
||||||
openInNewTab: true,
|
openInNewTab: true,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to impersonate user. Please try again.");
|
setError({
|
||||||
|
target,
|
||||||
|
message: err instanceof Error ? err.message : "Failed to impersonate user. Please try again.",
|
||||||
|
});
|
||||||
console.error("Impersonation error:", err);
|
console.error("Impersonation error:", err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setLoadingTarget(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProviderImpersonate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleImpersonate("provider", providerUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmailImpersonate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await handleImpersonate("email", email);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<main className="container mx-auto p-6 space-y-6 max-w-4xl">
|
<main className="container mx-auto p-6 space-y-6 max-w-5xl">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-3xl font-bold mb-2">Superadmin Dashboard</h1>
|
<h1 className="text-3xl font-bold mb-2">Superadmin Dashboard</h1>
|
||||||
<p className="text-sm text-muted-foreground">Manage users and view system-wide data</p>
|
<p className="text-sm text-muted-foreground">Manage users and view system-wide data</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{/* User Impersonation Card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>User Impersonation</CardTitle>
|
<CardTitle>Provider User ID</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Impersonate a user account for debugging or support purposes
|
Impersonate with the Stack provider user ID
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleImpersonate} className="space-y-4">
|
<form onSubmit={handleProviderImpersonate} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="userId">Provider User ID</Label>
|
<Label htmlFor="providerUserId">Provider User ID</Label>
|
||||||
<Input
|
<Input
|
||||||
id="userId"
|
id="providerUserId"
|
||||||
value={userId}
|
value={providerUserId}
|
||||||
onChange={(e) => setUserId(e.target.value)}
|
onChange={(e) => setProviderUserId(e.target.value)}
|
||||||
placeholder="Enter provider user ID"
|
placeholder="Provider user ID"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error?.target === "provider" && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||||
{error}
|
{error.message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading}
|
disabled={loadingTarget !== null}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{loadingTarget === "provider" ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Processing...
|
Processing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Impersonate User'
|
'Impersonate by Provider ID'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Workflow Runs Card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Email</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Impersonate with a primary email address
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleEmailImpersonate} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email Address</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error?.target === "email" && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loadingTarget !== null}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{loadingTarget === "email" ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Processing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Impersonate by Email'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Workflow Runs</CardTitle>
|
<CardTitle>Workflow Runs</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|
@ -110,19 +183,13 @@ export default function SuperadminPage() {
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<Link href="/superadmin/runs">
|
||||||
<p className="text-sm text-muted-foreground">
|
<Button className="w-full md:w-auto">
|
||||||
Access detailed information about all workflow runs, including status,
|
<List className="mr-2 h-4 w-4" />
|
||||||
recordings, transcripts, and usage data.
|
View All Runs
|
||||||
</p>
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
<Link href="/superadmin/runs">
|
</Button>
|
||||||
<Button className="w-full">
|
</Link>
|
||||||
<List className="mr-2 h-4 w-4" />
|
|
||||||
View All Runs
|
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, Info, Loader2, RefreshCw } from 'lucide-react';
|
import { AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, CheckCircle, ChevronLeft, ChevronRight, ExternalLink, FileText, Info, Loader2, RefreshCw } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
@ -541,25 +541,38 @@ export default function RunsPage() {
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Quick-link to open the workflow inside the *regular* app after
|
{/* Quick links open the regular app after impersonating the
|
||||||
successfully impersonating the owner of the workflow. */}
|
owner of the workflow run. */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="Open workflow as user"
|
title="Open workflow as user"
|
||||||
|
disabled={!run.user_id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const appBaseUrl = window.location.origin.includes('superadmin.')
|
|
||||||
? window.location.origin.replace('superadmin.', 'app.')
|
|
||||||
: window.location.origin;
|
|
||||||
impersonateAndMaybeRedirect(
|
impersonateAndMaybeRedirect(
|
||||||
run.user_id,
|
run.user_id,
|
||||||
`${appBaseUrl}/workflow/${run.workflow_id}`,
|
`/workflow/${run.workflow_id}`,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title="Open run details as user"
|
||||||
|
disabled={!run.user_id}
|
||||||
|
onClick={() => {
|
||||||
|
impersonateAndMaybeRedirect(
|
||||||
|
run.user_id,
|
||||||
|
`/workflow/${run.workflow_id}/run/${run.id}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RecordingResponseSchema } from "@/client/types.gen";
|
import type { RecordingResponseSchema } from "@/client/types.gen";
|
||||||
|
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
||||||
import {
|
import {
|
||||||
CredentialSelector,
|
CredentialSelector,
|
||||||
KeyValueEditor,
|
KeyValueEditor,
|
||||||
|
|
@ -11,7 +12,6 @@ import {
|
||||||
type ToolParameter,
|
type ToolParameter,
|
||||||
UrlInput,
|
UrlInput,
|
||||||
} from "@/components/http";
|
} from "@/components/http";
|
||||||
import { RecordingSelect, StaticTextWarning } from "@/components/flow/TextOrAudioInput";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,14 @@ import { useAuth } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
createMcpDefinition,
|
createMcpDefinition,
|
||||||
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
DEFAULT_END_CALL_REASON_DESCRIPTION,
|
||||||
type ExtendedTransferCallConfig,
|
|
||||||
type EndCallMessageType,
|
type EndCallMessageType,
|
||||||
|
type ExtendedTransferCallConfig,
|
||||||
getCategoryConfig,
|
getCategoryConfig,
|
||||||
getToolTypeLabel,
|
getToolTypeLabel,
|
||||||
MCP_URL_PATTERN,
|
MCP_URL_PATTERN,
|
||||||
renderToolIcon,
|
renderToolIcon,
|
||||||
type TransferDestinationSource,
|
|
||||||
type ToolCategory,
|
type ToolCategory,
|
||||||
|
type TransferDestinationSource,
|
||||||
} from "../config";
|
} from "../config";
|
||||||
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
|
import { BuiltinToolConfig, EndCallToolConfig, HttpApiToolConfig, TransferCallToolConfig } from "./components";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ import type {
|
||||||
HttpApiToolDefinition,
|
HttpApiToolDefinition,
|
||||||
McpToolDefinition,
|
McpToolDefinition,
|
||||||
PresetToolParameter,
|
PresetToolParameter,
|
||||||
|
ToolParameter,
|
||||||
TransferCallConfig,
|
TransferCallConfig,
|
||||||
TransferCallToolDefinition,
|
TransferCallToolDefinition,
|
||||||
ToolParameter,
|
|
||||||
} from "@/client/types.gen";
|
} from "@/client/types.gen";
|
||||||
|
|
||||||
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
|
export type ToolCategory = "http_api" | "end_call" | "transfer_call" | "calculator" | "native" | "integration" | "mcp";
|
||||||
|
|
|
||||||
|
|
@ -3125,8 +3125,9 @@ export type HuggingFaceSttConfiguration = {
|
||||||
*
|
*
|
||||||
* Request payload for superadmin impersonation.
|
* Request payload for superadmin impersonation.
|
||||||
*
|
*
|
||||||
* Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
|
* ``provider_user_id``, ``user_id``, or ``email`` may be supplied. If more
|
||||||
* provided, ``provider_user_id`` takes precedence.
|
* than one is provided, ``provider_user_id`` takes precedence, followed by
|
||||||
|
* ``user_id`` and then ``email``.
|
||||||
*/
|
*/
|
||||||
export type ImpersonateRequest = {
|
export type ImpersonateRequest = {
|
||||||
/**
|
/**
|
||||||
|
|
@ -3137,6 +3138,10 @@ export type ImpersonateRequest = {
|
||||||
* User Id
|
* User Id
|
||||||
*/
|
*/
|
||||||
user_id?: number | null;
|
user_id?: number | null;
|
||||||
|
/**
|
||||||
|
* Email
|
||||||
|
*/
|
||||||
|
email?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { twMerge } from "tailwind-merge"
|
||||||
import { getAuthUserApiV1UserAuthUserGet } from "@/client/sdk.gen";
|
import { getAuthUserApiV1UserAuthUserGet } from "@/client/sdk.gen";
|
||||||
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
|
import { getWorkflowCountApiV1WorkflowCountGet } from "@/client/sdk.gen";
|
||||||
import { impersonateApiV1SuperuserImpersonatePost } from "@/client/sdk.gen";
|
import { impersonateApiV1SuperuserImpersonatePost } from "@/client/sdk.gen";
|
||||||
|
import { detailFromError } from "@/lib/apiError";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
|
|
@ -108,13 +109,15 @@ export async function getRedirectUrl(token: string, permissions: { id: string }[
|
||||||
/**
|
/**
|
||||||
* Centralised impersonation logic to avoid code duplication between pages.
|
* Centralised impersonation logic to avoid code duplication between pages.
|
||||||
*
|
*
|
||||||
* It performs the super-admin impersonate request, sets the cross-sub-domain
|
* It performs the super-admin impersonate request, sends the Stack tokens to
|
||||||
* refresh cookie and optionally redirects the browser to the supplied path.
|
* the target-domain helper route, and optionally redirects the browser to the
|
||||||
|
* supplied path.
|
||||||
*/
|
*/
|
||||||
export async function impersonateAsSuperadmin(params: {
|
export async function impersonateAsSuperadmin(params: {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
userId?: number;
|
userId?: number;
|
||||||
providerUserId?: string;
|
providerUserId?: string;
|
||||||
|
email?: string;
|
||||||
redirectPath?: string;
|
redirectPath?: string;
|
||||||
/**
|
/**
|
||||||
* If true the browser opens the impersonated session in a **new tab**
|
* If true the browser opens the impersonated session in a **new tab**
|
||||||
|
|
@ -122,7 +125,21 @@ export async function impersonateAsSuperadmin(params: {
|
||||||
*/
|
*/
|
||||||
openInNewTab?: boolean;
|
openInNewTab?: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { accessToken, userId, providerUserId, redirectPath, openInNewTab = false } = params;
|
const {
|
||||||
|
accessToken: adminAccessToken,
|
||||||
|
userId,
|
||||||
|
providerUserId,
|
||||||
|
email,
|
||||||
|
redirectPath,
|
||||||
|
openInNewTab = false,
|
||||||
|
} = params;
|
||||||
|
const targetWindow = openInNewTab ? window.open('', '_blank') : null;
|
||||||
|
if (targetWindow) {
|
||||||
|
targetWindow.opener = null;
|
||||||
|
}
|
||||||
|
if (openInNewTab && !targetWindow) {
|
||||||
|
throw new Error('Unable to open impersonation tab. Please allow pop-ups and try again.');
|
||||||
|
}
|
||||||
|
|
||||||
// Build request body depending on which identifier we have.
|
// Build request body depending on which identifier we have.
|
||||||
const body: Record<string, unknown> = {};
|
const body: Record<string, unknown> = {};
|
||||||
|
|
@ -132,20 +149,36 @@ export async function impersonateAsSuperadmin(params: {
|
||||||
if (providerUserId !== undefined) {
|
if (providerUserId !== undefined) {
|
||||||
body.provider_user_id = providerUserId;
|
body.provider_user_id = providerUserId;
|
||||||
}
|
}
|
||||||
|
if (email !== undefined) {
|
||||||
if (Object.keys(body).length === 0) {
|
body.email = email;
|
||||||
throw new Error('Either userId or providerUserId must be provided');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await impersonateApiV1SuperuserImpersonatePost({
|
if (Object.keys(body).length === 0) {
|
||||||
body,
|
targetWindow?.close();
|
||||||
headers: {
|
throw new Error('Either userId, providerUserId, or email must be provided');
|
||||||
Authorization: `Bearer ${accessToken}`,
|
}
|
||||||
},
|
|
||||||
});
|
let resp: Awaited<ReturnType<typeof impersonateApiV1SuperuserImpersonatePost>>;
|
||||||
|
try {
|
||||||
|
resp = await impersonateApiV1SuperuserImpersonatePost({
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${adminAccessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
targetWindow?.close();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.error) {
|
||||||
|
targetWindow?.close();
|
||||||
|
throw new Error(detailFromError(resp.error, 'Failed to impersonate user'));
|
||||||
|
}
|
||||||
|
|
||||||
const refreshToken = resp.data?.refresh_token;
|
const refreshToken = resp.data?.refresh_token;
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
|
targetWindow?.close();
|
||||||
throw new Error('No refresh token returned from impersonate');
|
throw new Error('No refresh token returned from impersonate');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,14 +201,16 @@ export async function impersonateAsSuperadmin(params: {
|
||||||
|
|
||||||
// Build the redirect URL to the helper route, passing along the refresh token and
|
// Build the redirect URL to the helper route, passing along the refresh token and
|
||||||
// the final destination.
|
// the final destination.
|
||||||
const impersonateUrl = `${appBaseUrl}/impersonate?refresh_token=${encodeURIComponent(
|
const impersonateUrl = new URL('/impersonate', appBaseUrl);
|
||||||
refreshToken,
|
impersonateUrl.searchParams.set('refresh_token', refreshToken);
|
||||||
)}&redirect_path=${encodeURIComponent(finalRedirect)}`;
|
impersonateUrl.searchParams.set('redirect_path', finalRedirect);
|
||||||
|
|
||||||
if (openInNewTab) {
|
if (openInNewTab) {
|
||||||
window.open(impersonateUrl, '_blank');
|
if (!targetWindow) {
|
||||||
|
throw new Error('Unable to open impersonation tab. Please allow pop-ups and try again.');
|
||||||
|
}
|
||||||
|
targetWindow.location.href = impersonateUrl.toString();
|
||||||
} else {
|
} else {
|
||||||
window.location.href = impersonateUrl;
|
window.location.href = impersonateUrl.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue