fix: fix superadmin impersonation

This commit is contained in:
Abhishek Kumar 2026-07-11 15:51:36 +05:30
parent d1339970a5
commit e405457676
16 changed files with 593 additions and 172 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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'];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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