mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
feat: add telnyx webhook api key in telephony config
This commit is contained in:
parent
45a81c88e0
commit
bca12f5efa
9 changed files with 249 additions and 13 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
75
ui/src/context/TelephonyConfigWarningsContext.tsx
Normal file
75
ui/src/context/TelephonyConfigWarningsContext.tsx
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue