From 05ead4dc867062e61702fbf7ec82cce4bb08c9e5 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Mon, 5 Jan 2026 19:41:38 +0530 Subject: [PATCH] feat: enable api key access to routes --- .../488eb58e4e6e_add_cloudonix_mode.py | 49 +++++-- api/services/auth/depends.py | 60 +++++++-- ui/src/components/flow/nodes/StartCall.tsx | 124 +++++++++++++++++- 3 files changed, 212 insertions(+), 21 deletions(-) diff --git a/api/alembic/versions/488eb58e4e6e_add_cloudonix_mode.py b/api/alembic/versions/488eb58e4e6e_add_cloudonix_mode.py index 3e306bc..378cec6 100644 --- a/api/alembic/versions/488eb58e4e6e_add_cloudonix_mode.py +++ b/api/alembic/versions/488eb58e4e6e_add_cloudonix_mode.py @@ -5,15 +5,15 @@ Revises: ebc80cea7965 Create Date: 2026-01-03 18:08:37.310476 """ + from typing import Sequence, Union from alembic import op -import sqlalchemy as sa from alembic_postgresql_enum import TableReference # revision identifiers, used by Alembic. -revision: str = '488eb58e4e6e' -down_revision: Union[str, None] = 'ebc80cea7965' +revision: str = "488eb58e4e6e" +down_revision: Union[str, None] = "ebc80cea7965" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -21,10 +21,24 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.sync_enum_values( - enum_schema='public', - enum_name='workflow_run_mode', - new_values=['twilio', 'vonage', 'vobiz', 'cloudonix', 'stasis', 'webrtc', 'smallwebrtc', 'VOICE', 'CHAT'], - affected_columns=[TableReference(table_schema='public', table_name='workflow_runs', column_name='mode')], + enum_schema="public", + enum_name="workflow_run_mode", + new_values=[ + "twilio", + "vonage", + "vobiz", + "cloudonix", + "stasis", + "webrtc", + "smallwebrtc", + "VOICE", + "CHAT", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="workflow_runs", column_name="mode" + ) + ], enum_values_to_rename=[], ) # ### end Alembic commands ### @@ -33,10 +47,23 @@ def upgrade() -> None: def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.sync_enum_values( - enum_schema='public', - enum_name='workflow_run_mode', - new_values=['twilio', 'vonage', 'vobiz', 'stasis', 'webrtc', 'smallwebrtc', 'VOICE', 'CHAT'], - affected_columns=[TableReference(table_schema='public', table_name='workflow_runs', column_name='mode')], + enum_schema="public", + enum_name="workflow_run_mode", + new_values=[ + "twilio", + "vonage", + "vobiz", + "stasis", + "webrtc", + "smallwebrtc", + "VOICE", + "CHAT", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="workflow_runs", column_name="mode" + ) + ], enum_values_to_rename=[], ) # ### end Alembic commands ### diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 6d38b33..79a3fcd 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -15,7 +15,14 @@ from api.services.configuration.registry import ServiceProviders async def get_user( authorization: Annotated[str | None, Header()] = None, + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, ) -> UserModel: + # ------------------------------------------------------------------ + # Check if API key is provided (takes precedence) + # ------------------------------------------------------------------ + if x_api_key: + return await _handle_api_key_auth(x_api_key) + # ------------------------------------------------------------------ # Check if we're in OSS deployment mode # ------------------------------------------------------------------ @@ -101,13 +108,14 @@ async def get_user( async def get_user_optional( authorization: Annotated[str | None, Header()] = None, + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, ) -> UserModel | None: """ Same as get_user but returns None instead of raising 401 if unauthorized. Useful for endpoints that need to work both with and without auth. """ try: - return await get_user(authorization) + return await get_user(authorization, x_api_key) except HTTPException as e: if e.status_code == 401: return None @@ -178,6 +186,37 @@ async def _handle_oss_auth(authorization: str | None) -> UserModel: ) +async def _handle_api_key_auth(api_key: str) -> UserModel: + """ + Handle authentication via X-API-Key header. + Returns the user who created the API key with the correct organization context. + """ + # Validate the API key + api_key_model = await db_client.validate_api_key(api_key) + + if not api_key_model: + raise HTTPException(status_code=401, detail="Invalid or expired API key") + + # API key must have a created_by user + if not api_key_model.created_by: + raise HTTPException(status_code=401, detail="API key has no associated user") + + # Get the user who created this API key + user = await db_client.get_user_by_id(api_key_model.created_by) + if not user: + raise HTTPException(status_code=401, detail="API key owner not found") + + # Set the organization context to the API key's organization + user.selected_organization_id = api_key_model.organization_id + + logger.debug( + f"Authenticated via API key: {api_key_model.key_prefix}... " + f"(user_id={user.id}, org_id={api_key_model.organization_id})" + ) + + return user + + async def create_user_configuration_with_mps_key( user_id: int, organization_id: int, user_provider_id: str ) -> Optional[UserConfiguration]: @@ -262,12 +301,13 @@ async def create_user_configuration_with_mps_key( async def get_superuser( authorization: Annotated[str | None, Header()] = None, + x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, ) -> UserModel: """ Dependency to check if the authenticated user is a superuser. Raises HTTPException if user is not authenticated or not a superuser. """ - user = await get_user(authorization) + user = await get_user(authorization, x_api_key) if not user.is_superuser: raise HTTPException( @@ -280,20 +320,24 @@ async def get_superuser( async def get_user_ws( websocket: WebSocket, token: str = Query(None), + api_key: str = Query(None, alias="api_key"), ) -> UserModel: """ WebSocket authentication dependency. - Uses token from query parameters for authentication. + Uses token or api_key from query parameters for authentication. """ - if not token: + if not token and not api_key: await websocket.close(code=1008, reason="Missing authentication token") raise HTTPException(status_code=401, detail="Missing authentication token") - # Use the same logic as get_user but with token from query - authorization = f"Bearer {token}" - try: - user = await get_user(authorization) + # API key takes precedence + if api_key: + user = await get_user(None, api_key) + else: + # Use the same logic as get_user but with token from query + authorization = f"Bearer {token}" + user = await get_user(authorization, None) return user except HTTPException as e: await websocket.close(code=1008, reason=e.detail) diff --git a/ui/src/components/flow/nodes/StartCall.tsx b/ui/src/components/flow/nodes/StartCall.tsx index 3e08e2a..865590c 100644 --- a/ui/src/components/flow/nodes/StartCall.tsx +++ b/ui/src/components/flow/nodes/StartCall.tsx @@ -1,11 +1,11 @@ import { NodeProps, NodeToolbar, Position } from "@xyflow/react"; -import { Edit, Play, Wrench } from "lucide-react"; +import { Edit, Play, PlusIcon, Trash2Icon, Wrench } from "lucide-react"; import { memo, useEffect, useMemo, useState } from "react"; import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext"; import { ToolBadges } from "@/components/flow/ToolBadges"; import { ToolSelector } from "@/components/flow/ToolSelector"; -import { FlowNodeData } from "@/components/flow/types"; +import { ExtractionVariable, FlowNodeData } from "@/components/flow/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -33,6 +33,12 @@ interface StartCallEditFormProps { setDelayedStart: (value: boolean) => void; delayedStartDuration: number; setDelayedStartDuration: (value: number) => void; + extractionEnabled: boolean; + setExtractionEnabled: (value: boolean) => void; + extractionPrompt: string; + setExtractionPrompt: (value: string) => void; + variables: ExtractionVariable[]; + setVariables: (vars: ExtractionVariable[]) => void; toolUuids: string[]; setToolUuids: (value: string[]) => void; } @@ -56,6 +62,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => { const [detectVoicemail, setDetectVoicemail] = useState(data.detect_voicemail ?? false); const [delayedStart, setDelayedStart] = useState(data.delayed_start ?? false); const [delayedStartDuration, setDelayedStartDuration] = useState(data.delayed_start_duration ?? 2); + const [extractionEnabled, setExtractionEnabled] = useState(data.extraction_enabled ?? false); + const [extractionPrompt, setExtractionPrompt] = useState(data.extraction_prompt ?? ""); + const [variables, setVariables] = useState(data.extraction_variables ?? []); const [toolUuids, setToolUuids] = useState(data.tool_uuids ?? []); // Compute if form has unsaved changes (only check prompt, name) @@ -76,6 +85,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => { detect_voicemail: detectVoicemail, delayed_start: delayedStart, delayed_start_duration: delayedStart ? delayedStartDuration : undefined, + extraction_enabled: extractionEnabled, + extraction_prompt: extractionPrompt, + extraction_variables: variables, tool_uuids: toolUuids.length > 0 ? toolUuids : undefined, }); setOpen(false); @@ -95,6 +107,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => { setDetectVoicemail(data.detect_voicemail ?? false); setDelayedStart(data.delayed_start ?? false); setDelayedStartDuration(data.delayed_start_duration ?? 3); + setExtractionEnabled(data.extraction_enabled ?? false); + setExtractionPrompt(data.extraction_prompt ?? ""); + setVariables(data.extraction_variables ?? []); setToolUuids(data.tool_uuids ?? []); } setOpen(newOpen); @@ -110,6 +125,9 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => { setDetectVoicemail(data.detect_voicemail ?? false); setDelayedStart(data.delayed_start ?? false); setDelayedStartDuration(data.delayed_start_duration ?? 3); + setExtractionEnabled(data.extraction_enabled ?? false); + setExtractionPrompt(data.extraction_prompt ?? ""); + setVariables(data.extraction_variables ?? []); setToolUuids(data.tool_uuids ?? []); } }, [data, open]); @@ -173,6 +191,12 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => { setDelayedStart={setDelayedStart} delayedStartDuration={delayedStartDuration} setDelayedStartDuration={setDelayedStartDuration} + extractionEnabled={extractionEnabled} + setExtractionEnabled={setExtractionEnabled} + extractionPrompt={extractionPrompt} + setExtractionPrompt={setExtractionPrompt} + variables={variables} + setVariables={setVariables} toolUuids={toolUuids} setToolUuids={setToolUuids} /> @@ -197,9 +221,42 @@ const StartCallEditForm = ({ setDelayedStart, delayedStartDuration, setDelayedStartDuration, + extractionEnabled, + setExtractionEnabled, + extractionPrompt, + setExtractionPrompt, + variables, + setVariables, toolUuids, setToolUuids, }: StartCallEditFormProps) => { + const handleVariableNameChange = (idx: number, value: string) => { + const newVars = [...variables]; + newVars[idx] = { ...newVars[idx], name: value }; + setVariables(newVars); + }; + + const handleVariableTypeChange = (idx: number, value: 'string' | 'number' | 'boolean') => { + const newVars = [...variables]; + newVars[idx] = { ...newVars[idx], type: value }; + setVariables(newVars); + }; + + const handleVariablePromptChange = (idx: number, value: string) => { + const newVars = [...variables]; + newVars[idx] = { ...newVars[idx], prompt: value }; + setVariables(newVars); + }; + + const handleRemoveVariable = (idx: number) => { + const newVars = variables.filter((_, i) => i !== idx); + setVariables(newVars); + }; + + const handleAddVariable = () => { + setVariables([...variables, { name: '', type: 'string', prompt: '' }]); + }; + return (
@@ -289,6 +346,69 @@ const StartCallEditForm = ({ )}
+ {/* Variable Extraction Section */} +
+ + + +
+ + {extractionEnabled && ( +
+ + +