Compare commits

...

24 Commits

Author SHA1 Message Date
Hoan Luu Huu
9fd847015e rime labs support new voices (#483) 2025-02-07 07:22:45 -05:00
Hoan Luu Huu
c237b7e7f2 support voxist stt (#480) 2025-02-05 08:33:12 -05:00
Hoan Luu Huu
48b3e14076 support recents call dropdown filter to have yesterday option (#463)
* support recents call dropdown filter to have yesterday option

* fix review comment
2025-01-17 09:39:47 -05:00
rammohan-y
db08badb9b Feat/473: TypeaheadSelector component (#474)
* feat/473: introduced typeadhed-selector component and used in ApplicationSelect, AccountSelect components

* making selectedIndex as 0 if it is below 0

* feat/473: fixed styles

* made carrier selector as typeahead selector

* converted account-filter to use typeahead-selector

* styles refactoring

* updated test cases

* added typeahead test case

* added more test cases for typeahead account filter

* feat/473: introduced typeadhed-selector component and used in ApplicationSelect, AccountSelect components

* making selectedIndex as 0 if it is below 0

* feat/473: fixed styles

* made carrier selector as typeahead selector

* converted account-filter to use typeahead-selector

* styles refactoring

* updated test cases

* added typeahead test case

* added more test cases for typeahead account filter
2025-01-16 08:18:40 -05:00
Dave Horton
423c8de513 update deps 2025-01-14 10:47:51 -05:00
Hoan Luu Huu
668a642b09 support custom tts streaming (#476)
* support custom tts streaming

* support custom tts streaming vendor

* fix review comment
2025-01-14 08:36:46 -05:00
Hoan Luu Huu
411eb4ece8 support tts cartesia (#471) 2024-12-19 18:41:53 -05:00
Hoan Luu Huu
8d4ffddddc support carrier dtmf type selection (#465)
* support carrier dtmf type selection

* fix review comments
2024-11-26 20:24:26 -05:00
rammohan-y
294b7b2058 feat/940: Removed last_used column from speech services (#460) 2024-11-22 07:59:13 -05:00
Hoan Luu Huu
e32664d0e0 fixed recents call show call from another account (#467) 2024-11-13 21:25:48 -05:00
Hoan Luu Huu
ae8b4ae124 support add google voice cloning key (#461)
* support add google voice cloning key

* fix bugs on google voice cloning

* wip

* fixed google custom voice
2024-11-04 07:10:59 -05:00
Hoan Luu Huu
a586771ea6 support playht3.0 languages (#459)
* support playht3.0 languages

* wip
2024-10-16 07:21:36 -04:00
Hoan Luu Huu
7aaea04d3c support speechmatics speechcredential (#458)
* support speechmatics

* support speechmatics regions

* add env VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS
2024-10-11 08:58:23 -04:00
Hoan Luu Huu
f1d2ed8abd allow system information contains log level and account has enable_debug_log (#457)
* sip gateways support inbound pad crypto

* allow system information contains log level and account has enable_debug_log

* only admin can set account log level
2024-10-07 09:51:38 -04:00
Hoan Luu Huu
d7d61a769d sorting application name in applications index, phone number application selection (#455)
* sip gateways support inbound pad crypto

* sorting application name in applications index, phone number application selection
2024-10-03 10:23:17 -04:00
Hoan Luu Huu
c9da7946f3 sip gateways support inbound pad crypto (#452) 2024-09-11 09:37:02 +01:00
Hoan Luu Huu
5755cd8886 support admin settings private network CIDR (#447)
* support admin settings private network CIDR

* support admin settings private network CIDR

* fix review comment
2024-08-14 18:30:24 -04:00
Hoan Luu Huu
786327a0b9 support deepgram on-prem (#444) 2024-08-07 07:24:35 -04:00
Hoan Luu Huu
2c390715d8 Feat/rid of env (#443)
* Get rid of VITE_API_BASE_URL

* wip

* wip
2024-07-15 08:58:22 -04:00
Hoan Luu Huu
dcdc2c0808 add use sips scheme to outbound tls gateway (#439)
* add use sips scheme to outbound tls gateway

* update license
2024-06-15 09:13:04 -04:00
Hoan Luu Huu
a3c48e7efb support verbio speech (#434) 2024-05-29 07:57:05 -04:00
Hoan Luu Huu
6b9167e6b8 support speech aws polly by roleArn (#428)
* support speech aws polly by roleArn

* add 3 types of aws poly credential

* wip
2024-05-02 07:58:02 -04:00
Hoan Luu Huu
e7889e1ad3 fix send OPTIONS ping mess the layout (#431) 2024-04-30 07:43:44 -04:00
Hoan Luu Huu
1111e93918 Send options ping sip gateway (#363)
* wip

* up options ping

* update review comments
2024-04-15 07:19:35 -04:00
29 changed files with 1745 additions and 300 deletions

6
.env
View File

@@ -1,5 +1,5 @@
VITE_API_BASE_URL=http://127.0.0.1:3000/v1
VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
#VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## enables choosing units and lisenced account call limits
# VITE_APP_ENABLE_ACCOUNT_LIMITS_ALL=true
@@ -25,4 +25,6 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## Base url for jambomz webapp
#VITE_APP_BASE_URL="http://jambonz.one"
## Strip publishable key
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"
## ignore some specific speech vendors, defined by ADDITIONAL_SPEECH_VENDORS constant
# VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS=true

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Drachtio Communications Services, LLC
Copyright (c) 2018-2024 FirstFive8, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jambonz-webapp",
"version": "0.9.0",
"version": "0.9.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jambonz-webapp",
"version": "0.9.0",
"version": "0.9.3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "0.9.0",
"version": "0.9.3",
"license": "MIT",
"type": "module",
"engines": {

View File

@@ -1,6 +1,9 @@
import { hasValue } from "src/utils";
import type {
CartesiaOptions,
Currency,
ElevenLabsOptions,
GoogleCustomVoice,
LimitField,
LimitUnitOption,
PasswordSettings,
@@ -12,6 +15,7 @@ import type {
WebHook,
WebhookOption,
} from "./types";
import { Vendor } from "src/vendor/types";
/** This window object is serialized and injected at docker runtime */
/** The API url is constructed with the docker containers `ip:port` */
@@ -28,6 +32,7 @@ interface JambonzWindowObject {
BASE_URL: string;
DEFAULT_SERVICE_PROVIDER_SID: string;
STRIPE_PUBLISHABLE_KEY: string;
DISABLE_ADDITIONAL_SPEECH_VENDORS: string;
}
declare global {
@@ -37,8 +42,11 @@ declare global {
}
/** https://vitejs.dev/guide/env-and-mode.html#env-files */
export const API_BASE_URL =
const CONFIGURED_API_BASE_URL =
window.JAMBONZ?.API_BASE_URL || import.meta.env.VITE_API_BASE_URL;
export const API_BASE_URL = hasValue(CONFIGURED_API_BASE_URL)
? CONFIGURED_API_BASE_URL
: `${window.location.protocol}//${window.location.hostname}/api/v1`;
/** Serves mock API responses from a local dev API server */
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
@@ -72,6 +80,13 @@ export const DISABLE_CALL_RECORDING: boolean =
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
/** Disable additional speech vendors */
export const DISABLE_ADDITIONAL_SPEECH_VENDORS: boolean =
window.JAMBONZ?.DISABLE_ADDITIONAL_SPEECH_VENDORS === "true" ||
JSON.parse(
import.meta.env.VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS || "false",
);
export const DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
@@ -201,10 +216,31 @@ export const AUDIO_FORMAT_OPTIONS = [
},
];
export const LOG_LEVEL_OPTIONS = [
{
name: "Info",
value: "info",
},
{
name: "Debug",
value: "debug",
},
];
export const DEFAULT_ELEVENLABS_MODEL = "eleven_multilingual_v2";
export const DEFAULT_WHISPER_MODEL = "tts-1";
// VERBIO
export const VERBIO_STT_MODELS = [
{ name: "V1", value: "V1" },
{ name: "V2", value: "V2" },
];
export const DEFAULT_VERBIO_MODEL = "V1";
export const ADDITIONAL_SPEECH_VENDORS: Lowercase<Vendor>[] = ["speechmatics"];
// Google Custom Voice reported usage options
export const DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = "REALTIME";
@@ -213,6 +249,13 @@ export const GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = [
{ name: "REALTIME", value: "REALTIME" },
{ name: "OFFLINE", value: "OFFLINE" },
];
export const DEFAULT_GOOGLE_CUSTOM_VOICE: GoogleCustomVoice = {
name: "",
reported_usage: DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
use_voice_cloning_key: 0,
voice_cloning_key_file: null,
};
// ElevenLabs options
export const DEFAULT_ELEVENLABS_OPTIONS: Partial<ElevenLabsOptions> = {
optimize_streaming_latency: 3,
@@ -240,6 +283,12 @@ export const DEFAULT_PLAYHT_OPTIONS: Partial<PlayHTOptions> = {
style_guidance: 20,
text_guidance: 1,
};
// Cartesia options
export const DEFAULT_CARTESIA_OPTIONS: Partial<CartesiaOptions> = {
speed: 0.0,
emotion: "positivity:high",
};
/** Password Length options */
export const PASSWORD_MIN = 8;
@@ -253,6 +302,7 @@ export const PASSWORD_LENGTHS_OPTIONS = Array(13)
/** List view filters */
export const DATE_SELECTION = [
{ name: "today", value: "today" },
{ name: "yesterday", value: "yesterday" },
{ name: "last 7d", value: "7" },
{ name: "last 14d", value: "14" },
{ name: "last 30d", value: "30" },
@@ -271,6 +321,11 @@ export const USER_SCOPE_SELECTION: SelectorOptions[] = [
{ name: "Account", value: "account" },
];
export const DTMF_TYPE_SELECTION: SelectorOptions[] = [
{ name: "RFC 2833", value: "rfc2833" },
{ name: "Tones", value: "tones" },
];
/** Available webhook methods */
export const WEBHOOK_METHODS: WebhookOption[] = [
{

View File

@@ -225,6 +225,16 @@ export const getBlob = (url: string) => {
});
};
export const postBlobFetch = <Type>(url: string, formdata?: FormData) => {
return fetchTransport<Type>(url, {
method: "POST",
body: formdata,
headers: {
Authorization: `Bearer ${getToken()}`,
},
});
};
/** Simple wrappers for fetchTransport calls to any API, :GET, :POST, :PUT, :DELETE */
export const getFetch = <Type>(url: string) => {
@@ -492,6 +502,15 @@ export const postGoogleCustomVoice = (payload: Partial<GoogleCustomVoice>) => {
payload,
);
};
export const postGoogleVoiceCloningKey = (sid: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return postBlobFetch<EmptyResponse>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}/VoiceCloningKey`,
formData,
);
};
/** Named wrappers for `putFetch` */
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
@@ -875,6 +894,7 @@ export const getSpeechSupportedLanguagesAndVoices = (
sid: string | undefined,
vendor: string,
label: string,
create_new: boolean = false,
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -883,7 +903,7 @@ export const getSpeechSupportedLanguagesAndVoices = (
: `${API_SERVICE_PROVIDERS}/${sid}`) +
`/SpeechCredentials/speech/supportedLanguagesAndVoices?vendor=${vendor}${
label ? `&label=${label}` : ""
}`;
}${create_new ? "&create_new=true" : ""}`;
return getFetch<SpeechSupportedLanguagesAndVoices>(apiUrl);
};

View File

@@ -26,6 +26,8 @@ export interface LimitUnitOption {
/** User roles / permissions */
export type UserScopes = "admin" | "service_provider" | "account";
export type LogLevel = "info" | "debug";
export type UserPermissions =
| "VIEW_ONLY"
| "PROVISION_SERVICES"
@@ -122,9 +124,11 @@ export interface ForgotPassword {
}
export interface SystemInformation {
domain_name: string;
sip_domain_name: string;
monitoring_domain_name: string;
domain_name: null | string;
sip_domain_name: null | string;
monitoring_domain_name: null | string;
private_network_cidr: null | string;
log_level: LogLevel;
}
export interface TtsCache {
@@ -261,6 +265,7 @@ export interface Account {
device_to_call_ratio?: number;
trial_end_date?: null | string;
is_active: boolean;
enable_debug_log: boolean;
}
export interface Product {
@@ -376,7 +381,10 @@ export interface GoogleCustomVoice {
speech_credential_sid?: string;
name: string;
reported_usage: string;
model: string;
model?: string;
use_voice_cloning_key: number;
voice_cloning_key?: string | null;
voice_cloning_key_file?: File | null;
}
export interface SpeechCredential {
@@ -390,6 +398,7 @@ export interface SpeechCredential {
region: null | string;
aws_region: null | string;
api_key: null | string;
role_arn: null | string;
user_id: null | string;
access_key_id: null | string;
secret_access_key: null | string;
@@ -401,6 +410,7 @@ export interface SpeechCredential {
custom_stt_endpoint_url: null | string;
custom_stt_endpoint: null | string;
client_id: null | string;
client_secret: null | string;
secret: null | string;
nuance_tts_uri: null | string;
nuance_stt_uri: null | string;
@@ -413,14 +423,18 @@ export interface SpeechCredential {
auth_token: null | string;
custom_stt_url: null | string;
custom_tts_url: null | string;
custom_tts_streaming_url: null | string;
label: null | string;
cobalt_server_uri: null | string;
model_id: null | string;
voice_engine: null | string;
engine_version: null | string;
model: null | string;
options: null | string;
deepgram_stt_uri: null | string;
deepgram_tts_uri: null | string;
deepgram_stt_use_tls: number;
speechmatics_stt_uri: null | string;
}
export interface Alert {
@@ -438,6 +452,8 @@ export interface CarrierRegisterStatus {
callId: null | string;
}
export type DtmfType = "rfc2833" | "tones" | "info";
export interface Carrier {
voip_carrier_sid: string;
name: string;
@@ -464,6 +480,7 @@ export interface Carrier {
smpp_inbound_password: null | string;
smpp_enquire_link_interval: number;
register_status: CarrierRegisterStatus;
dtmf_type: DtmfType;
}
export interface PredefinedCarrier extends Carrier {
@@ -485,6 +502,8 @@ export interface SipGateway extends Gateway {
protocol?: string;
port: number | null;
pad_crypto?: boolean;
send_options_ping?: boolean;
use_sips_scheme?: boolean;
}
export interface SmppGateway extends Gateway {
@@ -736,3 +755,26 @@ export interface RimelabsOptions {
speedAlpha: number;
reduceLatency: boolean;
}
export type CartesiaEmotions =
| "anger:lowest"
| "anger:low"
| "anger:high"
| "anger:highest"
| "positivity:lowest"
| "positivity:low"
| "positivity:high"
| "positivity:highest"
| "surprise:lowest"
| "surprise:high"
| "surprise:highest"
| "sadness:lowest"
| "sadness:low"
| "curiosity:low"
| "curiosity:high"
| "curiosity:highest";
export interface CartesiaOptions {
speed: number;
emotion: CartesiaEmotions;
}

View File

@@ -43,32 +43,63 @@ describe("<AccountFilter>", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Default value is properly set to first option */
cy.get("select").should("have.value", accountsSorted[0].account_sid);
cy.get("input").should("have.value", accountsSorted[0].name);
});
it("updates value onChange", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Assert onChange value updates */
cy.get("select").select(accountsSorted[1].account_sid);
cy.get("select").should("have.value", accountsSorted[1].account_sid);
cy.get("input").clear();
cy.get("input").type(accountsSorted[1].name);
cy.get("input").should("have.value", accountsSorted[1].name);
});
it("manages the focused state", () => {
cy.mount(<AccountFilterTestWrapper />);
/** Test the `focused` state className (applied onFocus) */
cy.get("select").select(accountsSorted[1].account_sid);
cy.get(".account-filter").should("have.class", "focused");
cy.get("select").blur();
cy.get(".account-filter").should("not.have.class", "focused");
cy.get("input").clear();
cy.get("input").type(accountsSorted[1].name);
cy.get("input").parent().should("have.class", "focused");
cy.get("input").blur();
cy.get("input").parent().should("not.have.class", "focused");
});
it("renders with default option", () => {
/** Test with the `defaultOption` prop */
cy.mount(<AccountFilterTestWrapper defaultOption />);
/** No default value is set when this prop is present */
cy.get("select").should("have.value", "");
cy.get("input").should("have.value", "All accounts");
});
it("verify the typeahead dropdown", () => {
/** Test by typing cus then custom account is selected */
cy.mount(<AccountFilterTestWrapper defaultOption />);
cy.get("input").clear();
cy.get("input").type("cus");
cy.get("div#account_filter-option-1").should("have.text", "custom account");
});
it("handles Enter key press", () => {
cy.mount(<AccountFilterTestWrapper />);
cy.get("input").clear();
cy.get("input").type("cus{enter}");
cy.get("input").should("have.value", "custom account");
});
it("navigates down and up with arrow keys", () => {
cy.mount(<AccountFilterTestWrapper />);
cy.get("input").clear();
// Press arrow down to move to the first option
cy.get("input").type("{downarrow}");
cy.get("input").type("{enter}");
cy.get("input").should("have.value", "default account");
// Press up to move to the previous option
cy.get("input").type("{uparrow}");
cy.get("input").type("{uparrow}");
cy.get("input").type("{enter}");
cy.get("input").should("have.value", "custom account");
});
});

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import React, { useEffect } from "react";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
import { TypeaheadSelector } from "src/components/forms";
import type { Account } from "src/api/types";
import { hasLength, sortLocaleName } from "src/utils";
@@ -22,12 +22,10 @@ export const AccountFilter = ({
accounts,
defaultOption,
}: AccountFilterProps) => {
const [focus, setFocus] = useState(false);
const classes = {
smsel: true,
"smsel--filter": true,
"account-filter": true,
focused: focus,
};
useEffect(() => {
@@ -36,41 +34,30 @@ export const AccountFilter = ({
}
}, [accounts, defaultOption, setAccountSid]);
const options = [
...(defaultOption ? [{ name: "All accounts", value: "" }] : []),
...(hasLength(accounts)
? accounts.sort(sortLocaleName).map((acct) => ({
name: acct.name,
value: acct.account_sid,
}))
: []),
];
return (
<div className={classNames(classes)}>
{label && <label htmlFor="account_filter">{label}:</label>}
<div>
<select
id="account_filter"
name="account_filter"
value={accountSid}
onChange={(e) => {
setAccountSid(e.target.value);
setAccountFilter(e.target.value);
}}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
>
{defaultOption ? (
<option value="">All accounts</option>
) : (
accounts &&
!accounts.length && <option value="">No accounts</option>
)}
{hasLength(accounts) &&
accounts.sort(sortLocaleName).map((acct) => {
return (
<option key={acct.account_sid} value={acct.account_sid}>
{acct.name}
</option>
);
})}
</select>
<span>
<Icons.ChevronUp />
<Icons.ChevronDown />
</span>
</div>
<TypeaheadSelector
id="account_filter"
name="account_filter"
value={accountSid}
options={options}
className="small"
onChange={(e) => {
setAccountSid(e.target.value);
setAccountFilter(e.target.value);
}}
/>
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { Selector } from "src/components/forms";
import { TypeaheadSelector } from "src/components/forms";
import type { Account } from "src/api/types";
import { hasLength } from "src/utils";
@@ -16,7 +16,7 @@ type AccountSelectProps = {
disabled?: boolean;
};
type SelectorRef = HTMLSelectElement;
type SelectorRef = HTMLInputElement;
export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
(
@@ -41,7 +41,7 @@ export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
<label htmlFor="account_sid">
{label} {required && <span>*</span>}
</label>
<Selector
<TypeaheadSelector
ref={ref}
id="account_sid"
name="account_sid"

View File

@@ -1,6 +1,6 @@
import React, { useEffect, forwardRef } from "react";
import { Selector } from "src/components/forms";
import { TypeaheadSelector } from "src/components/forms";
import { hasLength } from "src/utils";
import type { Application } from "src/api/types";
@@ -18,7 +18,7 @@ type ApplicationSelectProps = {
disabled?: boolean;
};
type SelectorRef = HTMLSelectElement;
type SelectorRef = HTMLInputElement;
export const ApplicationSelect = forwardRef<
SelectorRef,
@@ -47,7 +47,7 @@ export const ApplicationSelect = forwardRef<
<label htmlFor={id}>
{label} {required && <span>*</span>}
</label>
<Selector
<TypeaheadSelector
ref={ref}
id={id}
name={id}

View File

@@ -6,6 +6,7 @@ import { FileUpload } from "./file-upload";
import { AccountSelect } from "./account-select";
import { ApplicationSelect } from "./application-select";
import { LocalLimits, useLocalLimitsRef } from "./local-limits";
import { TypeaheadSelector } from "./typeahead-selector";
export {
Passwd,
@@ -17,4 +18,5 @@ export {
ApplicationSelect,
LocalLimits,
useLocalLimitsRef,
TypeaheadSelector,
};

View File

@@ -0,0 +1,391 @@
import React, { useState, forwardRef, useEffect } from "react";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
import "./styles.scss";
/**
* Represents an option in the typeahead selector dropdown
* @interface TypeaheadOption
* @property {string} name - The display text shown in the dropdown
* @property {string} value - The underlying value used when the option is selected
*/
export interface TypeaheadOption {
name: string;
value: string;
}
/**
* Props for the TypeaheadSelector component
* @extends {JSX.IntrinsicElements["input"]} - Inherits all standard HTML input props
* @typedef TypeaheadSelectorProps
* @property {TypeaheadOption[]} options - Array of selectable options to display in the dropdown
* @property {string} [className] - Optional CSS class name to apply to the component
*/
type TypeaheadSelectorProps = JSX.IntrinsicElements["input"] & {
options: TypeaheadOption[];
className?: string;
};
type TypeaheadSelectorRef = HTMLInputElement;
/**
* TypeaheadSelector - A searchable dropdown component with keyboard navigation
*
* @component
* @param {Object} props
* @param {string} props.id - Unique identifier for the input
* @param {string} props.name - Form field name
* @param {string} props.value - Currently selected value
* @param {TypeaheadOption[]} props.options - Array of selectable options
* @param {boolean} props.disabled - Whether the input is disabled
* @param {Function} props.onChange - Callback when selection changes
* @param {Ref} ref - Forwarded ref for the input element
*
* Features:
* - Keyboard navigation (up/down arrows, enter to select, escape to close)
* - Auto-scroll selected option into view
* - Filtering options by typing
* - Click or keyboard selection
* - Maintains value synchronization with parent component
* - Accessibility support with ARIA attributes
*/
export const TypeaheadSelector = forwardRef<
TypeaheadSelectorRef,
TypeaheadSelectorProps
>(
(
{
id,
name,
value = "",
options,
disabled,
onChange,
className,
...restProps
}: TypeaheadSelectorProps,
ref,
) => {
const [inputValue, setInputValue] = useState("");
const [filteredOptions, setFilteredOptions] = useState(options);
const [isOpen, setIsOpen] = useState(false);
const inputRef = React.useRef<HTMLInputElement | null>(null);
const classes = {
"typeahead-selector": true,
[`typeahead-selector${className}`]: true,
focused: isOpen,
disabled: !!disabled,
};
const [activeIndex, setActiveIndex] = useState(-1);
/**
* Synchronizes the input field with external value changes
* - Updates the input value when the selected value changes externally
* - Sets the input text to the name of the selected option
* - Updates the active index to match the selected option
* - Runs when either the value prop or options array changes
*/
useEffect(() => {
let selectedIndex = options.findIndex((opt) => opt.value === value);
selectedIndex = selectedIndex < 0 ? 0 : selectedIndex;
const selected = options[selectedIndex];
setInputValue(selected?.name ?? "");
setActiveIndex(selectedIndex);
}, [value, options]);
/**
* Handles changes to the input field value
* @param {React.ChangeEvent<HTMLInputElement>} e - Input change event
*
* - Updates the input field with user's typed value
* - Opens the dropdown menu
* - Shows all available options (unfiltered)
* - Finds and highlights the first option that starts with the input text
* - Scrolls the highlighted option into view
*/
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
setInputValue(input);
setIsOpen(true);
setFilteredOptions(options);
const currentIndex = options.findIndex((opt) =>
opt.name.toLowerCase().startsWith(input.toLowerCase()),
);
setActiveIndex(currentIndex);
// Wait for dropdown to render, then scroll to the selected option
setTimeout(() => {
scrollActiveOptionIntoView(currentIndex);
}, 0);
};
/**
* Scrolls the option at the specified index into view within the dropdown
* @param {number} index - The index of the option to scroll into view
*
* - Uses the option's ID to find its DOM element
* - Smoothly scrolls the option into view if found
* - Does nothing if the option element doesn't exist
*/
const scrollActiveOptionIntoView = (index: number) => {
const optionElement = document.getElementById(`${id}-option-${index}`);
if (optionElement) {
optionElement.scrollIntoView({ block: "nearest" });
}
};
/**
* Handles keyboard navigation and selection within the dropdown
* @param {React.KeyboardEvent<HTMLInputElement>} e - Keyboard event
*
* Keyboard controls:
* - ArrowDown/ArrowUp: Opens dropdown if closed, otherwise navigates options
* - Enter: Selects the currently highlighted option
* - Escape: Closes the dropdown
*
* Features:
* - Prevents default arrow key scrolling behavior
* - Auto-scrolls the active option into view
* - Wraps navigation within available options
* - Maintains current selection if at list boundaries
*/
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
setIsOpen(true);
setFilteredOptions(options);
return;
}
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActiveIndex((prev) => {
const newIndex =
prev < filteredOptions.length - 1 ? prev + 1 : prev;
scrollActiveOptionIntoView(newIndex);
return newIndex;
});
break;
case "ArrowUp":
e.preventDefault();
setActiveIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : prev;
scrollActiveOptionIntoView(newIndex);
return newIndex;
});
break;
case "Enter":
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
handleOptionSelect(filteredOptions[activeIndex], e);
}
break;
case "Escape":
setIsOpen(false);
break;
}
};
/**
* Handles the selection of an option from the dropdown
* @param {TypeaheadOption} option - The selected option object
* @param {React.MouseEvent | React.KeyboardEvent} e - Optional event object
*
* - Updates the input field with the selected option's name
* - Closes the dropdown
* - Triggers the onChange callback with a synthetic event containing the selected value
*/
const handleOptionSelect = (
option: TypeaheadOption,
e?: React.MouseEvent | React.KeyboardEvent,
) => {
e?.preventDefault();
setInputValue(option.name);
setIsOpen(false);
if (onChange) {
const syntheticEvent = {
target: { value: option.value, name },
} as React.ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
/**
* Handles the input focus event
*
* - Opens the dropdown menu
* - Shows all available options (unfiltered)
* - Finds and highlights the currently selected option based on value or input text
* - Scrolls the highlighted option into view after dropdown renders
*
* Note: Uses setTimeout to ensure the dropdown is rendered before attempting to scroll
*/
const handleFocus = () => {
setIsOpen(true);
setFilteredOptions(options);
// Find and highlight the current value in the dropdown
const currentIndex = options.findIndex(
(opt) => opt.value === value || opt.name === inputValue,
);
setActiveIndex(currentIndex);
// Wait for dropdown to render, then scroll to the selected option
setTimeout(() => {
scrollActiveOptionIntoView(currentIndex);
}, 0);
};
/**
* Handles the input blur (focus loss) event
* @param {React.FocusEvent} e - The blur event object
*
* - Checks if focus is moving outside the component
* - If focus leaves component:
* - Validates current input value against available options
* - Resets input to last valid selection if no match found
* - Closes the dropdown menu
* - Preserves focus state if clicking within component (e.g., dropdown options)
*/
const handleBlur = (e: React.FocusEvent) => {
// Check if the new focus target is within our component
const relatedTarget = e.relatedTarget as Node;
const container = inputRef.current?.parentElement;
if (!container?.contains(relatedTarget)) {
// Reset value if it doesn't match any option
const matchingOption = options.find(
(opt) => opt.name.toLowerCase() === inputValue.toLowerCase(),
);
if (!matchingOption) {
const selected = options.find((opt) => opt.value === value);
setInputValue(selected?.name || "");
}
setIsOpen(false);
}
};
/**
* Renders a typeahead selector component with dropdown functionality.
*
* Key features:
* - Input field with autocomplete functionality
* - Dropdown toggle button with chevron icons
* - Dropdown list of filterable options
* - Keyboard navigation support
* - Accessibility attributes (ARIA)
*
* Component Structure:
* 1. Input field:
* - Handles text input, focus/blur events
* - Supports both function and object refs
* - Disables browser autocomplete features
*
* 2. Toggle button:
* - Opens/closes dropdown
* - Shows up/down chevron icons
* - Resets filtered options on click
* - Auto-scrolls to selected option
*
* 3. Dropdown menu:
* - Displays filtered options
* - Supports mouse and keyboard interaction
* - Highlights active option
* - Implements proper ARIA attributes for accessibility
*
* States managed:
* - isOpen: Controls dropdown visibility
* - activeIndex: Tracks currently focused option
* - inputValue: Current input text
* - filteredOptions: Available options based on input
*/
return (
<div className={classNames(classes)}>
<input
className={classNames({
active: isOpen,
disabled: !!disabled,
})}
ref={(node) => {
// Handle both refs
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
inputRef.current = node;
}}
id={id}
name={name}
value={inputValue}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
disabled={disabled}
{...restProps}
/>
<span
role="button"
tabIndex={0}
onBlur={handleBlur}
className={classNames({
active: isOpen,
disabled: !!disabled,
pointerevents: true,
})}
onClick={() => {
setIsOpen(!isOpen);
setFilteredOptions(options);
const currentIndex = options.findIndex(
(opt) => opt.value === value || opt.name === inputValue,
);
setActiveIndex(currentIndex);
// Wait for dropdown to render, then scroll to the selected option
setTimeout(() => {
scrollActiveOptionIntoView(currentIndex);
}, 0);
}}
onKeyDown={handleKeyDown}
>
<Icons.ChevronUp />
<Icons.ChevronDown />
</span>
{isOpen && (
<div
className="typeahead-dropdown"
role="listbox"
id={`${id}-listbox`}
>
{filteredOptions.map((option, index) => (
<div
key={`${id}_${option.value}`}
className={classNames({
"typeahead-option": true,
active: index === activeIndex,
})}
role="option"
id={`${id}-option-${index}`}
aria-selected={index === activeIndex}
tabIndex={-1}
onMouseDown={() => handleOptionSelect(option)}
onMouseEnter={() => setActiveIndex(index)}
>
{option.name}
</div>
))}
</div>
)}
</div>
);
},
);
TypeaheadSelector.displayName = "TypeaheadSelector";

View File

@@ -0,0 +1,182 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "@jambonz/ui-kit/src/styles/index";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
// ... imports remain the same ...
// Common mixins for shared styles
@mixin typeahead-base {
position: relative;
max-width: vars.$widthtypeaheadselector;
&.disabled {
@include mixins.disabled();
}
&.focused {
input {
border-color: ui-vars.$dark;
outline: 0;
}
span {
background-color: ui-vars.$dark;
}
}
}
@mixin typeahead-input {
@include ui-mixins.m();
appearance: none;
padding: ui-vars.$px01 ui-vars.$px02;
border-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
background-color: ui-vars.$white;
max-width: vars.$widthtypeaheadinput;
transition: border-color 0.2s ease;
font-family: inherit;
&:focus {
border-color: ui-vars.$dark;
outline: 0;
}
&[disabled] {
@include mixins.disabled();
}
}
@mixin typeahead-span {
height: 100%;
width: 50px;
background-color: ui-vars.$grey;
border-radius: 0 ui-vars.$px01 ui-vars.$px01 0;
position: absolute;
right: 0;
top: 0;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
transition: background-color 0.2s ease;
&.disabled {
@include mixins.disabled();
}
&.active {
background-color: ui-vars.$dark;
}
svg {
stroke: ui-vars.$white;
cursor: default;
&:first-child {
transform: translateY(5px);
}
&:last-child {
transform: translateY(-5px);
}
}
}
@mixin typeahead-dropdown {
@include ui-mixins.m();
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ui-vars.$white;
border: 1px solid ui-vars.$dark;
max-height: 200px;
overflow-y: auto;
}
@mixin typeahead-option {
cursor: pointer;
transition: all 0.2s ease;
font-weight: normal;
display: block;
padding-block-start: 0px;
padding-block-end: 1px;
min-block-size: 1.2em;
padding-inline: 2px;
white-space: nowrap;
padding-left: 16px;
font-family: inherit;
line-height: 30.4px;
&:hover,
&.active {
background-color: #006dff;
color: ui-vars.$white;
}
&.active {
cursor: default;
}
}
// Main classes using the mixins
.typeahead-selector {
@include typeahead-base();
width: 100%;
input {
@include typeahead-input();
width: 100%;
}
span {
@include typeahead-span();
}
.typeahead-dropdown {
@include typeahead-dropdown();
z-index: 1000;
}
.typeahead-option {
@include typeahead-option();
}
}
.typeahead-selectorsmall {
@include typeahead-base();
width: auto;
input {
@include typeahead-input();
height: 34px;
min-width: 370px;
font-size: var(--mxs-size);
}
span {
@include typeahead-span();
}
.typeahead-dropdown {
@include typeahead-dropdown();
width: 100%;
}
.typeahead-option {
@include typeahead-option();
font-size: var(--mxs-size);
}
.pointerevents {
pointer-events: all;
cursor: default;
}
}
.filters--multi {
overflow-x: visible !important;
white-space: nowrap;
grid-gap: 16px;
}

View File

@@ -14,7 +14,14 @@ import {
postAccountBucketCredentialTest,
deleteAccount,
} from "src/api";
import { ClipBoard, Icons, Modal, Section, Tooltip } from "src/components";
import {
ClipBoard,
Icons,
Modal,
ScopedAccess,
Section,
Tooltip,
} from "src/components";
import {
Selector,
Checkzone,
@@ -67,6 +74,7 @@ import dayjs from "dayjs";
import { EditBoard } from "src/components/editboard";
import { ModalLoader } from "src/components/modal";
import { useAuth } from "src/router/auth";
import { Scope } from "src/store/types";
type AccountFormProps = {
apps?: Application[];
@@ -137,6 +145,7 @@ export const AccountForm = ({
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const [azureConnectionString, setAzureConnectionString] = useState("");
const [endpoint, setEndpoint] = useState("");
const [enableDebugLog, setEnableDebugLog] = useState(false);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -381,6 +390,7 @@ export const AccountForm = ({
if (account && account.data) {
putAccount(account.data.account_sid, {
name,
enable_debug_log: enableDebugLog,
...(!ENABLE_HOSTED_SYSTEM && { sip_realm: realm || null }),
webhook_secret: account.data.webhook_secret,
siprec_hook_sid: recId || null,
@@ -450,6 +460,7 @@ export const AccountForm = ({
queue_event_hook: queueHook || null,
registration_hook: regHook || null,
service_provider_sid: currentServiceProvider.service_provider_sid,
enable_debug_log: enableDebugLog,
})
.then(({ json }) => {
toastSuccess("Account created successfully");
@@ -465,6 +476,7 @@ export const AccountForm = ({
/** Set current account data values if applicable -- e.g. "edit mode" */
useEffect(() => {
if (account && account.data) {
setEnableDebugLog(account.data.enable_debug_log);
setName(account.data.name);
if (account.data.sip_realm) {
@@ -1021,6 +1033,22 @@ export const AccountForm = ({
} cached TTS prompts`}</MS>
</fieldset>
)}
<ScopedAccess scope={Scope.admin} user={user}>
<fieldset>
<label htmlFor="enable_debug_log" className="chk">
<input
id="enable_debug_log"
name="enable_debug_log"
type="checkbox"
onChange={(e) => setEnableDebugLog(e.target.checked)}
checked={enableDebugLog}
/>
<Tooltip text="You can enable debug log for calls only to this account">
Enable debug log for this account
</Tooltip>
</label>
</fieldset>
</ScopedAccess>
{!DISABLE_CALL_RECORDING && (
<>
<fieldset>

View File

@@ -113,59 +113,62 @@ export const Applications = () => {
{!hasValue(applications) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredApplications) ? (
filteredApplications.map((application) => {
return (
<div className="item" key={application.application_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
className="i"
>
<strong>{application.name}</strong>
<Icons.ArrowRight />
</Link>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${
application.account_sid ? "teal" : "grey"
}`}
filteredApplications
.sort((a, b) => a.name.localeCompare(b.name))
.map((application) => {
return (
<div className="item" key={application.application_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
className="i"
>
<Icons.Activity />
<span>
{
accounts?.find(
(acct) =>
acct.account_sid === application.account_sid,
)?.name
}
</span>
<strong>{application.name}</strong>
<Icons.ArrowRight />
</Link>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${
application.account_sid ? "teal" : "grey"
}`}
>
<Icons.Activity />
<span>
{
accounts?.find(
(acct) =>
acct.account_sid ===
application.account_sid,
)?.name
}
</span>
</div>
</div>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete application"
onClick={() => setApplication(application)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_APPLICATIONS}/${application.application_sid}/edit`}
title="Edit application"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete application"
onClick={() => setApplication(application)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
);
})
);
})
) : accountSid ? (
<M>No applications.</M>
) : (

View File

@@ -28,6 +28,11 @@ import {
VENDOR_SONIOX,
VENDOR_WELLSAID,
VENDOR_WHISPER,
VENDOR_SPEECHMATICS,
VENDOR_PLAYHT,
VENDOR_CARTESIA,
VENDOR_VOXIST,
VENDOR_RIMELABS,
} from "src/vendor";
import {
LabelOptions,
@@ -198,6 +203,19 @@ export const SpeechProviderSelection = ({
if (synthesisGoogleCustomVoiceOptions.length > 0) {
updateTtsVoice(synthesisGoogleCustomVoiceOptions[0].value);
}
}
// PlayHT3.0 all voices are listed under english language, all voices can be used for multiple languages
else if (
synthVendor === VENDOR_PLAYHT &&
synthesisSupportedLanguagesAndVoices.tts.some(
(l) => l.value === "english",
)
) {
setSynthesisVoiceOptions(
synthesisSupportedLanguagesAndVoices.tts.find(
(tts) => tts.value === "english",
)!.voices,
);
} else {
setSynthesisVoiceOptions(voicesOpts);
}
@@ -261,6 +279,26 @@ export const SpeechProviderSelection = ({
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_PLAYHT) {
const newLang = json.tts.find(
(lang) => lang.value === LANG_EN_US || lang.value === "english",
);
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_CARTESIA) {
const newLang = json.tts.find((lang) => lang.value === "en");
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_RIMELABS) {
const newLang = json.tts.find((lang) => lang.value === "eng");
setSynthLang(newLang!.value);
updateTtsVoice(newLang!.voices[0].value);
return;
}
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
let newLang = json.tts.find((lang) => lang.value === synthLang);
@@ -360,8 +398,10 @@ export const SpeechProviderSelection = ({
value={synthVendor}
options={ttsVendorOptions.filter(
(vendor) =>
vendor.value != VENDOR_ASSEMBLYAI &&
vendor.value != VENDOR_SONIOX &&
vendor.value !== VENDOR_ASSEMBLYAI &&
vendor.value !== VENDOR_VOXIST &&
vendor.value !== VENDOR_SONIOX &&
vendor.value !== VENDOR_SPEECHMATICS &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_COBALT,
)}
@@ -383,6 +423,7 @@ export const SpeechProviderSelection = ({
value={synthLabel}
options={ttsLabelOptions}
onChange={(e) => {
shouldUpdateTtsVoice.current = true;
setSynthLabel(e.target.value);
}}
/>
@@ -410,7 +451,9 @@ export const SpeechProviderSelection = ({
id="synthesis_lang"
name="synthesis_lang"
value={synthLang}
options={synthesisLanguageOptions}
options={synthesisLanguageOptions.sort((a, b) =>
a.name.localeCompare(b.name),
)}
onChange={(e) => {
shouldUpdateTtsVoice.current = true;
const language = e.target.value;
@@ -465,7 +508,9 @@ export const SpeechProviderSelection = ({
id="synthesis_voice"
name="synthesis_voice"
value={synthVoice}
options={synthesisVoiceOptions}
options={synthesisVoiceOptions.sort((a, b) =>
a.name.localeCompare(b.name),
)}
onChange={(e) => setSynthVoice(e.target.value)}
/>
)}

View File

@@ -19,6 +19,7 @@ import {
import {
DEFAULT_SIP_GATEWAY,
DEFAULT_SMPP_GATEWAY,
DTMF_TYPE_SELECTION,
FQDN,
FQDN_TOP_LEVEL,
INVALID,
@@ -29,7 +30,7 @@ import {
TECH_PREFIX_MINLENGTH,
USER_ACCOUNT,
} from "src/api/constants";
import { Icons, Section } from "src/components";
import { Icons, Section, Tooltip } from "src/components";
import {
Checkzone,
Message,
@@ -52,16 +53,17 @@ import {
isNotBlank,
} from "src/utils";
import type {
Account,
UseApiDataMap,
Carrier,
SipGateway,
SmppGateway,
PredefinedCarrier,
Sbc,
Smpp,
Application,
import {
type Account,
type UseApiDataMap,
type Carrier,
type SipGateway,
type SmppGateway,
type PredefinedCarrier,
type Sbc,
type Smpp,
type Application,
DtmfType,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { RegisterStatus } from "./register-status";
@@ -101,6 +103,7 @@ export const CarrierForm = ({
const [e164, setE164] = useState(false);
const [applicationSid, setApplicationSid] = useState("");
const [accountSid, setAccountSid] = useState("");
const [dtmfType, setDtmfType] = useState<DtmfType>("rfc2833");
const [sipRegister, setSipRegister] = useState(false);
const [sipUser, setSipUser] = useState("");
@@ -216,6 +219,9 @@ export const CarrierForm = ({
if (obj.smpp_inbound_password) {
setSmppInboundPass(obj.smpp_inbound_password);
}
if (obj.dtmf_type) {
setDtmfType(obj.dtmf_type);
}
}
};
@@ -524,6 +530,7 @@ export const CarrierForm = ({
smpp_password: smppPass.trim() || null,
smpp_inbound_system_id: smppInboundSystemId.trim() || null,
smpp_inbound_password: smppInboundPass.trim() || null,
dtmf_type: dtmfType,
};
if (carrier && carrier.data) {
@@ -764,6 +771,23 @@ export const CarrierForm = ({
: false
}
/>
<label htmlFor="dtmf_type">
<Tooltip
text={
"RFC 2833 is commonly used on VoIP networks. Do not change unless you are certain this carrier does not support it"
}
>
DTMF type
</Tooltip>
</label>
<Selector
id="dtmf_type"
name="dtmf_type"
value={dtmfType}
options={DTMF_TYPE_SELECTION}
onChange={(e) => setDtmfType(e.target.value as DtmfType)}
/>
{user &&
disableDefaultTrunkRouting(user?.scope) &&
accountSid &&
@@ -1096,29 +1120,71 @@ export const CarrierForm = ({
<div>Outbound</div>
</label>
</div>
{g.outbound > 0 && g.protocol === "tls/srtp" && (
<div>
<label htmlFor={`sip_pad_crypto_${i}`} className="chk">
<input
id={`sip_pad_crypto_${i}`}
name={`sip_pad_crypto_${i}`}
type="checkbox"
checked={g.pad_crypto ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"pad_crypto",
e.target.checked,
);
}}
/>
<div>Pad crypto</div>
</label>
</div>
{Boolean(g.outbound) && (
<div>
<label
htmlFor={`sip_pad_crypto_${i}`}
htmlFor={`send_options_ping_${i}`}
className="chk"
>
<input
id={`sip_pad_crypto_${i}`}
name={`sip_pad_crypto_${i}`}
id={`send_options_ping_${i}`}
name={`send_options_ping_${i}`}
type="checkbox"
checked={g.pad_crypto ? true : false}
checked={g.send_options_ping ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"pad_crypto",
"send_options_ping",
e.target.checked,
);
}}
/>
<div>Pad crypto</div>
<div>Send OPTIONS ping</div>
</label>
</div>
)}
{Boolean(g.outbound) &&
(g.protocol === "tls" || g.protocol === "tls/srtp") && (
<div>
<label
htmlFor={`use_sips_scheme_${i}`}
className="chk"
>
<input
id={`use_sips_scheme_${i}`}
name={`use_sips_scheme_${i}`}
type="checkbox"
checked={g.use_sips_scheme ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"use_sips_scheme",
e.target.checked,
);
}}
/>
<div>Use sips scheme</div>
</label>
</div>
)}
</div>
<button

View File

@@ -10,9 +10,9 @@ import {
import { Section } from "src/components";
import {
Message,
Selector,
AccountSelect,
ApplicationSelect,
TypeaheadSelector,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -169,7 +169,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
<label htmlFor="sip_trunk">
Carrier <span>*</span>
</label>
<Selector
<TypeaheadSelector
id="sip_trunk"
name="sip_trunk"
required
@@ -200,9 +200,11 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
application={[applicationSid, setApplicationSid]}
applications={
applications
? applications.filter(
(application) => application.account_sid === accountSid,
)
? applications
.filter(
(application) => application.account_sid === accountSid,
)
.sort((a, b) => a.name.localeCompare(b.name))
: []
}
/>

View File

@@ -63,7 +63,12 @@ export const RecentCalls = () => {
count: Number(perPageFilter),
...(dateFilter === "today"
? { start: dayjs().startOf("date").toISOString() }
: { days: Number(dateFilter) }),
: dateFilter === "yesterday"
? {
start: dayjs().subtract(1, "day").startOf("day").toISOString(),
end: dayjs().subtract(1, "day").endOf("day").toISOString(),
}
: { days: Number(dateFilter) }),
...(statusFilter !== "all" && { answered: statusFilter }),
...(directionFilter !== "io" && { direction: directionFilter }),
...(filter && { filter }),
@@ -157,7 +162,10 @@ export const RecentCalls = () => {
{!hasValue(calls) && hasLength(accounts) ? (
<Spinner />
) : hasLength(calls) ? (
calls.map((call) => <DetailsItem key={call.call_sid} call={call} />)
//call.call_sid is null incase of failure, cannot be used as key
calls.map((call) => (
<DetailsItem key={call.sip_callid} call={call} />
))
) : (
<M>No data.</M>
)}

View File

@@ -7,11 +7,20 @@ import {
postSystemInformation,
deleteTtsCache,
} from "src/api";
import { PasswordSettings, SystemInformation, TtsCache } from "src/api/types";
import {
LogLevel,
PasswordSettings,
SystemInformation,
TtsCache,
} from "src/api/types";
import { toastError, toastSuccess } from "src/store";
import { Selector } from "src/components/forms";
import { hasValue } from "src/utils";
import { PASSWORD_LENGTHS_OPTIONS, PASSWORD_MIN } from "src/api/constants";
import { hasValue, isvalidIpv4OrCidr } from "src/utils";
import {
LOG_LEVEL_OPTIONS,
PASSWORD_LENGTHS_OPTIONS,
PASSWORD_MIN,
} from "src/api/constants";
import { Modal } from "src/components";
export const AdminSettings = () => {
@@ -25,9 +34,11 @@ export const AdminSettings = () => {
const [requireDigit, setRequireDigit] = useState(false);
const [requireSpecialCharacter, setRequireSpecialCharacter] = useState(false);
const [domainName, setDomainName] = useState("");
const [privateNetworkCidr, setPrivateNetworkCidr] = useState("");
const [sipDomainName, setSipDomainName] = useState("");
const [monitoringDomainName, setMonitoringDomainName] = useState("");
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
const [logLevel, setLogLevel] = useState<LogLevel>("info");
const handleClearCache = () => {
deleteTtsCache()
@@ -44,10 +55,22 @@ export const AdminSettings = () => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (privateNetworkCidr) {
const cidrs = privateNetworkCidr.split(",");
for (const cidr of cidrs) {
if (cidr && !isvalidIpv4OrCidr(cidr)) {
toastError(`Invalid private network CIDR "${cidr}"`);
return;
}
}
}
const systemInformationPayload: Partial<SystemInformation> = {
domain_name: domainName,
sip_domain_name: sipDomainName,
monitoring_domain_name: monitoringDomainName,
domain_name: domainName || null,
sip_domain_name: sipDomainName || null,
monitoring_domain_name: monitoringDomainName || null,
private_network_cidr: privateNetworkCidr || null,
log_level: logLevel,
};
const passwordSettingsPayload: Partial<PasswordSettings> = {
min_password_length: minPasswordLength,
@@ -61,7 +84,7 @@ export const AdminSettings = () => {
.then(() => {
passwordSettingsFetcher();
systemInformationFetcher();
toastSuccess("Password settings successfully updated");
toastSuccess("Admin settings updated successfully");
})
.catch((error) => {
toastError(error.msg);
@@ -78,11 +101,21 @@ export const AdminSettings = () => {
setMinPasswordLength(passwordSettings.min_password_length);
}
}
if (hasValue(systemInformation)) {
if (systemInformation?.domain_name) {
setDomainName(systemInformation.domain_name);
}
if (systemInformation?.sip_domain_name) {
setSipDomainName(systemInformation.sip_domain_name);
}
if (systemInformation?.monitoring_domain_name) {
setMonitoringDomainName(systemInformation.monitoring_domain_name);
}
if (systemInformation?.private_network_cidr) {
setPrivateNetworkCidr(systemInformation.private_network_cidr);
}
if (systemInformation?.log_level) {
setLogLevel(systemInformation.log_level);
}
}, [passwordSettings, systemInformation]);
return (
@@ -107,6 +140,15 @@ export const AdminSettings = () => {
value={sipDomainName}
onChange={(e) => setSipDomainName(e.target.value)}
/>
<label htmlFor="name">Private Network CIDR</label>
<input
id="private_network_cidr"
type="text"
name="private_network_cidr"
placeholder="Private network CIDR"
value={privateNetworkCidr}
onChange={(e) => setPrivateNetworkCidr(e.target.value)}
/>
<label htmlFor="name">Monitoring Domain Name</label>
<input
id="monitor_domain_name"
@@ -116,6 +158,17 @@ export const AdminSettings = () => {
value={monitoringDomainName}
onChange={(e) => setMonitoringDomainName(e.target.value)}
/>
<label htmlFor="audio_format">Log Level</label>
<Selector
id={"audio_format"}
name={"audio_format"}
value={logLevel}
options={LOG_LEVEL_OPTIONS}
onChange={(e) => {
setLogLevel(e.target.value as LogLevel);
}}
/>
</fieldset>
<fieldset>
<label htmlFor="min_password_length">Min password length</label>

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ import {
} from "src/api";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import {
getHumanDateTime,
isUserAccountScope,
hasLength,
hasValue,
@@ -178,24 +177,6 @@ export const SpeechServices = () => {
<span>{getUsage(credential)}</span>
</div>
</div>
<div>
<div
className={`i txt--${
credential.last_used ? "teal" : "grey"
}`}
>
{credential.last_used ? (
<Icons.CheckCircle />
) : (
<Icons.XCircle />
)}
<span>
{credential.last_used
? getHumanDateTime(credential.last_used)
: "Never used"}
</span>
</div>
</div>
<div>
<CredentialStatus cred={credential} />
</div>

View File

@@ -231,7 +231,7 @@ fieldset {
}
&:nth-child(2) {
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
margin-top: ui-vars.$px02;
> div:last-child {
@@ -329,6 +329,11 @@ fieldset {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
&:nth-child(5) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
}
> button {

View File

@@ -12,6 +12,8 @@ $widthnavi: 280px;
$widthinput: 512px;
$widthbreak: 800px;
$widthsmall: 640px;
$widthtypeaheadselector: 512px;
$widthtypeaheadinput: 467px;
/** Used by api-keys layout */
$gridbreak1: 1070px;

View File

@@ -89,6 +89,22 @@ export const getIpValidationType = (ipv4: string): IpType => {
return type;
};
function isValidIPV4(ip: string): boolean {
const ipv4Pattern =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
return ipv4Pattern.test(ip);
}
function isValidCIDR(cidr: string): boolean {
const cidrPattern =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
return cidrPattern.test(cidr);
}
export function isvalidIpv4OrCidr(input: string): boolean {
return isValidIPV4(input) || isValidCIDR(input);
}
export const getObscured = (str: string, sub = 4, char = "*") => {
const len = str.length - sub;
const obscured = str.substring(0, len).replace(/[a-zA-Z0-9]/g, char);

42
src/vendor/index.tsx vendored
View File

@@ -15,13 +15,17 @@ export const VENDOR_DEEPGRAM = "deepgram";
export const VENDOR_IBM = "ibm";
export const VENDOR_NVIDIA = "nvidia";
export const VENDOR_SONIOX = "soniox";
export const VENDOR_SPEECHMATICS = "speechmatics";
export const VENDOR_CUSTOM = "custom";
export const VENDOR_COBALT = "cobalt";
export const VENDOR_ELEVENLABS = "elevenlabs";
export const VENDOR_ASSEMBLYAI = "assemblyai";
export const VENDOR_VOXIST = "voxist";
export const VENDOR_WHISPER = "whisper";
export const VENDOR_PLAYHT = "playht";
export const VENDOR_RIMELABS = "rimelabs";
export const VENDOR_VERBIO = "verbio";
export const VENDOR_CARTESIA = "cartesia";
export const vendors: VendorOptions[] = [
{
@@ -60,6 +64,10 @@ export const vendors: VendorOptions[] = [
name: "Soniox",
value: VENDOR_SONIOX,
},
{
name: "Speechmatics",
value: VENDOR_SPEECHMATICS,
},
{
name: "Custom",
value: VENDOR_CUSTOM,
@@ -76,6 +84,10 @@ export const vendors: VendorOptions[] = [
name: "AssemblyAI",
value: VENDOR_ASSEMBLYAI,
},
{
name: "Voxist",
value: VENDOR_VOXIST,
},
{
name: "Whisper",
value: VENDOR_WHISPER,
@@ -88,8 +100,35 @@ export const vendors: VendorOptions[] = [
name: "RimeLabs",
value: VENDOR_RIMELABS,
},
{
name: "Verbio",
value: VENDOR_VERBIO,
},
{
name: "Cartesia",
value: VENDOR_CARTESIA,
},
].sort((a, b) => a.name.localeCompare(b.name)) as VendorOptions[];
export const AWS_CREDENTIAL_ACCESS_KEY = "access_key";
export const AWS_CREDENTIAL_IAM_ASSUME_ROLE = "assume_role";
export const AWS_INSTANCE_PROFILE = "instance_profile";
export const AWS_CREDENTIAL_TYPES = [
{
name: "AWS access key",
value: AWS_CREDENTIAL_ACCESS_KEY,
},
{
name: "AWS assume role",
value: AWS_CREDENTIAL_IAM_ASSUME_ROLE,
},
{
name: "AWS instance profile",
value: AWS_INSTANCE_PROFILE,
},
];
export const useRegionVendors = () => {
const [regions, setRegions] = useState<RegionVendors>();
@@ -100,17 +139,20 @@ export const useRegionVendors = () => {
import("./regions/aws-regions"),
import("./regions/ms-azure-regions"),
import("./regions/ibm-regions"),
import("./regions/speechmatics-regions"),
]).then(
([
{ default: awsRegions },
{ default: msRegions },
{ default: ibmRegions },
{ default: speechmaticsRegions },
]) => {
if (!ignore) {
setRegions({
aws: awsRegions,
microsoft: msRegions,
ibm: ibmRegions,
speechmatics: speechmaticsRegions,
});
}
},

View File

@@ -0,0 +1,18 @@
import type { Region } from "../types";
export const regions: Region[] = [
{
name: "EU (EU2 - On-demand)",
value: "eu2.rt.speechmatics.com",
},
{
name: "EU (EU1 - Enterprise)",
value: "neu.rt.speechmatics.com",
},
{
name: "US (US1 - Enterprise)",
value: "wus.rt.speechmatics.com",
},
];
export default regions;

11
src/vendor/types.ts vendored
View File

@@ -8,13 +8,17 @@ export type Vendor =
| "IBM"
| "Nvidia"
| "Soniox"
| "Speechmatics"
| "Cobalt"
| "Custom"
| "ElevenLabs"
| "assemblyai"
| "voxist"
| "whisper"
| "playht"
| "rimelabs";
| "rimelabs"
| "verbio"
| "Cartesia";
export interface VendorOptions {
name: Vendor;
@@ -70,6 +74,7 @@ export interface RegionVendors {
aws: Region[];
microsoft: Region[];
ibm: Region[];
speechmatics: Region[];
}
export interface TtsModels {
@@ -87,6 +92,7 @@ export interface RecognizerVendors {
ibm: Language[];
nvidia: Language[];
soniox: Language[];
speechmatics: Language[];
cobalt: Language[];
assemblyai: Language[];
}
@@ -102,6 +108,9 @@ export interface SynthesisVendors {
elevenlabs: VoiceLanguage[];
whisper: VoiceLanguage[];
deepgram: VoiceLanguage[];
playht: VoiceLanguage[];
cartesia: VoiceLanguage[];
rimelabs: VoiceLanguage[];
}
export interface MSRawSpeech {