dograh/ui/src/app/telephony-configurations/page.tsx
2026-01-29 11:57:57 +05:30

805 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Plus, Trash2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet, saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost } from "@/client/sdk.gen";
import type {
CloudonixConfigurationRequest,
CloudonixConfigurationResponse,
TelephonyConfigurationResponse,
TwilioConfigurationRequest,
VobizConfigurationRequest,
VonageConfigurationRequest
} from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuth } from "@/lib/auth";
// TODO: Make UI provider-agnostic
interface TelephonyConfigForm {
provider: string;
// Twilio fields
account_sid?: string;
auth_token?: string;
// Vonage fields
application_id?: string;
private_key?: string;
api_key?: string;
api_secret?: string;
// Vobiz fields
auth_id?: string;
vobiz_auth_token?: string;
// Cloudonix fields
bearer_token?: string;
domain_id?: string;
// Common field - multiple phone numbers
from_numbers: string[];
}
export default function ConfigureTelephonyPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { user, getAccessToken, loading: authLoading } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [hasExistingConfig, setHasExistingConfig] = useState(false);
// Get returnTo parameter from URL
const returnTo = searchParams.get("returnTo") || "/workflow";
const {
register,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<TelephonyConfigForm>({
defaultValues: {
provider: "twilio",
from_numbers: [""],
},
});
const selectedProvider = watch("provider");
const fromNumbers = watch("from_numbers") || [""];
const addPhoneNumber = () => {
setValue("from_numbers", [...fromNumbers, ""]);
};
const removePhoneNumber = (index: number) => {
if (fromNumbers.length > 1) {
setValue("from_numbers", fromNumbers.filter((_, i) => i !== index));
}
};
const updatePhoneNumber = (index: number, value: string) => {
const newNumbers = [...fromNumbers];
newNumbers[index] = value;
setValue("from_numbers", newNumbers);
};
useEffect(() => {
// Don't fetch config while auth is still loading
if (authLoading || !user) {
return;
}
// Fetch existing configuration with masked sensitive fields
const fetchConfig = async () => {
try {
const accessToken = await getAccessToken();
const response = await getTelephonyConfigurationApiV1OrganizationsTelephonyConfigGet({
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.error) {
// Simple single provider config
if (response.data?.twilio) {
setHasExistingConfig(true);
setValue("provider", "twilio");
setValue("account_sid", response.data.twilio.account_sid);
setValue("auth_token", response.data.twilio.auth_token);
setValue("from_numbers", response.data.twilio.from_numbers?.length > 0 ? response.data.twilio.from_numbers : [""]);
} else if (response.data?.vonage) {
setHasExistingConfig(true);
setValue("provider", "vonage");
setValue("application_id", response.data.vonage.application_id);
setValue("private_key", response.data.vonage.private_key);
setValue("api_key", response.data.vonage.api_key || "");
setValue("api_secret", response.data.vonage.api_secret || "");
setValue("from_numbers", response.data.vonage.from_numbers?.length > 0 ? response.data.vonage.from_numbers : [""]);
} else if (response.data?.vobiz) {
setHasExistingConfig(true);
setValue("provider", "vobiz");
setValue("auth_id", response.data.vobiz.auth_id);
setValue("vobiz_auth_token", response.data.vobiz.auth_token);
setValue("from_numbers", response.data.vobiz.from_numbers?.length > 0 ? response.data.vobiz.from_numbers : [""]);
} else if ((response.data as TelephonyConfigurationResponse)?.cloudonix) {
const cloudonixConfig = (response.data as TelephonyConfigurationResponse).cloudonix as CloudonixConfigurationResponse;
setHasExistingConfig(true);
setValue("provider", "cloudonix");
setValue("bearer_token", cloudonixConfig.bearer_token);
setValue("domain_id", cloudonixConfig.domain_id);
setValue("from_numbers", cloudonixConfig.from_numbers?.length > 0 ? cloudonixConfig.from_numbers : [""]);
}
}
} catch (error) {
console.error("Failed to fetch config:", error);
}
};
fetchConfig();
}, [setValue, getAccessToken, authLoading, user]);
const onSubmit = async (data: TelephonyConfigForm) => {
setIsLoading(true);
try {
const accessToken = await getAccessToken();
// Build the request body based on provider
let requestBody:
| TwilioConfigurationRequest
| VonageConfigurationRequest
| VobizConfigurationRequest
| CloudonixConfigurationRequest;
const filteredNumbers = data.from_numbers.filter(n => n.trim() !== "");
// Validate phone numbers are provided (except for Cloudonix where optional)
if (data.provider !== "cloudonix" && filteredNumbers.length === 0) {
toast.error("At least one phone number is required");
setIsLoading(false);
return;
}
// Validate phone number format based on provider
const twilioPattern = /^\+[1-9]\d{1,14}$/;
const vonageVobizPattern = /^[1-9]\d{1,14}$/;
const cloudonixPattern = /^\+?[1-9]\d{1,14}$/;
let pattern: RegExp;
let formatMessage: string;
if (data.provider === "twilio") {
pattern = twilioPattern;
formatMessage = "with + prefix (e.g., +1234567890)";
} else if (data.provider === "cloudonix") {
pattern = cloudonixPattern;
formatMessage = "(e.g., +1234567890)";
} else {
pattern = vonageVobizPattern;
formatMessage = "without + prefix (e.g., 14155551234)";
}
const invalidNumbers = filteredNumbers.filter(n => !pattern.test(n));
if (invalidNumbers.length > 0) {
toast.error(`Invalid phone number format. Please enter numbers ${formatMessage}`);
setIsLoading(false);
return;
}
if (data.provider === "twilio") {
requestBody = {
provider: data.provider,
from_numbers: filteredNumbers,
account_sid: data.account_sid,
auth_token: data.auth_token,
} as TwilioConfigurationRequest;
} else if (data.provider === "vonage") {
requestBody = {
provider: data.provider,
from_numbers: filteredNumbers,
application_id: data.application_id,
private_key: data.private_key,
api_key: data.api_key || undefined,
api_secret: data.api_secret || undefined,
} as VonageConfigurationRequest;
} else if (data.provider === "vobiz") {
requestBody = {
provider: data.provider,
from_numbers: filteredNumbers,
auth_id: data.auth_id,
auth_token: data.vobiz_auth_token,
} as VobizConfigurationRequest;
} else {
// Cloudonix
requestBody = {
provider: data.provider,
from_numbers: filteredNumbers,
bearer_token: data.bearer_token!,
domain_id: data.domain_id!,
} as CloudonixConfigurationRequest;
}
const response = await saveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPost({
headers: { Authorization: `Bearer ${accessToken}` },
body: requestBody
});
if (response.error) {
const errorMsg = typeof response.error === 'string'
? response.error
: (response.error as { detail?: string })?.detail || "Failed to save configuration";
throw new Error(errorMsg);
}
toast.success("Telephony configuration saved successfully");
// Redirect back to the page that sent us here
router.push(returnTo);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to save configuration"
);
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">Configure Telephony</h1>
<p className="text-muted-foreground mb-6">
Set up your telephony provider to make phone calls
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<Card className="h-full">
<CardHeader>
<CardTitle>
{selectedProvider === "twilio"
? "Twilio"
: selectedProvider === "vonage"
? "Vonage"
: selectedProvider === "vobiz"
? "Vobiz"
: "Cloudonix"}{" "}
Setup Guide
</CardTitle>
<CardDescription>
{selectedProvider === "cloudonix" ? (
<>
Cloudonix is an AI Connectivity platform, enabling you to connect Dograh to any SIP product or SIP Telephony Provider.<br/><br/>
<iframe
style={{ border: 0 }}
width="100%"
height="450"
src="https://www.youtube.com/embed/qLKX0F99jpU?si=a_sF9ijSJdV4OdG-"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/><br/><br/>
Visit{" "}
<a
href="https://cockpit.cloudonix.io/onboarding?affiliate=DOGRAH"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
https://cloudonix.com
</a>{" "}
for more information about Cloudonix services and pricing.Visit{" "}
<a
href="https://developers.cloudonix.com"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
https://developers.cloudonix.com
</a>{" "}
for developer documentation and API reference.
</>
) : selectedProvider === "vobiz" ? (
<>
Vobiz is a telephony provider. Visit their documentation
for setup instructions.
</>
) : (
<>
Watch this video to learn how to setup{" "}
{selectedProvider === "twilio" ? "Twilio" : "Vonage"}
</>
)}
</CardDescription>
</CardHeader>
<CardContent>
{selectedProvider === "twilio" || selectedProvider === "vonage" ? (
<div className="aspect-video">
<iframe
style={{ border: 0 }}
width="100%"
height="100%"
src={
selectedProvider === "twilio"
? "https://www.tella.tv/video/cmgbvzkrt00jk0clacu16blm3/embed?b=0&title=1&a=1&loop=0&t=0&muted=0&wt=0"
: "https://www.tella.tv/video/configuring-telephony-on-dograh-with-vonage-3wvo/embed?b=0&title=1&a=1&loop=0&t=0&muted=0&wt=0"
}
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
</div>
) : selectedProvider === "vobiz" ? (
<div className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">Getting Started with Vobiz:</h4>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Sign up for a Vobiz account</li>
<li>Get your Auth ID from the Vobiz dashboard</li>
<li>Generate an Auth Token</li>
<li>Configure phone numbers in your Vobiz account</li>
<li>Enter your credentials below</li>
</ol>
</div>
<div className="bg-muted border border-border rounded p-3">
<p className="text-sm">
<strong>Note:</strong> Vobiz provides cloud-based telephony services
with global reach and competitive pricing.
</p>
</div>
</div>
) : (
<div className="space-y-4 text-sm">
<div>
<h4 className="font-semibold mb-2">Getting Started with Cloudonix:</h4>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Sign up for a Cloudonix account at https://cloudonix.com</li>
<li>Create an <i>API token</i> for your Cloudonix domain</li>
<li>Configure your Cloudoinx <i>API Token</i> and <i>Cloudonix Domain Name</i> in Dograh</li>
<li>Configure an optional outbound phone number for your Dograh agent</li>
</ol>
</div>
<div className="bg-muted border border-border rounded p-3">
<p className="text-sm">
<strong>Note:</strong> Cloudonix uses Bearer token
authentication and is fully TwiML-compatible for voice
applications.
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
<div>
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Provider Configuration</CardTitle>
<CardDescription>
Configure your telephony provider settings
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Provider Selection */}
<div className="space-y-2">
<Label>Provider</Label>
<Select
value={selectedProvider}
onValueChange={(value) => setValue("provider", value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="twilio">Twilio</SelectItem>
<SelectItem value="vonage">Vonage</SelectItem>
<SelectItem value="vobiz">Vobiz</SelectItem>
<SelectItem value="cloudonix">Cloudonix</SelectItem>
</SelectContent>
</Select>
{hasExistingConfig && (
<p className="text-sm text-amber-600">
Switching providers will require entering new credentials
</p>
)}
</div>
{/* Twilio-specific fields */}
{selectedProvider === "twilio" && (
<>
<div className="space-y-2">
<Label htmlFor="account_sid">Account SID</Label>
<Input
id="account_sid"
autoComplete="username"
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
{...register("account_sid", {
required: "Account SID is required",
})}
/>
{errors.account_sid && (
<p className="text-sm text-red-500">
{errors.account_sid.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="auth_token">Auth Token</Label>
<Input
id="auth_token"
type="password"
autoComplete="current-password"
placeholder={
hasExistingConfig
? "Leave masked to keep existing"
: "Enter your auth token"
}
{...register("auth_token", {
required: !hasExistingConfig
? "Auth token is required"
: false,
})}
/>
{errors.auth_token && (
<p className="text-sm text-red-500">
{errors.auth_token.message}
</p>
)}
</div>
<div className="space-y-2">
<Label>CLI Phone Numbers</Label>
{fromNumbers.map((number, index) => (
<div key={index} className="flex gap-2">
<Input
autoComplete="tel"
placeholder="+1234567890"
value={number}
onChange={(e) => updatePhoneNumber(index, e.target.value)}
/>
{fromNumbers.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^\+[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500">
Enter valid phone numbers with country code (e.g., +1234567890)
</p>
)}
</div>
</>
)}
{/* Vonage-specific fields */}
{selectedProvider === "vonage" && (
<>
<div className="space-y-2">
<Label htmlFor="application_id">Application ID</Label>
<Input
id="application_id"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
{...register("application_id", {
required: selectedProvider === "vonage" ? "Application ID is required" : false,
})}
/>
{errors.application_id && (
<p className="text-sm text-red-500">
{errors.application_id.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="private_key">Private Key</Label>
<textarea
id="private_key"
className="w-full min-h-[100px] px-3 py-2 text-sm border border-input bg-background rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----"
{...register("private_key", {
required: selectedProvider === "vonage" && !hasExistingConfig
? "Private key is required"
: false,
})}
/>
{errors.private_key && (
<p className="text-sm text-red-500">
{errors.private_key.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="api_key">API Key (Optional)</Label>
<Input
id="api_key"
placeholder="Optional - for some operations"
{...register("api_key")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api_secret">API Secret (Optional)</Label>
<Input
id="api_secret"
type="password"
placeholder="Optional - for webhook verification"
{...register("api_secret")}
/>
</div>
<div className="space-y-2">
<Label>CLI Phone Numbers</Label>
{fromNumbers.map((number, index) => (
<div key={index} className="flex gap-2">
<Input
autoComplete="tel"
placeholder="14155551234 (no + prefix for Vonage)"
value={number}
onChange={(e) => updatePhoneNumber(index, e.target.value)}
/>
{fromNumbers.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500">
Enter valid phone numbers without + prefix (e.g., 14155551234)
</p>
)}
</div>
</>
)}
{/* Vobiz-specific fields */}
{selectedProvider === "vobiz" && (
<>
<div className="space-y-2">
<Label htmlFor="auth_id">Auth ID</Label>
<Input
id="auth_id"
placeholder="MA_SYQRLN1K"
{...register("auth_id", {
required: selectedProvider === "vobiz" ? "Auth ID is required" : false,
})}
/>
{errors.auth_id && (
<p className="text-sm text-red-500">
{errors.auth_id.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vobiz_auth_token">Auth Token</Label>
<Input
id="vobiz_auth_token"
type="password"
autoComplete="current-password"
placeholder={
hasExistingConfig
? "Leave masked to keep existing"
: "Enter your auth token"
}
{...register("vobiz_auth_token", {
required: selectedProvider === "vobiz" && !hasExistingConfig
? "Auth token is required"
: false,
})}
/>
{errors.vobiz_auth_token && (
<p className="text-sm text-red-500">
{errors.vobiz_auth_token.message}
</p>
)}
</div>
<div className="space-y-2">
<Label>CLI Phone Numbers</Label>
{fromNumbers.map((number, index) => (
<div key={index} className="flex gap-2">
<Input
autoComplete="tel"
placeholder="14155551234 (no + prefix for Vobiz)"
value={number}
onChange={(e) => updatePhoneNumber(index, e.target.value)}
/>
{fromNumbers.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500">
Enter valid phone numbers without + prefix (e.g., 14155551234)
</p>
)}
</div>
</>
)}
{/* Cloudonix-specific fields */}
{selectedProvider === "cloudonix" && (
<>
<div className="space-y-2">
<Label htmlFor="bearer_token">Domain API Token (eg. XI-....)</Label>
<Input
id="bearer_token"
type="password"
autoComplete="current-password"
placeholder={
hasExistingConfig
? "Leave masked to keep existing"
: "Enter your bearer token"
}
{...register("bearer_token", {
required:
selectedProvider === "cloudonix" && !hasExistingConfig
? "Domain API token is required"
: false,
})}
/>
{errors.bearer_token && (
<p className="text-sm text-red-500">
{errors.bearer_token.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="domain_id">Domain Name or UUID</Label>
<Input
id="domain_id"
placeholder="your-domain-id"
{...register("domain_id", {
required:
selectedProvider === "cloudonix"
? "Domain Name or UUID is required"
: false,
})}
/>
{errors.domain_id && (
<p className="text-sm text-red-500">
{errors.domain_id.message}
</p>
)}
</div>
<div className="space-y-2">
<Label>CLI Phone Numbers (Optional)</Label>
{fromNumbers.map((number, index) => (
<div key={index} className="flex gap-2">
<Input
autoComplete="tel"
placeholder="+1234567890"
value={number}
onChange={(e) => updatePhoneNumber(index, e.target.value)}
/>
{fromNumbers.length > 1 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => removePhoneNumber(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={addPhoneNumber}
>
<Plus className="h-4 w-4 mr-2" />
Add Phone Number
</Button>
{fromNumbers.some(n => n.trim() !== "" && !/^\+?[1-9]\d{1,14}$/.test(n)) && (
<p className="text-sm text-red-500">
Enter valid phone numbers (e.g., +1234567890)
</p>
)}
<p className="text-xs text-muted-foreground">
Phone numbers can be fetched from Cloudonix DNIDs if not
specified
</p>
</div>
</>
)}
<div className="pt-4 space-y-3">
<Button
type="submit"
className="w-full"
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save Configuration"}
</Button>
<div className="text-center">
<p className="text-xs text-muted-foreground">
Configure inbound calling?{" "}
<a
href="https://docs.dograh.com/integrations/telephony/inbound"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 dark:text-blue-400 hover:underline"
>
View documentation
</a>
</p>
</div>
</div>
</CardContent>
</Card>
</form>
</div>
</div>
</div>
</div>
);
}