From 01c201bf092bb1df94f2685c7aaed612af79415a Mon Sep 17 00:00:00 2001 From: Sabiha Khan <87858386+chewwbaka@users.noreply.github.com> Date: Sat, 9 May 2026 18:03:42 +0530 Subject: [PATCH] feat: add telnyx webhook api key in telephony config (#270) --- api/db/telephony_configuration_client.py | 28 +++++++ api/routes/organization.py | 32 ++++++++ .../telephony/providers/telnyx/__init__.py | 13 ++++ .../telephony/providers/telnyx/config.py | 9 +++ .../telephony/providers/telnyx/provider.py | 1 + ui/src/app/layout.tsx | 19 +++-- ui/src/app/telephony-configurations/page.tsx | 42 ++++++++++- ui/src/components/layout/AppSidebar.tsx | 43 ++++++++++- .../TelephonyConfigWarningsContext.tsx | 75 +++++++++++++++++++ 9 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 ui/src/context/TelephonyConfigWarningsContext.tsx diff --git a/api/db/telephony_configuration_client.py b/api/db/telephony_configuration_client.py index a545aeb..a8f7234 100644 --- a/api/db/telephony_configuration_client.py +++ b/api/db/telephony_configuration_client.py @@ -75,6 +75,34 @@ class TelephonyConfigurationClient(BaseDBClient): ) return list(result.scalars().all()) + async def count_telnyx_configs_missing_webhook_public_key( + self, organization_id: int + ) -> int: + """Count Telnyx configs in this org with no webhook_public_key in credentials. + + Used by the org-warnings endpoint to surface a UI nudge until customers + paste their portal-issued public key. + """ + async with self.async_session() as session: + result = await session.execute( + select(func.count(TelephonyConfigurationModel.id)).where( + TelephonyConfigurationModel.organization_id == organization_id, + TelephonyConfigurationModel.provider == "telnyx", + ( + TelephonyConfigurationModel.credentials.op("->>")( + "webhook_public_key" + ).is_(None) + ) + | ( + TelephonyConfigurationModel.credentials.op("->>")( + "webhook_public_key" + ) + == "" + ), + ) + ) + return int(result.scalar() or 0) + async def list_all_telephony_configurations_by_provider( self, provider: str ) -> List[TelephonyConfigurationModel]: diff --git a/api/routes/organization.py b/api/routes/organization.py index 55ba114..f60a413 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -87,6 +87,17 @@ class TelephonyProvidersMetadataResponse(BaseModel): providers: List[TelephonyProviderMetadata] +class TelephonyConfigWarningsResponse(BaseModel): + """Aggregated telephony-configuration warning counts for the user's org. + + Drives the page banner and nav badge that nudge customers to finish + optional-but-recommended configuration steps. Shape is a flat dict so + new warning types can be added without breaking the client. + """ + + telnyx_missing_webhook_public_key_count: int + + @router.get( "/telephony-providers/metadata", response_model=TelephonyProvidersMetadataResponse, @@ -127,6 +138,27 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)): return TelephonyProvidersMetadataResponse(providers=providers) +@router.get( + "/telephony-config-warnings", + response_model=TelephonyConfigWarningsResponse, +) +async def get_telephony_config_warnings(user: UserModel = Depends(get_user)): + """Return aggregated warning counts for the current org's telephony configs. + + Today this surfaces only Telnyx configs missing ``webhook_public_key``; + additional warning types should be added as new fields on the response. + """ + if not user.selected_organization_id: + raise HTTPException(status_code=400, detail="No organization selected") + + telnyx_missing = await db_client.count_telnyx_configs_missing_webhook_public_key( + user.selected_organization_id + ) + return TelephonyConfigWarningsResponse( + telnyx_missing_webhook_public_key_count=telnyx_missing, + ) + + def preserve_masked_fields(provider: str, request_dict: dict, existing: dict): """If the client re-submitted a masked sensitive field, restore the original.""" for field_name in _sensitive_fields(provider): diff --git a/api/services/telephony/providers/telnyx/__init__.py b/api/services/telephony/providers/telnyx/__init__.py index 558f787..ecbf33f 100644 --- a/api/services/telephony/providers/telnyx/__init__.py +++ b/api/services/telephony/providers/telnyx/__init__.py @@ -27,6 +27,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]: "provider": "telnyx", "api_key": value.get("api_key"), "connection_id": value.get("connection_id"), + "webhook_public_key": value.get("webhook_public_key"), "from_numbers": value.get("from_numbers", []), } @@ -124,6 +125,18 @@ _UI_METADATA = ProviderUIMetadata( "blank and we will auto-create one for you on save." ), ), + ProviderUIField( + name="webhook_public_key", + label="Webhook Public Key", + type="textarea", + required=False, + sensitive=False, + description=( + "Public key from Mission Control Portal → Keys & Credentials " + "→ Public Key. Used to verify Telnyx webhook signatures. " + "Without it, webhooks from Telnyx will be rejected." + ), + ), ProviderUIField( name="from_numbers", label="Phone Numbers", diff --git a/api/services/telephony/providers/telnyx/config.py b/api/services/telephony/providers/telnyx/config.py index c51e733..bce04b5 100644 --- a/api/services/telephony/providers/telnyx/config.py +++ b/api/services/telephony/providers/telnyx/config.py @@ -18,6 +18,14 @@ class TelnyxConfigurationRequest(BaseModel): "stored on the configuration." ), ) + webhook_public_key: Optional[str] = Field( + default=None, + description=( + "Webhook public key from Mission Control Portal → Keys & " + "Credentials → Public Key. Used to verify Telnyx webhook " + "signatures." + ), + ) # Phone numbers are managed via the dedicated phone-numbers endpoints; the # legacy /telephony-config POST shim still accepts them inline. from_numbers: List[str] = Field( @@ -31,4 +39,5 @@ class TelnyxConfigurationResponse(BaseModel): provider: Literal["telnyx"] = Field(default="telnyx") api_key: str # Masked connection_id: Optional[str] = None + webhook_public_key: Optional[str] = None from_numbers: List[str] diff --git a/api/services/telephony/providers/telnyx/provider.py b/api/services/telephony/providers/telnyx/provider.py index d127dfa..7ad684b 100644 --- a/api/services/telephony/providers/telnyx/provider.py +++ b/api/services/telephony/providers/telnyx/provider.py @@ -48,6 +48,7 @@ class TelnyxProvider(TelephonyProvider): def __init__(self, config: Dict[str, Any]): self.api_key = config.get("api_key") self.connection_id = config.get("connection_id") + self.webhook_public_key = config.get("webhook_public_key") self.from_numbers = config.get("from_numbers", []) if isinstance(self.from_numbers, str): diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 060fdd3..78a5011 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -11,6 +11,7 @@ import SpinLoader from "@/components/SpinLoader"; import { Toaster } from "@/components/ui/sonner"; import { AppConfigProvider } from "@/context/AppConfigContext"; import { OnboardingProvider } from "@/context/OnboardingContext"; +import { TelephonyConfigWarningsProvider } from "@/context/TelephonyConfigWarningsContext"; import { UserConfigProvider } from "@/context/UserConfigContext"; import { AuthProvider } from "@/lib/auth"; @@ -63,14 +64,16 @@ export default function RootLayout({ }> - - - - {children} - - - - + + + + + {children} + + + + + diff --git a/ui/src/app/telephony-configurations/page.tsx b/ui/src/app/telephony-configurations/page.tsx index 0534c8e..4d6b4bb 100644 --- a/ui/src/app/telephony-configurations/page.tsx +++ b/ui/src/app/telephony-configurations/page.tsx @@ -1,6 +1,7 @@ "use client"; import { + AlertTriangle, ChevronRight, Copy, ExternalLink, @@ -44,10 +45,15 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext"; import { useAuth } from "@/lib/auth"; export default function TelephonyConfigurationsPage() { const { user, getAccessToken, loading: authLoading } = useAuth(); + const { + telnyxMissingWebhookPublicKeyCount, + refresh: refreshWarnings, + } = useTelephonyConfigWarnings(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); @@ -75,6 +81,14 @@ export default function TelephonyConfigurationsPage() { } }, [authLoading, user, getAccessToken]); + // After a save (create/update), the backing config may have flipped between + // missing/present webhook_public_key — refresh the cached warning state so + // the page banner and nav badge update without a manual reload. + const onSaved = useCallback(async () => { + await fetchItems(); + await refreshWarnings(); + }, [fetchItems, refreshWarnings]); + useEffect(() => { fetchItems(); }, [fetchItems]); @@ -156,6 +170,30 @@ export default function TelephonyConfigurationsPage() { + {telnyxMissingWebhookPublicKeyCount > 0 && ( +
+
+ +
+

Webhook public key not configured

+

+ {telnyxMissingWebhookPublicKeyCount === 1 + ? "1 Telnyx configuration is" + : `${telnyxMissingWebhookPublicKeyCount} Telnyx configurations are`}{" "} + missing a webhook public key. Without it, Telnyx call status + updates and inbound calls will be rejected starting{" "} + 15 May 2026. Copy your + public key from{" "} + + Mission Control Portal → Keys & Credentials → Public Key + {" "} + and paste it into the affected Telnyx configuration below. +

+
+
+
+ )} + {loading ? (
@@ -263,13 +301,13 @@ export default function TelephonyConfigurationsPage() { open={createOpen} onOpenChange={setCreateOpen} existing={null} - onSaved={fetchItems} + onSaved={onSaved} /> 0; // On mobile the sidebar renders as a full-width sheet overlay, so treat it // as always "expanded" regardless of the desktop collapsed/expanded state. @@ -191,6 +195,8 @@ export function AppSidebar() { const SidebarLink = ({ item }: { item: typeof overviewSection[0] }) => { const isItemActive = isActive(item.url); const Icon = item.icon; + const showWarningDot = + item.url === "/telephony-configurations" && hasTelephonyWarning; if (effectiveState === "collapsed") { return ( @@ -204,14 +210,30 @@ export function AppSidebar() { isItemActive && "bg-accent text-accent-foreground" )} > - + - {item.title} + {showWarningDot && ( + + )} + + {item.title} + {showWarningDot && " — action required before 15 May 2026"} + -

{item.title}

+

+ {item.title} + {showWarningDot && ( + + Action required before 15 May 2026 + + )} +

@@ -229,6 +251,21 @@ export function AppSidebar() { {item.title} + {showWarningDot && ( + + + + + + +

Action required before 15 May 2026

+
+
+
+ )} ); diff --git a/ui/src/context/TelephonyConfigWarningsContext.tsx b/ui/src/context/TelephonyConfigWarningsContext.tsx new file mode 100644 index 0000000..cb1d094 --- /dev/null +++ b/ui/src/context/TelephonyConfigWarningsContext.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { client } from '@/client/client.gen'; +import { useAuth } from '@/lib/auth'; + +interface TelephonyConfigWarningsResponse { + telnyx_missing_webhook_public_key_count: number; +} + +interface TelephonyConfigWarningsContextType { + telnyxMissingWebhookPublicKeyCount: number; + refresh: () => Promise; + loading: boolean; +} + +const TelephonyConfigWarningsContext = createContext({ + telnyxMissingWebhookPublicKeyCount: 0, + refresh: async () => { }, + loading: false, +}); + +// One-shot fetch on first authenticated load. The state is cheap to compute +// server-side (one indexed JSONB query) but rendering it in both the page +// banner and the nav badge means we don't want to refetch on every route +// change. Page-level callers invalidate via refresh() after a save. +export function TelephonyConfigWarningsProvider({ children }: { children: ReactNode }) { + const auth = useAuth(); + const [count, setCount] = useState(0); + const [loading, setLoading] = useState(false); + const hasFetched = useRef(false); + + const doFetch = useCallback(async () => { + setLoading(true); + try { + const res = await client.get({ + url: '/api/v1/organizations/telephony-config-warnings', + }); + const data = res.data as TelephonyConfigWarningsResponse | undefined; + setCount(data?.telnyx_missing_webhook_public_key_count ?? 0); + } catch { + setCount(0); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (auth.loading || !auth.isAuthenticated || hasFetched.current) return; + hasFetched.current = true; + doFetch(); + }, [auth.loading, auth.isAuthenticated, doFetch]); + + const refresh = useCallback(async () => { + if (!auth.isAuthenticated) return; + await doFetch(); + }, [auth.isAuthenticated, doFetch]); + + return ( + + {children} + + ); +} + +export function useTelephonyConfigWarnings() { + return useContext(TelephonyConfigWarningsContext); +}