feat: add telnyx webhook api key in telephony config (#270)

This commit is contained in:
Sabiha Khan 2026-05-09 18:03:42 +05:30 committed by GitHub
parent 45a81c88e0
commit 01c201bf09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 249 additions and 13 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({
<AppConfigProvider>
<Suspense fallback={<SpinLoader />}>
<UserConfigProvider>
<OnboardingProvider>
<PostHogIdentify />
<AppLayout>
{children}
</AppLayout>
<Toaster />
<ChatwootWidget />
</OnboardingProvider>
<TelephonyConfigWarningsProvider>
<OnboardingProvider>
<PostHogIdentify />
<AppLayout>
{children}
</AppLayout>
<Toaster />
<ChatwootWidget />
</OnboardingProvider>
</TelephonyConfigWarningsProvider>
</UserConfigProvider>
</Suspense>
</AppConfigProvider>

View file

@ -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<TelephonyConfigurationListItem[]>([]);
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() {
</Button>
</div>
{telnyxMissingWebhookPublicKeyCount > 0 && (
<div className="mb-6 rounded-md border border-amber-300 bg-amber-50 p-4 text-amber-900 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 shrink-0 mt-0.5" />
<div className="space-y-1 text-sm">
<p className="font-medium">Webhook public key not configured</p>
<p>
{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{" "}
<span className="font-medium">15 May 2026</span>. Copy your
public key from{" "}
<span className="whitespace-nowrap">
Mission Control Portal Keys &amp; Credentials Public Key
</span>{" "}
and paste it into the affected Telnyx configuration below.
</p>
</div>
</div>
</div>
)}
{loading ? (
<div className="grid gap-3">
<Skeleton className="h-24 w-full" />
@ -263,13 +301,13 @@ export default function TelephonyConfigurationsPage() {
open={createOpen}
onOpenChange={setCreateOpen}
existing={null}
onSaved={fetchItems}
onSaved={onSaved}
/>
<ConfigFormDialog
open={editOpen}
onOpenChange={setEditOpen}
existing={editTarget}
onSaved={fetchItems}
onSaved={onSaved}
/>
<AlertDialog

View file

@ -2,6 +2,7 @@
import type { Team } from "@stackframe/stack";
import {
AlertTriangle,
ArrowUpCircle,
AudioLines,
Brain,
@ -55,6 +56,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAppConfig } from "@/context/AppConfigContext";
import { useTelephonyConfigWarnings } from "@/context/TelephonyConfigWarningsContext";
import { useLatestReleaseVersion } from "@/hooks/useLatestReleaseVersion";
import type { LocalUser } from "@/lib/auth";
import { useAuth } from "@/lib/auth";
@ -73,6 +75,8 @@ export function AppSidebar() {
const { state, isMobile, setOpenMobile } = useSidebar();
const { provider, getSelectedTeam, logout, user } = useAuth();
const { config } = useAppConfig();
const { telnyxMissingWebhookPublicKeyCount } = useTelephonyConfigWarnings();
const hasTelephonyWarning = telnyxMissingWebhookPublicKeyCount > 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"
)}
>
<Link href={item.url} onClick={handleMobileNavClick}>
<Link href={item.url} onClick={handleMobileNavClick} className="relative">
<Icon className="h-4 w-4" />
<span className="sr-only">{item.title}</span>
{showWarningDot && (
<AlertTriangle
aria-hidden
className="absolute -right-0.5 -top-0.5 h-3 w-3 text-amber-500"
/>
)}
<span className="sr-only">
{item.title}
{showWarningDot && " — action required before 15 May 2026"}
</span>
</Link>
</SidebarMenuButton>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.title}</p>
<p>
{item.title}
{showWarningDot && (
<span className="block text-amber-600 dark:text-amber-400">
Action required before 15 May 2026
</span>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@ -229,6 +251,21 @@ export function AppSidebar() {
<Link href={item.url} onClick={handleMobileNavClick}>
<Icon className="h-4 w-4" />
<span>{item.title}</span>
{showWarningDot && (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<AlertTriangle
aria-label="Action required on a telephony configuration before 15 May 2026"
className="ml-auto h-3.5 w-3.5 text-amber-500"
/>
</TooltipTrigger>
<TooltipContent side="right">
<p>Action required before 15 May 2026</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</Link>
</SidebarMenuButton>
);

View file

@ -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<void>;
loading: boolean;
}
const TelephonyConfigWarningsContext = createContext<TelephonyConfigWarningsContextType>({
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<TelephonyConfigWarningsResponse>({
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 (
<TelephonyConfigWarningsContext.Provider
value={{
telnyxMissingWebhookPublicKeyCount: count,
refresh,
loading,
}}
>
{children}
</TelephonyConfigWarningsContext.Provider>
);
}
export function useTelephonyConfigWarnings() {
return useContext(TelephonyConfigWarningsContext);
}