Compare commits

...

33 Commits

Author SHA1 Message Date
Hoan Luu Huu
36f22e2075 fix recordAllCalls show wrong bucket region (#522) 2025-05-22 07:25:17 -04:00
Hoan Luu Huu
8b2bde4e11 support tier pricing (#520) 2025-05-18 14:00:07 -04:00
Dave Horton
0c35321c1f update version 2025-05-14 15:40:35 -04:00
Hoan Luu Huu
ce07e89da5 Add env var to defer full retrieval of phone numbers (#518)
* Add env var to defer full retrieval of phone numbers

* wip

* wip

* wip
2025-05-14 07:55:16 -04:00
Hoan Luu Huu
3e6ef5346e fixed carrier sip proxy can't add port (#517) 2025-05-13 07:51:36 -04:00
Vasudev Anubrolu
94a873cffb feat/864-playht on prem conditional block (#515)
* feat/864-playht on prem conditional block

* feat/864 playht on prem condition for checkobox

---------

Co-authored-by: Hoan Luu Huu <110280845+xquanluu@users.noreply.github.com>
2025-05-10 10:35:41 -04:00
Hoan Luu Huu
020b11e8ef support App envs obscured input text (#516)
* support App envs obscured input text

* wip
2025-05-10 09:13:23 -04:00
Hoan Luu Huu
46727f621b fix enable recording feature replace obscurbed value to input text after user edit it and test (#514) 2025-05-09 07:43:42 -04:00
Hoan Luu Huu
35f7661f45 fixed PUT of env_vars in Applications should not stringify (#513) 2025-05-08 20:25:31 -04:00
Vasudev Anubrolu
0a91bb09a5 feat/864 playht on prem (#508)
* feat/864 playht on prem

* feat/864 playht on prem null check

---------

Co-authored-by: vasudevan-Kore <vasudev.anubrolu@kore.com>
2025-05-08 12:26:39 -04:00
Hoan Luu Huu
70a0c2d7b2 support applicatin env vars (#509)
* support applicatin env vars

* wip

* wip

* wip

* wip

* wip
2025-05-08 08:41:05 -04:00
Hoan Luu Huu
db3a0cc646 support rimelabs arcana (#510) 2025-05-07 07:26:57 -04:00
Sam Machin
94181873f3 Disable password managers on some password forms (#503)
* Update index.tsx

add the data-lpignore attribute to the password component and remove redundant {} on autoComplete

* Update index.tsx

* add other password managers and disable ignore on main login
2025-04-11 08:19:46 -04:00
rammohan-y
3437d7f3d7 Feat/371 view only user checkbox (#478)
* feat/371 added read-only checkbox

* wip

* wip-2

* renamed is_read_only to is_view_only
2025-04-01 09:29:26 -04:00
rammohan-y
38128b1531 fixed error when selecting yesterday option from date filter, also added more options in the filter (#500) 2025-04-01 09:21:03 -04:00
Hoan Luu Huu
f9e4c241f3 support openAi stt (#496)
* support openAi stt

* wip

* wip

* add back model selection to openai
2025-03-28 10:15:27 -04:00
Hoan Luu Huu
c4be87353c fix: application synthsizer configuraiton is shown different than data stored in db when view application. (#493) 2025-03-17 07:24:51 -04:00
Hoan Luu Huu
9c9699ea69 improvements when entering application call hook (#489) 2025-03-10 09:26:27 -04:00
Hoan Luu Huu
e48fce08d4 support showing call cloudwatch logs (#490)
* support showing call cloudwatch logs

* wip

* fix review comments

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
2025-03-10 08:35:18 -04:00
Hoan Luu Huu
e36b031c76 filter carrier based on account_sid when creating phone number (#492) 2025-03-06 07:41:50 -05:00
Hoan Luu Huu
bf87e4fb80 Feat/gh 482 (#488)
* remove messaging hook from application

* remove messaging hook from application
2025-02-26 19:02:54 -05:00
Hoan Luu Huu
b8140ba0d6 Support voip carrier sip proxy (#484)
* Support voip carrier sip proxy

* fixed review comment
2025-02-17 09:47:02 -05:00
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
39 changed files with 2139 additions and 322 deletions

8
.env
View File

@@ -1,4 +1,4 @@
VITE_API_BASE_URL=http://127.0.0.1:3000/v1
#VITE_API_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
@@ -27,4 +27,8 @@ VITE_API_BASE_URL=http://127.0.0.1:3000/v1
## Strip publishable key
#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
# VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS=true
## AWS region for enabling Recent Call Feature server logs
#VITE_APP_AWS_REGION=us-west-2
## enable lazy loading for phone numbers (improves performance when managing large quantities)
# VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD=true

54
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "jambonz-webapp",
"version": "0.9.0",
"version": "0.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jambonz-webapp",
"version": "0.9.0",
"version": "0.9.4",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -2561,30 +2561,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@@ -3238,6 +3214,31 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/cypress/node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/cypress/node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -7205,6 +7206,7 @@
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}

View File

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

View File

@@ -1,7 +1,8 @@
import { hasValue } from "src/utils";
import type {
CartesiaOptions,
Currency,
ElevenLabsOptions,
GoogleCustomVoice,
LimitField,
LimitUnitOption,
PasswordSettings,
@@ -31,6 +32,8 @@ interface JambonzWindowObject {
DEFAULT_SERVICE_PROVIDER_SID: string;
STRIPE_PUBLISHABLE_KEY: string;
DISABLE_ADDITIONAL_SPEECH_VENDORS: string;
AWS_REGION: string;
ENABLE_PHONE_NUMBER_LAZY_LOAD: string;
}
declare global {
@@ -42,9 +45,10 @@ declare global {
/** https://vitejs.dev/guide/env-and-mode.html#env-files */
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`;
export const API_BASE_URL =
CONFIGURED_API_BASE_URL && CONFIGURED_API_BASE_URL.length !== 0
? 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;
@@ -85,6 +89,13 @@ export const DISABLE_ADDITIONAL_SPEECH_VENDORS: boolean =
import.meta.env.VITE_APP_DISABLE_ADDITIONAL_SPEECH_VENDORS || "false",
);
export const AWS_REGION: string =
window.JAMBONZ?.AWS_REGION || import.meta.env.VITE_APP_AWS_REGION;
export const ENABLE_PHONE_NUMBER_LAZY_LOAD: boolean =
window.JAMBONZ?.ENABLE_PHONE_NUMBER_LAZY_LOAD === "true" ||
JSON.parse(import.meta.env.VITE_APP_ENABLE_PHONE_NUMBER_LAZY_LOAD || "false");
export const DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
@@ -247,6 +258,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,
@@ -274,6 +292,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;
@@ -287,6 +311,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" },
@@ -305,6 +330,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[] = [
{
@@ -422,3 +452,4 @@ export const API_SUBSCRIPTIONS = `${API_BASE_URL}/Subscriptions`;
export const API_CHANGE_PASSWORD = `${API_BASE_URL}/change-password`;
export const API_SIGNIN = `${API_BASE_URL}/signin`;
export const API_GOOGLE_CUSTOM_VOICES = `${API_BASE_URL}/GoogleCustomVoices`;
export const API_APP_ENV = `${API_BASE_URL}/AppEnv`;

View File

@@ -34,6 +34,7 @@ import {
API_CHANGE_PASSWORD,
API_SIGNIN,
API_GOOGLE_CUSTOM_VOICES,
API_APP_ENV,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -94,6 +95,8 @@ import type {
GoogleCustomVoice,
GoogleCustomVoicesQuery,
SpeechSupportedLanguagesAndVoices,
AppEnv,
PhoneNumberQuery,
} from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
@@ -225,6 +228,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 +505,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>) => {
@@ -793,6 +815,11 @@ export const getGoogleCustomVoices = (
const qryStr = getQuery<Partial<GoogleCustomVoicesQuery>>(query);
return getFetch<GoogleCustomVoice[]>(`${API_GOOGLE_CUSTOM_VOICES}?${qryStr}`);
};
// ENV VARS
export const getAppEnvSchema = (url: string) => {
return getFetch<AppEnv>(`${API_APP_ENV}?url=${url}`);
};
/** Wrappers for APIs that can have a mock dev server response */
@@ -804,17 +831,19 @@ export const getRecentCalls = (sid: string, query: Partial<CallQuery>) => {
const qryStr = getQuery<Partial<CallQuery>>(query);
return getFetch<PagedResponse<RecentCall>>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls?${qryStr}`
: `${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`,
`${API_ACCOUNTS}/${sid}/RecentCalls?${qryStr}`,
);
};
export const getRecentCall = (sid: string, sipCallId: string) => {
return getFetch<TotalResponse>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`,
`${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}`,
);
};
export const getRecentCallLog = (sid: string, callSid: string) => {
return getFetch<string[]>(
`${API_ACCOUNTS}/${sid}/RecentCalls/${callSid}/logs`,
);
};
@@ -871,6 +900,12 @@ export const getPrice = () => {
return getFetch<PriceInfo[]>(API_PRICE);
};
export const getPhoneNumbers = (query: Partial<PhoneNumberQuery>) => {
const qryStr = getQuery<Partial<PhoneNumberQuery>>(query);
return getFetch<PhoneNumber[]>(`${API_PHONE_NUMBERS}?${qryStr}`);
};
export const getSpeechSupportedLanguagesAndVoices = (
sid: string | undefined,
vendor: string,

View File

@@ -143,6 +143,7 @@ export interface User {
name: string;
email: string;
is_active: boolean;
is_view_only: boolean;
force_change: boolean;
account_sid: string | null;
account_name?: string | null;
@@ -174,6 +175,7 @@ export interface UserUpdatePayload {
name: string;
force_change: boolean;
is_active: boolean;
is_view_only: boolean;
service_provider_sid: string | null;
account_sid: string | null;
}
@@ -318,7 +320,6 @@ export interface Application {
app_json: null | string;
call_hook: null | WebHook;
account_sid: null | string;
messaging_hook: null | WebHook;
application_sid: string;
call_status_hook: null | WebHook;
speech_synthesis_voice: null | string;
@@ -337,6 +338,7 @@ export interface Application {
fallback_speech_recognizer_vendor: null | string;
fallback_speech_recognizer_language: null | string;
fallback_speech_recognizer_label: null | string;
env_vars: null | Record<string, string | number | boolean>;
}
export interface PhoneNumber {
@@ -381,7 +383,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 {
@@ -420,6 +425,7 @@ 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;
@@ -431,6 +437,7 @@ export interface SpeechCredential {
deepgram_tts_uri: null | string;
deepgram_stt_use_tls: number;
speechmatics_stt_uri: null | string;
playht_tts_uri: null | string;
}
export interface Alert {
@@ -448,6 +455,8 @@ export interface CarrierRegisterStatus {
callId: null | string;
}
export type DtmfType = "rfc2833" | "tones" | "info";
export interface Carrier {
voip_carrier_sid: string;
name: string;
@@ -474,6 +483,8 @@ export interface Carrier {
smpp_inbound_password: null | string;
smpp_enquire_link_interval: number;
register_status: CarrierRegisterStatus;
dtmf_type: DtmfType;
outbound_sip_proxy: string | null;
}
export interface PredefinedCarrier extends Carrier {
@@ -551,6 +562,11 @@ export interface PageQuery {
days?: number;
}
export interface PhoneNumberQuery extends PageQuery {
account_sid?: string;
filter?: string;
}
export interface CallQuery extends PageQuery {
direction?: string;
answered?: string;
@@ -649,8 +665,9 @@ export interface Price {
recurring: Recurring;
stripe_price_id: null | string;
tiers_mode: null | string;
tiers?: null | Tier[];
type: null | string;
unit_amount: number;
unit_amount: null | number;
unit_amount_decimal: null | string;
}
@@ -669,9 +686,11 @@ export interface StripeCustomerId {
}
export interface Tier {
up_to: number;
flat_amount: number;
unit_amount: number;
up_to: null | number;
flat_amount: null | number;
unit_amount: null | number;
flat_amount_decimal: null | string;
unit_amount_decimal: null | string;
}
export interface ServiceData {
@@ -721,6 +740,7 @@ export interface SpeechSupportedLanguagesAndVoices {
tts: VoiceLanguage[];
stt: Language[];
models: Model[];
sttModels: Model[];
}
export interface ElevenLabsOptions {
@@ -748,3 +768,38 @@ 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;
}
export interface AppEnvProperty {
description: string;
type: string;
required?: boolean;
default?: string | number | boolean;
obscure?: boolean;
}
export interface AppEnv {
[key: string]: AppEnvProperty;
}

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

@@ -8,6 +8,8 @@ type PasswdProps = JSX.IntrinsicElements["input"] & {
locked?: boolean;
/** This is optional in case an onChange override is necessary... */
setValue?: React.Dispatch<React.SetStateAction<string>>;
/** Whether to ignore password managers */
ignorePasswordManager?: boolean;
};
type PasswdRef = HTMLInputElement;
@@ -22,16 +24,27 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
setValue,
placeholder,
locked = false,
ignorePasswordManager = true,
...restProps
}: PasswdProps,
ref,
) => {
const [reveal, setReveal] = useState(false);
// Create object with conditional password manager attributes
const passwordManagerAttributes = ignorePasswordManager
? {
"data-lpignore": "true",
"data-1p-ignore": "",
"data-form-type": "other",
"data-bwignore": "",
}
: {};
return (
<div className="passwd">
<input
autoComplete={"off"}
autoComplete="off"
ref={ref}
type={reveal ? "text" : "password"}
name={name}
@@ -43,6 +56,7 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
}
}}
{...restProps}
{...passwordManagerAttributes}
/>
{!locked && (
<button

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

@@ -0,0 +1,40 @@
import React, { useState } from "react";
import { Icons } from "src/components/icons";
import "./styles.scss";
interface ObscureInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const ObscureInput = ({
value,
onChange,
className = "",
...props
}: ObscureInputProps) => {
const [revealed, setRevealed] = useState(false);
return (
<div className="passwd">
<input
type={revealed ? "text" : "password"}
value={value}
onChange={onChange}
className={className}
{...props}
/>
<button
className="btnty"
type="button"
onClick={() => setRevealed(!revealed)}
aria-label={revealed ? "Hide text" : "Show text"}
>
{revealed ? <Icons.EyeOff /> : <Icons.Eye />}
</button>
</div>
);
};
export default ObscureInput;

View File

@@ -0,0 +1,39 @@
@use "src/styles/vars";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.obscure-input {
position: relative; // This is correct
width: 100%;
display: block; // Add this to ensure proper containing block
&__field {
width: 100%;
padding-right: 40px;
font-family: ui-vars.$font-mono;
box-sizing: border-box; // Add this to ensure padding doesn't expand width
}
&__toggle {
position: absolute;
right: 10px;
top: 0;
height: 100%; // Make the button take full height of input
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 2; // Ensure button is above input
svg {
stroke: ui-vars.$jambonz;
pointer-events: none;
width: 18px; // Control icon size
height: 18px;
}
}
}

View File

@@ -123,10 +123,13 @@ export const AccountForm = ({
const [tmpBucketVendor, setTmpBucketVendor] = useState("");
const [recordFormat, setRecordFormat] = useState("mp3");
const [bucketRegion, setBucketRegion] = useState("us-east-1");
const [tmpBucketRegion, setTmpBucketRegion] = useState("");
const [bucketName, setBucketName] = useState("");
const [tmpBucketName, setTmpBucketName] = useState("");
const [bucketAccessKeyId, setBucketAccessKeyId] = useState("");
const [tmpBucketAccessKeyId, setTmpBucketAccessKeyId] = useState("");
const [bucketSecretAccessKey, setBucketSecretAccessKey] = useState("");
const [tmpBucketSecretAccessKey, setTmpBucketSecretAccessKey] = useState("");
const [bucketCredentialChecked, setBucketCredentialChecked] = useState(false);
const [bucketTags, setBucketTags] = useState<AwsTag[]>([]);
const [bucketGoogleServiceKey, setBucketGoogleServiceKey] =
@@ -144,7 +147,9 @@ export const AccountForm = ({
const deleteMessageRef = useRef<HTMLInputElement | null>(null);
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const [azureConnectionString, setAzureConnectionString] = useState("");
const [tmpAzureConnectionString, setTmpAzureConnectionString] = useState("");
const [endpoint, setEndpoint] = useState("");
const [tmpEndpoint, setTmpEndpoint] = useState("");
const [enableDebugLog, setEnableDebugLog] = useState(false);
/** This lets us map and render the same UI for each... */
@@ -529,23 +534,34 @@ export const AccountForm = ({
setBucketName(account.data.bucket_credential?.name);
}
if (account.data.bucket_credential?.access_key_id) {
if (tmpBucketAccessKeyId) {
setBucketAccessKeyId(tmpBucketAccessKeyId);
} else if (account.data.bucket_credential?.access_key_id) {
setBucketAccessKeyId(account.data.bucket_credential?.access_key_id);
}
if (account.data.bucket_credential?.secret_access_key) {
if (tmpBucketSecretAccessKey) {
setBucketSecretAccessKey(tmpBucketSecretAccessKey);
} else if (account.data.bucket_credential?.secret_access_key) {
setBucketSecretAccessKey(
account.data.bucket_credential?.secret_access_key,
);
}
if (account.data.bucket_credential?.region) {
if (tmpBucketRegion) {
setBucketRegion(tmpBucketRegion);
} else if (account.data.bucket_credential?.region) {
setBucketRegion(account.data.bucket_credential?.region);
}
if (account.data.bucket_credential?.connection_string) {
if (tmpAzureConnectionString) {
setAzureConnectionString(tmpAzureConnectionString);
} else if (account.data.bucket_credential?.connection_string) {
setAzureConnectionString(
account.data.bucket_credential.connection_string,
);
}
if (account.data.bucket_credential?.endpoint) {
if (tmpEndpoint) {
setEndpoint(tmpEndpoint);
} else if (account.data.bucket_credential?.endpoint) {
setEndpoint(account.data.bucket_credential.endpoint);
}
if (account.data.record_all_calls) {
@@ -1103,6 +1119,7 @@ export const AccountForm = ({
value={endpoint}
onChange={(e) => {
setEndpoint(e.target.value);
setTmpEndpoint(e.target.value);
}}
/>
</>
@@ -1150,7 +1167,10 @@ export const AccountForm = ({
value: "",
},
].concat(regions["aws"])}
onChange={(e) => setBucketRegion(e.target.value)}
onChange={(e) => {
setBucketRegion(e.target.value);
setTmpBucketRegion(e.target.value);
}}
/>
</>
)}
@@ -1166,6 +1186,7 @@ export const AccountForm = ({
value={bucketAccessKeyId}
onChange={(e) => {
setBucketAccessKeyId(e.target.value);
setTmpBucketAccessKeyId(e.target.value);
}}
/>
<label htmlFor="bucket_aws_secret_key">
@@ -1179,6 +1200,7 @@ export const AccountForm = ({
value={bucketSecretAccessKey}
onChange={(e) => {
setBucketSecretAccessKey(e.target.value);
setTmpBucketSecretAccessKey(e.target.value);
}}
/>
</>
@@ -1227,6 +1249,7 @@ export const AccountForm = ({
value={azureConnectionString}
onChange={(e) => {
setAzureConnectionString(e.target.value);
setTmpAzureConnectionString(e.target.value);
}}
/>
</>

View File

@@ -257,7 +257,10 @@ const SubscriptionForm = () => {
[],
);
const initFeesAndCost = (priceData: PriceInfo[]) => {
const initFeesAndCost = (
priceData: PriceInfo[],
serviceData: ServiceData[],
) => {
serviceData.forEach((service) => {
const record = priceData.find(
(item) => item.category === service.category,
@@ -272,7 +275,23 @@ const SubscriptionForm = () => {
let fees = 0;
switch (price.billing_scheme) {
case "per_unit":
fees = (price.unit_amount * 1) / 100;
fees = ((price.unit_amount || 0) * 1) / 100;
break;
case "tiered":
if (price.tiers && price.tiers.length) {
const tier = price.tiers.find(
(item) => !item.up_to || item.up_to >= service.capacity,
);
if (tier) {
if (typeof tier.flat_amount === "number") {
fees = tier.flat_amount / 100;
} else {
fees = ((tier.unit_amount || 0) * 1) / 100;
}
}
service.tiers = price.tiers;
}
break;
default:
break;
@@ -283,6 +302,7 @@ const SubscriptionForm = () => {
service.product_sid = record.product_sid;
service.stripe_product_id = record.stripe_product_id;
service.fees = fees;
service.cost = fees * service.capacity;
service.feesLabel = `${
CurrencySymbol[service.currency || "usd"]
}${fees} per ${
@@ -294,7 +314,7 @@ const SubscriptionForm = () => {
}
});
setServiceData([...serviceData]);
return [...serviceData];
};
const getServicePrice = (
@@ -320,7 +340,7 @@ const SubscriptionForm = () => {
fees = tier.flat_amount / 100;
cost = fees;
} else {
fees = tier.unit_amount / 100;
fees = (tier.unit_amount || 0) / 100;
cost = fees * capacityNum;
}
}
@@ -362,22 +382,23 @@ const SubscriptionForm = () => {
key: string,
value: (typeof serviceData)[number][keyof ServiceData],
) => {
setServiceData(
serviceData.map((g, i) =>
i === index
? {
...g,
[key]: value,
...(key === "capacity" && { cost: Number(value) * g.fees }),
}
: g,
),
let serviceD = serviceData.map((g, i) =>
i === index
? {
...g,
[key]: value,
}
: g,
);
if (key === "capacity" && priceInfo) {
serviceD = initFeesAndCost(priceInfo, serviceD);
}
setServiceData([...serviceD]);
};
useEffect(() => {
if (priceInfo) {
initFeesAndCost(priceInfo);
setServiceData(initFeesAndCost(priceInfo, serviceData));
}
if (userData && priceInfo) {

View File

@@ -47,7 +47,12 @@ export const Alerts = () => {
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) }),
};
getAlerts(accountSid, payload)
@@ -103,7 +108,7 @@ export const Alerts = () => {
id="date_filter"
label="Date"
filter={[dateFilter, setDateFilter]}
options={DATE_SELECTION.slice(0, 2)}
options={DATE_SELECTION}
/>
</section>
<Section {...(hasLength(alerts) && { slim: true })}>

View File

@@ -1,9 +1,9 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { ClipBoard, Section } from "src/components";
import { ClipBoard, Section, Tooltip } from "src/components";
import {
Selector,
Checkzone,
@@ -23,6 +23,7 @@ import {
putApplication,
useServiceProviderData,
useApiData,
getAppEnvSchema,
} from "src/api";
import {
ROUTE_INTERNAL_ACCOUNTS,
@@ -48,11 +49,13 @@ import type {
WebhookMethod,
UseApiDataMap,
SpeechCredential,
AppEnv,
} from "src/api/types";
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
import { hasLength, isUserAccountScope, useRedirect } from "src/utils";
import { setAccountFilter, setLocation } from "src/store/localStore";
import SpeechProviderSelection from "./speech-selection";
import ObscureInput from "src/components/obscure-input";
type ApplicationFormProps = {
application?: UseApiDataMap<Application>;
@@ -77,11 +80,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const [tmpStatusWebhook, setTmpStatusWebhook] =
useState<WebHook>(DEFAULT_WEBHOOK);
const [initialStatusWebhook, setInitialStatusWebhook] = useState(false);
const [messageWebhook, setMessageWebhook] =
useState<WebHook>(DEFAULT_WEBHOOK);
const [tmpMessageWebhook, setTmpMessageWebhook] =
useState<WebHook>(DEFAULT_WEBHOOK);
const [initialMessageWebhook, setInitialMessageWebhook] = useState(false);
const [synthVendor, setSynthVendor] =
useState<keyof SynthesisVendors>(VENDOR_GOOGLE);
const [synthLang, setSynthLang] = useState(LANG_EN_US);
@@ -127,6 +125,12 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
useState("");
const [initalCheckFallbackSpeech, setInitalCheckFallbackSpeech] =
useState(false);
const [appEnv, setAppEnv] = useState<AppEnv | null>(null);
const appEnvTimeoutRef = useRef<number | null>(null);
const [envVars, setEnvVars] = useState<Record<
string,
string | number | boolean
> | null>(null);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -139,6 +143,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tmpStateSet: setTmpCallWebhook,
initialCheck: initialCallWebhook,
required: true,
webhookEnv: appEnv,
},
{
label: "Call status",
@@ -150,16 +155,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
initialCheck: initialStatusWebhook,
required: true,
},
{
label: "Messaging",
prefix: "message_webhook",
stateVal: messageWebhook,
tmpStateVal: tmpMessageWebhook,
stateSet: setMessageWebhook,
tmpStateSet: setTmpMessageWebhook,
initialCheck: initialMessageWebhook,
required: false,
},
];
useRedirect<Account>(
@@ -202,7 +197,6 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
app_json: applicationJson || null,
call_hook: callWebhook || null,
account_sid: accountSid || null,
messaging_hook: messageWebhook || null,
call_status_hook: statusWebhook || null,
speech_synthesis_vendor: synthVendor || null,
speech_synthesis_language: synthLang || null,
@@ -213,6 +207,25 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
speech_recognizer_label: recogLabel || null,
record_all_calls: recordAllCalls ? 1 : 0,
use_for_fallback_speech: useForFallbackSpeech ? 1 : 0,
env_vars: envVars
? Object.keys(envVars).reduce((acc, key) => {
const value = envVars[key];
// Keep only values that:
// 1. Are defined in appEnv schema
// 2. Are not empty strings, undefined, or null
// 3. For booleans and numbers, keep them even if they're false or 0
if (
appEnv &&
appEnv[key] &&
(value === false ||
value === 0 ||
(value !== "" && value != null))
) {
return { ...acc, [key]: value };
}
return acc;
}, {})
: null,
fallback_speech_synthesis_vendor: useForFallbackSpeech
? fallbackSpeechSynthsisVendor || null
: null,
@@ -471,24 +484,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
else setInitialStatusWebhook(false);
}
if (application.data.messaging_hook) {
setMessageWebhook(application.data.messaging_hook);
setTmpMessageWebhook(application.data.messaging_hook);
if (
application.data.messaging_hook.username ||
application.data.messaging_hook.password
)
setInitialMessageWebhook(true);
else setInitialMessageWebhook(false);
}
if (application.data.account_sid)
setAccountSid(application.data.account_sid);
if (application.data.messaging_hook)
setMessageWebhook(application.data.messaging_hook);
if (application.data.speech_synthesis_vendor)
setSynthVendor(
application.data.speech_synthesis_vendor as keyof SynthesisVendors,
@@ -544,6 +542,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
application.data.fallback_speech_synthesis_voice,
);
}
if (application.data.env_vars) {
setEnvVars(application.data.env_vars);
}
}
}, [application]);
@@ -579,6 +580,33 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
setFallbackSpeechRecognizerLabel(tmp);
};
useEffect(() => {
if (callWebhook && callWebhook.url) {
// Clear any existing timeout to prevent multiple requests
if (appEnvTimeoutRef.current) {
clearTimeout(appEnvTimeoutRef.current);
appEnvTimeoutRef.current = null;
}
appEnvTimeoutRef.current = setTimeout(() => {
getAppEnvSchema(callWebhook.url)
.then(({ json }) => {
setAppEnv(json);
})
.catch((error) => {
setMessage(error.msg);
});
}, 500);
}
return () => {
if (appEnvTimeoutRef.current) {
clearTimeout(appEnvTimeoutRef.current);
appEnvTimeoutRef.current = null;
}
};
}, [callWebhook]);
return (
<Section slim>
<form
@@ -640,7 +668,29 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
webhook.stateSet({
...webhook.stateVal,
url: e.target.value,
...(e.target.value.startsWith("ws") && {
method: "GET",
}),
});
if (
e.target.value.startsWith("ws") &&
webhook.prefix === "call_webhook"
) {
const statusWebhook = webhooks.find(
(w) => w.prefix === "status_webhook",
);
if (
statusWebhook &&
((statusWebhook.stateVal?.url || "").length === 0 ||
statusWebhook.stateVal?.url.startsWith("ws"))
) {
statusWebhook.stateSet({
...statusWebhook.stateVal,
url: e.target.value,
method: "GET",
});
}
}
}}
/>
</div>
@@ -656,6 +706,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
method: e.target.value as WebhookMethod,
});
}}
disabled={webhook.stateVal?.url.startsWith("ws")}
options={WEBHOOK_METHODS}
/>
</div>
@@ -707,6 +758,135 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}}
/>
</Checkzone>
{webhook.webhookEnv &&
Object.keys(webhook.webhookEnv).length > 0 && (
<>
{Object.keys(webhook.webhookEnv).map((key) => {
const envType = webhook.webhookEnv![key].type;
const isBoolean = envType === "boolean";
const isNumber = envType === "number";
const defaultValue = webhook.webhookEnv![key].default;
return (
<div className="inp" key={key}>
{isBoolean ? (
// Boolean input as checkbox
<label htmlFor={`env_${key}`} className="chk">
<input
id={`env_${key}`}
type="checkbox"
name={`env_${key}`}
required={webhook.webhookEnv![key].required}
checked={
envVars && envVars[key] !== undefined
? Boolean(envVars[key])
: Boolean(defaultValue)
}
onChange={(e) => {
setEnvVars({
...(envVars || {}),
[key]: e.target.checked,
});
}}
/>
<Tooltip
text={webhook.webhookEnv![key].description}
>
{key}
{webhook.webhookEnv![key].required && (
<span>*</span>
)}
</Tooltip>
</label>
) : (
// Text or number input
<>
<label htmlFor={`env_${key}`}>
<Tooltip
text={webhook.webhookEnv![key].description}
>
{key}
{webhook.webhookEnv![key].required && (
<span>*</span>
)}
</Tooltip>
</label>
{webhook.webhookEnv![key].obscure ? (
<ObscureInput
name={`env_${key}`}
id={`env_${key}`}
placeholder={
webhook.webhookEnv![key].description
}
required={webhook.webhookEnv![key].required}
value={
envVars && envVars[key] !== undefined
? String(envVars[key])
: defaultValue !== undefined
? String(defaultValue)
: ""
}
onChange={(e) => {
// Convert to proper type based on schema
let newValue;
if (isNumber) {
newValue =
e.target.value === ""
? ""
: Number(e.target.value);
} else {
newValue = e.target.value;
}
setEnvVars({
...(envVars || {}),
[key]: newValue,
});
}}
/>
) : (
<input
id={`env_${key}`}
type={isNumber ? "number" : "text"}
name={`env_${key}`}
placeholder={
webhook.webhookEnv![key].description
}
required={webhook.webhookEnv![key].required}
value={
envVars && envVars[key] !== undefined
? String(envVars[key])
: defaultValue !== undefined
? String(defaultValue)
: ""
}
onChange={(e) => {
// Convert to proper type based on schema
let newValue;
if (isNumber) {
newValue =
e.target.value === ""
? ""
: Number(e.target.value);
} else {
newValue = e.target.value;
}
setEnvVars({
...(envVars || {}),
[key]: newValue,
});
}}
/>
)}
</>
)}
</div>
);
})}
</>
)}
</fieldset>
);
})}

View File

@@ -30,6 +30,10 @@ import {
VENDOR_WHISPER,
VENDOR_SPEECHMATICS,
VENDOR_PLAYHT,
VENDOR_CARTESIA,
VENDOR_VOXIST,
VENDOR_RIMELABS,
VENDOR_OPENAI,
} from "src/vendor";
import {
LabelOptions,
@@ -198,7 +202,7 @@ export const SpeechProviderSelection = ({
setSynthesisVoiceOptions(voicesOpts);
}
if (synthesisGoogleCustomVoiceOptions.length > 0) {
updateTtsVoice(synthesisGoogleCustomVoiceOptions[0].value);
updateTtsVoice(synthLang, synthesisGoogleCustomVoiceOptions[0].value);
}
}
// PlayHT3.0 all voices are listed under english language, all voices can be used for multiple languages
@@ -259,29 +263,39 @@ export const SpeechProviderSelection = ({
(!googleLang ||
!googleLang.voices.find((v) => v.value === synthVoice))
) {
setSynthLang(LANG_EN_US);
updateTtsVoice(LANG_EN_US_STANDARD_C);
updateTtsVoice(LANG_EN_US, LANG_EN_US_STANDARD_C);
return;
}
if (synthVendor === VENDOR_ELEVENLABS) {
// Samve Voices applied to all languages
// Voices are only available for the 1st language.
setSynthLang(ELEVENLABS_LANG_EN);
updateTtsVoice(json.tts[0].voices[0].value);
updateTtsVoice(ELEVENLABS_LANG_EN, json.tts[0].voices[0].value);
return;
}
if (synthVendor === VENDOR_WHISPER) {
const newLang = json.tts.find((lang) => lang.value === LANG_EN_US);
setSynthLang(LANG_EN_US);
updateTtsVoice(newLang!.voices[0].value);
updateTtsVoice(LANG_EN_US, 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);
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_CARTESIA) {
const newLang = json.tts.find((lang) => lang.value === "en");
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
if (synthVendor === VENDOR_RIMELABS) {
let newLang = json.tts.find((lang) => lang.value === "eng");
// If the new language doesn't map then default to the first one
if (!newLang) {
newLang = json.tts[0];
}
updateTtsVoice(newLang!.value, newLang!.voices[0].value);
return;
}
/** Google and AWS have different language lists */
@@ -289,14 +303,13 @@ export const SpeechProviderSelection = ({
let newLang = json.tts.find((lang) => lang.value === synthLang);
if (newLang) {
updateTtsVoice(newLang.voices[0].value);
updateTtsVoice(synthLang, newLang.voices[0].value);
return;
}
newLang = json.tts.find((lang) => lang.value === LANG_EN_US);
setSynthLang(LANG_EN_US);
updateTtsVoice(newLang!.voices[0].value);
updateTtsVoice(LANG_EN_US, newLang!.voices[0].value);
}
})
.catch((error) => {
@@ -322,9 +335,10 @@ export const SpeechProviderSelection = ({
}
};
const updateTtsVoice = (value: string) => {
const updateTtsVoice = (language: string, voice: string) => {
if (shouldUpdateTtsVoice.current) {
setSynthVoice(value);
setSynthLang(language);
setSynthVoice(voice);
shouldUpdateTtsVoice.current = false;
}
};
@@ -384,9 +398,11 @@ export const SpeechProviderSelection = ({
options={ttsVendorOptions.filter(
(vendor) =>
vendor.value !== VENDOR_ASSEMBLYAI &&
vendor.value !== VENDOR_VOXIST &&
vendor.value !== VENDOR_SONIOX &&
vendor.value !== VENDOR_SPEECHMATICS &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_OPENAI &&
vendor.value !== VENDOR_COBALT,
)}
onChange={(e) => {

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("");
@@ -116,6 +119,9 @@ export const CarrierForm = ({
const [diversion, setDiversion] = useState("");
const [initialDiversion, setInitialDiversion] = useState(false);
const [initialSipProxy, setInitialSipProxy] = useState(false);
const [outboundSipProxy, setOutboundSipProxy] = useState("");
const [smppSystemId, setSmppSystemId] = useState("");
const [smppPass, setSmppPass] = useState("");
const [smppInboundSystemId, setSmppInboundSystemId] = useState("");
@@ -139,6 +145,78 @@ export const CarrierForm = ({
const [smppInboundMessage, setSmppInboundMessage] = useState("");
const [smppOutboundMessage, setSmppOutboundMessage] = useState("");
const validateOutboundSipGateway = (
gateway: string,
acceptPort: boolean = false,
): boolean => {
/** validate outbound sip gateway formats:
* - IP address (e.g., "192.168.1.1")
* - DNS name (e.g., "example.com")
* - Domain with port (e.g., "example.com:5060")
* - sip:IP or domain (e.g., "sip:example.com")
* - sips:IP or domain (e.g., "sips:example.com")
* - sip:IP or domain with port (e.g., "sip:example.com:5060")
* - Full SIP URI with optional port (e.g., "sip:user@example.com:5060")
*/
// First handle URIs with colon but not sip: or sips: prefix
if (gateway.includes(":")) {
// Check if it's a domain:port format (without sip prefix)
if (!gateway.startsWith("sip:") && !gateway.startsWith("sips:")) {
if (!acceptPort) {
return false; // Reject domain:port if ports not accepted
}
// Extract domain part for validation
const parts = gateway.split(":");
const domain = parts[0];
// Validate domain part
const domainType = getIpValidationType(domain);
if (domainType === INVALID) {
return false;
}
// Optionally validate port range
if (parts.length > 1) {
const port = parseInt(parts[1]);
if (isNaN(port) || port < 1 || port > 65535) {
return false;
}
}
return true;
}
// Handle sip: or sips: URIs
// Use regex to properly extract domain (and port if present)
const sipUriPattern = /^(sip|sips):(?:([^@]+)@)?([^:@]+)(?::(\d+))?/;
const match = gateway.match(sipUriPattern);
if (match) {
const domain = match[3];
const domainType = getIpValidationType(domain);
if (domainType === INVALID) {
return false;
}
// If port is present, validate it
if (match[4] && !acceptPort) {
return false; // Reject if port not accepted
}
return true;
}
return false;
}
// Simple IP or domain name without any colons
const gatewayType = getIpValidationType(gateway);
return gatewayType !== INVALID;
};
const setCarrierStates = (obj: Carrier) => {
if (obj) {
setIsActive(obj.is_active);
@@ -204,6 +282,13 @@ export const CarrierForm = ({
setInitialDiversion(false);
}
if (obj.outbound_sip_proxy) {
setOutboundSipProxy(obj.outbound_sip_proxy);
setInitialSipProxy(true);
} else {
setInitialSipProxy(false);
}
if (obj.smpp_system_id) {
setSmppSystemId(obj.smpp_system_id);
}
@@ -216,6 +301,9 @@ export const CarrierForm = ({
if (obj.smpp_inbound_password) {
setSmppInboundPass(obj.smpp_inbound_password);
}
if (obj.dtmf_type) {
setDtmfType(obj.dtmf_type);
}
}
};
@@ -502,6 +590,14 @@ export const CarrierForm = ({
}
}
if (
isNotBlank(outboundSipProxy) &&
!validateOutboundSipGateway(outboundSipProxy, true)
) {
toastError("Please provide a valid SIP Proxy domain or IP address.");
return;
}
if (currentServiceProvider) {
const carrierPayload: Partial<Carrier> = {
name: carrierName.trim(),
@@ -524,6 +620,8 @@ export const CarrierForm = ({
smpp_password: smppPass.trim() || null,
smpp_inbound_system_id: smppInboundSystemId.trim() || null,
smpp_inbound_password: smppInboundPass.trim() || null,
dtmf_type: dtmfType,
outbound_sip_proxy: outboundSipProxy.trim().replaceAll(" ", "") || null,
};
if (carrier && carrier.data) {
@@ -764,6 +862,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 &&
@@ -947,6 +1062,33 @@ export const CarrierForm = ({
/>
</Checkzone>
</fieldset>
<fieldset>
<Checkzone
hidden
name="outbound_sip_proxy"
label="Outbound SIP Proxy"
initialCheck={initialSipProxy}
handleChecked={(e) => {
if (!e.target.checked) {
setOutboundSipProxy("");
}
}}
>
<MS>
Send all calls to this carrier through an outbound proxy
</MS>
<input
id="outbound_sip_proxy"
name="outbound_sip_proxy"
type="text"
value={outboundSipProxy}
placeholder="Outbound Sip Proxy"
onChange={(e) => {
setOutboundSipProxy(e.target.value);
}}
/>
</Checkzone>
</fieldset>
<fieldset>
<label htmlFor="sip_gateways">
SIP gateways<span>*</span>

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 {
@@ -41,12 +41,13 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
const [phoneNumbers] = useServiceProviderData<PhoneNumber[]>("PhoneNumbers");
const [carriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [voipCarriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [phoneNumberNum, setPhoneNumberNum] = useState("");
const [accountSid, setAccountSid] = useState("");
const [sipTrunkSid, setSipTrunkSid] = useState("");
const [applicationSid, setApplicationSid] = useState("");
const [message, setMessage] = useState("");
const [carriers, setCarriers] = useState<Carrier[]>(voipCarriers || []);
useRedirect<Account>(
accounts,
@@ -55,7 +56,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
);
useRedirect<Carrier>(
carriers,
voipCarriers,
ROUTE_INTERNAL_CARRIERS,
"You must create a SIP trunk before you can create a phone number.",
);
@@ -138,6 +139,20 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
}
}, [carriers, sipTrunkSid]);
// Filter carriers based on account_sid
useEffect(() => {
if (voipCarriers) {
setCarriers(
voipCarriers?.filter(
(carrier) =>
!accountSid ||
(carrier.is_active &&
(!carrier.account_sid || carrier.account_sid === accountSid)),
),
);
}
}, [accountSid, voipCarriers]);
return (
<>
<Section slim>
@@ -165,11 +180,17 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
disabled={phoneNumber ? true : false}
></input>
</fieldset>
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
/>
</fieldset>
<fieldset>
<label htmlFor="sip_trunk">
Carrier <span>*</span>
</label>
<Selector
<TypeaheadSelector
id="sip_trunk"
name="sip_trunk"
required
@@ -188,12 +209,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
disabled={phoneNumber ? true : false}
/>
</fieldset>
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
/>
</fieldset>
<fieldset>
<ApplicationSelect
defaultOption="Choose application"

View File

@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import {
deletePhoneNumber,
getPhoneNumbers,
putPhoneNumber,
useServiceProviderData,
} from "src/api";
@@ -30,7 +31,7 @@ import {
import { DeletePhoneNumber } from "./delete";
import type { Account, PhoneNumber, Carrier, Application } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
import { ENABLE_PHONE_NUMBER_LAZY_LOAD, USER_ACCOUNT } from "src/api/constants";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
@@ -41,8 +42,7 @@ export const PhoneNumbers = () => {
const [applications] = useServiceProviderData<Application[]>("Applications");
const [carriers] = useServiceProviderData<Carrier[]>("VoipCarriers");
const [phoneNumber, setPhoneNumber] = useState<PhoneNumber | null>(null);
const [phoneNumbers, refetch] =
useServiceProviderData<PhoneNumber[]>("PhoneNumbers");
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumber[] | null>(null);
const [selectedPhoneNumbers, setSelectedPhoneNumbers] = useState<
PhoneNumber[]
>([]);
@@ -59,12 +59,30 @@ export const PhoneNumbers = () => {
(phn) => !accountSid || phn.account_sid === accountSid,
)
: [];
}, [accountSid, phoneNumbers]);
}, [phoneNumbers, ...[!ENABLE_PHONE_NUMBER_LAZY_LOAD && accountSid]]);
const filteredPhoneNumbers = useFilteredResults<PhoneNumber>(
filter,
phoneNumbersFiltered,
);
const filteredPhoneNumbers = !ENABLE_PHONE_NUMBER_LAZY_LOAD
? useFilteredResults<PhoneNumber>(filter, phoneNumbersFiltered)
: phoneNumbersFiltered;
const refetch = () => {
getPhoneNumbers(
!ENABLE_PHONE_NUMBER_LAZY_LOAD
? {}
: {
...(accountSid && { account_sid: accountSid }),
...(filter && { filter }),
},
)
.then(({ json }) => {
if (json) {
setPhoneNumbers(json);
}
})
.catch((error) => {
toastError(error.msg);
});
};
const handleMassEdit = () => {
Promise.all(
@@ -114,6 +132,15 @@ export const PhoneNumbers = () => {
}
}, [user]);
useEffect(() => {
if (ENABLE_PHONE_NUMBER_LAZY_LOAD) {
setPhoneNumbers([]);
return;
}
refetch();
}, []);
return (
<>
<section className="mast">
@@ -141,6 +168,19 @@ export const PhoneNumbers = () => {
defaultOption
/>
</ScopedAccess>
{ENABLE_PHONE_NUMBER_LAZY_LOAD && (
<ButtonGroup>
<Button
small
onClick={() => {
setPhoneNumbers(null);
refetch();
}}
>
Search
</Button>
</ButtonGroup>
)}
</section>
<Section {...(hasLength(filteredPhoneNumbers) && { slim: true })}>
<div className="list">

View File

@@ -0,0 +1,131 @@
import dayjs from "dayjs";
import React, { useEffect, useState } from "react";
import { getRecentCallLog } from "src/api";
import { RecentCall } from "src/api/types";
import { Icons, Spinner } from "src/components";
import { toastError, toastSuccess } from "src/store";
import { hasValue } from "src/utils";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
type CallSystemLogsProps = {
call: RecentCall;
};
// Helper function to format logs
const formatLog = (log: string): string => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const parsedLog = JSON.parse(log) as any;
const l = {
...parsedLog,
time: dayjs(parsedLog.time).utc().format("YYYY-MM-DD HH:mm:ssZ"),
};
return JSON.stringify(l, null, 2);
} catch {
return log;
}
};
export default function CallSystemLogs({ call }: CallSystemLogsProps) {
const [logs, setLogs] = useState<string[] | null>();
const [loading, setLoading] = useState(false);
const [count, setCount] = useState(0);
useEffect(() => {}, [call]);
const getLogs = () => {
setLoading(true);
setCount((prev) => prev + 1);
if (call && call.account_sid && call.call_sid) {
getRecentCallLog(call.account_sid, call.call_sid)
.then(({ json }) => {
setLogs(json);
})
.catch((err) => {
if (err.status === 404) {
toastError("There is no log for this call");
} else {
toastError(err.msg);
}
})
.finally(() => {
setLoading(false);
});
}
};
const copyToClipboard = () => {
if (!logs) {
return;
}
const textToCopy = logs.map(formatLog).join("\n\n");
navigator.clipboard
.writeText(textToCopy)
.then(() => toastSuccess("Logs copied to clipboard"))
.catch(() => toastError("Failed to copy logs"));
};
const downloadLogs = () => {
if (!logs) {
return;
}
const textToDownload = logs.map(formatLog).join("\n\n");
const blob = new Blob([textToDownload], { type: "text/plain" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${call.call_sid}.log`;
a.click();
URL.revokeObjectURL(a.href);
};
return (
<>
<>
<div className="log-container">
<div className="log-buttons">
<button
onClick={getLogs}
className="log-retrieve-button"
title="Retrieve Logs"
disabled={loading}
>
<div style={{ display: "flex", gap: "5px" }}>
Retrieve Logs
{loading && <Spinner small />}
</div>
</button>
{hasValue(logs) && logs.length !== 0 && (
<>
<button
onClick={copyToClipboard}
className="log-button"
title="Copy to clipboard"
>
<Icons.Clipboard />
</button>
<button
onClick={downloadLogs}
className="log-button"
title="Download logs"
>
<Icons.Download />
</button>
</>
)}
</div>
<pre className="log-content">
{hasValue(logs) && logs.length !== 0
? logs?.map((log, index) => (
<div key={index}>{formatLog(log)}</div>
))
: count !== 0 && logs === null
? "No logs found"
: ""}
</pre>
</div>
</>
</>
);
}

View File

@@ -8,9 +8,10 @@ import type { RecentCall } from "src/api/types";
import { Tabs, Tab } from "@jambonz/ui-kit";
import CallDetail from "./call-detail";
import CallTracing from "./call-tracing";
import { DISABLE_JAEGER_TRACING } from "src/api/constants";
import { AWS_REGION, DISABLE_JAEGER_TRACING } from "src/api/constants";
import { Player } from "./player";
import "./styles.scss";
import CallSystemLogs from "./call-system-logs";
type DetailsItemProps = {
call: RecentCall;
@@ -78,6 +79,13 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
<Tab id="tracing" label="Tracing">
{open && <CallTracing call={call} />}
</Tab>
{hasValue(AWS_REGION) ? (
<Tab id="logs" label="Logs">
{open && <CallSystemLogs call={call} />}
</Tab>
) : (
<></>
)}
</Tabs>
)}
{open && (

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

@@ -85,3 +85,82 @@
margin-top: ui-vars.$px01;
}
}
/* CallSystemLogs.css */
/* Styles for the log container */
.log-container {
border-radius: 8px;
position: relative;
background: #1a1a1a; /* Dark background for the container (optional, if you want the entire container dark) */
color: #ffffff; /* Ensure text is visible on dark background */
}
/* Styles for the log buttons container (optional, if you want to style it separately) */
.log-buttons {
position: absolute;
top: 10px;
right: 25px;
display: flex;
gap: 12px;
}
/* Styles for the log content (pre element) */
.log-content {
margin-top: 16px;
background: #1a1a1a; /* Darker background for the log content */
overflow: auto;
min-height: 250px;
max-height: 800px;
font-family: monospace;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); /* Slightly darker shadow for contrast */
color: #e0e0e0; /* Light gray text for visibility on dark background */
}
/* Optional: Style for individual log entries (divs within pre) */
.log-content div {
margin-bottom: 10px;
}
/* Styles for log buttons */
.log-button {
padding: 8px;
cursor: pointer;
border: none;
border-radius: 50%;
background: #fff3f6; /* Light gray background for buttons, unchanged */
color: #da1c5c;
transition: transform 0.1s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.log-retrieve-button {
@extend .log-button;
border-radius: 8px;
width: auto;
height: 20px;
}
/* Hover state for buttons */
.log-button:hover {
background: #d5d5d5;
transform: scale(1.05);
}
.log-fetch-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%; /* Adjust based on your layout */
gap: 10px;
padding: 20px;
}

View File

@@ -18,6 +18,7 @@ import {
getGoogleCustomVoices,
getSpeechSupportedLanguagesAndVoices,
postGoogleCustomVoice,
postGoogleVoiceCloningKey,
postSpeechService,
putGoogleCustomVoice,
putSpeechService,
@@ -48,6 +49,9 @@ import {
AWS_INSTANCE_PROFILE,
VENDOR_VERBIO,
VENDOR_SPEECHMATICS,
VENDOR_CARTESIA,
VENDOR_VOXIST,
VENDOR_OPENAI,
} from "src/vendor";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import {
@@ -76,8 +80,9 @@ import type {
import { setAccountFilter, setLocation } from "src/store/localStore";
import {
ADDITIONAL_SPEECH_VENDORS,
DEFAULT_CARTESIA_OPTIONS,
DEFAULT_ELEVENLABS_OPTIONS,
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
DEFAULT_GOOGLE_CUSTOM_VOICE,
DEFAULT_PLAYHT_OPTIONS,
DEFAULT_RIMELABS_OPTIONS,
DEFAULT_VERBIO_MODEL,
@@ -120,6 +125,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [ttsRegion, setTtsRegion] = useState("");
const [ttsApiKey, setTtsApiKey] = useState("");
const [ttsModelId, setTtsModelId] = useState("");
const [sttModelId, setSttModelId] = useState("");
const [engineVersion, setEngineVersion] = useState(DEFAULT_VERBIO_MODEL);
const [instanceId, setInstanceId] = useState("");
const [initialCheckCustomTts, setInitialCheckCustomTts] = useState(false);
@@ -142,6 +148,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [tmpCustomVendorTtsUrl, setTmpCustomVendorTtsUrl] = useState("");
const [customVendorTtsUrl, setCustomVendorTtsUrl] = useState("");
const [tmpCustomVendorSttUrl, setTmpCustomVendorSttUrl] = useState("");
const [customVendorTtsStreamingUrl, setCustomVendorTtsStreamingUrl] =
useState("");
const [tmpCustomVendorTtsStreamingUrl, setTmpCustomVendorTtsStreamingUrl] =
useState("");
const [customVendorSttUrl, setCustomVendorSttUrl] = useState("");
const [initialOnPremNuanceTtsCheck, setInitialOnPremNuanceTtsCheck] =
useState(false);
@@ -159,6 +169,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const [customVoices, setCustomVoices] = useState<GoogleCustomVoice[]>([]);
const [customVoicesMessage, setCustomVoicesMessage] = useState("");
const [ttsModels, setTtsModels] = useState<Model[]>([]);
const [sttModels, setSttModels] = useState<Model[]>([]);
const [optionsInitialChecked, setOptionsInitialChecked] = useState(false);
const [options, setOptions] = useState("");
const [tmpOptions, setTmpOptions] = useState("");
@@ -181,7 +192,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
AWS_CREDENTIAL_ACCESS_KEY,
);
const [roleArn, setRoleArn] = useState("");
const [playhtTtsUri, setPlayhtTtsUri] = useState("");
const [tmpPlayhtTtsUri, setTmpPlayhtTtsUri] = useState("");
const [initialPlayhtOnpremCheck, setInitialPlayhtOnpremCheck] =
useState(false);
const handleFile = (file: File) => {
const handleError = () => {
setGoogleServiceKey(null);
@@ -217,6 +231,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
return DEFAULT_PLAYHT_OPTIONS;
case VENDOR_RIMELABS:
return DEFAULT_RIMELABS_OPTIONS;
case VENDOR_CARTESIA:
return DEFAULT_CARTESIA_OPTIONS;
}
}
return "";
@@ -231,11 +247,24 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
return "https://docs.play.ht/reference/api-generate-tts-audio-stream";
case VENDOR_RIMELABS:
return "https://rimelabs.mintlify.app/api-reference/endpoint/streaming-mp3#variable-parameters";
case VENDOR_CARTESIA:
return "https://docs.cartesia.ai/api-reference/tts/bytes";
}
}
return "";
};
const getModelLabelByVendor = (vendor: Lowercase<Vendor>) => {
switch (vendor) {
case VENDOR_PLAYHT:
return "Voice Engine";
case VENDOR_CARTESIA:
return "Model ID";
default:
return "Model";
}
};
const handlePutGoogleCustomVoices = () => {
if (!credential || !credential.data) {
return;
@@ -243,14 +272,43 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (useCustomVoicesCheck) {
Promise.all(
customVoices.map((v) => {
// voice cloning key is 200kb file, the content should be uploaded in separated api
const voice_cloning_key = v.voice_cloning_key_file;
delete v.voice_cloning_key_file;
delete v.voice_cloning_key;
const uploadVoiceCloningKey = (sid: string) => {
if (voice_cloning_key) {
return postGoogleVoiceCloningKey(sid, voice_cloning_key);
}
};
if (v.google_custom_voice_sid) {
const sid = v.google_custom_voice_sid;
delete v.google_custom_voice_sid;
return putGoogleCustomVoice(sid, v);
return new Promise((res, rej) => {
putGoogleCustomVoice(sid, v)
.then((resp) => {
if (!voice_cloning_key) {
return res(resp);
}
uploadVoiceCloningKey(sid)?.then(res).catch(rej);
})
.catch(rej);
});
} else {
return postGoogleCustomVoice({
...v,
speech_credential_sid: credential.data?.speech_credential_sid,
return new Promise((res, rej) => {
postGoogleCustomVoice({
...v,
speech_credential_sid: credential.data?.speech_credential_sid,
})
.then(({ json }) => {
if (!voice_cloning_key) {
return res(json);
}
uploadVoiceCloningKey(json.sid)?.then(res).catch(rej);
})
.catch(rej);
});
}
}),
@@ -265,7 +323,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
.catch((error) => {
toastError(error.msg);
});
} else if (useCustomVoicesCheck && customVoices.length > 0) {
} else if (!useCustomVoicesCheck && customVoices.length > 0) {
Promise.all(
customVoices.map((v) => {
if (v.google_custom_voice_sid) {
@@ -334,11 +392,16 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
...(vendor === VENDOR_NVIDIA && {
riva_server_uri: rivaServerUri || null,
}),
...(vendor === VENDOR_CARTESIA && {
model_id: ttsModelId || null,
options: options || null,
}),
...(vendor === VENDOR_CUSTOM && {
vendor: (vendor + ":" + customVendorName) as Lowercase<Vendor>,
use_for_tts: ttsCheck ? 1 : 0,
use_for_stt: sttCheck ? 1 : 0,
custom_tts_url: customVendorTtsUrl || null,
custom_tts_streaming_url: customVendorTtsStreamingUrl || null,
custom_stt_url: customVendorSttUrl || null,
auth_token: customVendorAuthToken || null,
}),
@@ -365,6 +428,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
ttsModelId && {
voice_engine: ttsModelId,
}),
...(vendor === VENDOR_OPENAI &&
sttModelId && {
model_id: sttModelId,
}),
...(vendor === VENDOR_DEEPGRAM && {
deepgram_stt_uri: deepgramSttUri || null,
deepgram_tts_uri: deepgramTtsUri || null,
@@ -376,6 +443,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
...(vendor === VENDOR_VERBIO && {
engine_version: engineVersion,
}),
...(vendor === VENDOR_PLAYHT && {
playht_tts_uri: playhtTtsUri || null,
}),
};
if (credential && credential.data) {
@@ -416,12 +486,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_WELLSAID ||
vendor === VENDOR_DEEPGRAM ||
vendor === VENDOR_ASSEMBLYAI ||
vendor === VENDOR_VOXIST ||
vendor === VENDOR_SONIOX ||
vendor === VENDOR_SPEECHMATICS ||
vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_WHISPER
vendor === VENDOR_WHISPER ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_OPENAI
? apiKey
: null,
}),
@@ -438,12 +511,32 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
.then(({ json }) => {
if (vendor === VENDOR_GOOGLE && useCustomVoicesCheck) {
Promise.all(
customVoices.map((v) =>
postGoogleCustomVoice({
...v,
speech_credential_sid: json.sid,
}),
),
customVoices.map((v) => {
// voice cloning key is 200kb file, the content should be uploaded in separated api
const voice_cloning_key = v.voice_cloning_key_file;
delete v.voice_cloning_key_file;
delete v.voice_cloning_key;
const uploadVoiceCloningKey = (sid: string) => {
if (voice_cloning_key) {
return postGoogleVoiceCloningKey(sid, voice_cloning_key);
}
};
return new Promise((res, rej) => {
postGoogleCustomVoice({
...v,
speech_credential_sid: json.sid,
})
.then(({ json }) => {
if (!voice_cloning_key) {
res(json);
}
uploadVoiceCloningKey(json.sid)?.then(res).catch(rej);
})
.catch(rej);
});
}),
).then(() => {
toastSuccess("Speech credential created successfully");
navigate(ROUTE_INTERNAL_SPEECH);
@@ -467,7 +560,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_OPENAI
) {
getSpeechSupportedLanguagesAndVoices(
currentServiceProvider?.service_provider_sid,
@@ -484,6 +579,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setTtsModelId(json.models[0].value);
}
}
if (json.sttModels) {
setSttModels(json.sttModels);
if (
json.sttModels.length > 0 &&
!json.sttModels.some((m) => m.value === sttModelId)
) {
setSttModelId(json.sttModels[0].value);
}
}
});
} else {
setTtsModels([]);
@@ -623,6 +727,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setTmpCustomVendorSttUrl(credential.data.custom_stt_url || "");
setCustomVendorTtsUrl(credential.data.custom_tts_url || "");
setTmpCustomVendorTtsUrl(credential.data.custom_tts_url || "");
setCustomVendorTtsStreamingUrl(
credential.data.custom_tts_streaming_url || "",
);
setTmpCustomVendorTtsStreamingUrl(
credential.data.custom_tts_streaming_url || "",
);
if (credential.data.label) {
setLabel(credential.data.label);
}
@@ -632,6 +742,12 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential.data.model_id) {
setTtsModelId(credential.data.model_id);
}
if (credential.data.model_id && vendor === VENDOR_OPENAI) {
setSttModelId(credential.data.model_id);
}
if (credential?.data?.playht_tts_uri) {
setPlayhtTtsUri(credential.data.playht_tts_uri);
}
}
if (credential?.data?.options) {
setOptions(credential.data.options);
@@ -694,6 +810,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
);
setSpeechmaticsEndpoint(credential.data.speechmatics_stt_uri);
}
setInitialPlayhtOnpremCheck(hasValue(credential?.data?.playht_tts_uri));
}, [credential]);
const updateCustomVoices = (
@@ -808,9 +925,11 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
{vendor && (
<fieldset>
{vendor !== VENDOR_ASSEMBLYAI &&
vendor !== VENDOR_VOXIST &&
vendor !== VENDOR_COBALT &&
vendor !== VENDOR_SONIOX &&
vendor !== VENDOR_SPEECHMATICS &&
vendor !== VENDOR_OPENAI &&
vendor != VENDOR_CUSTOM && (
<label htmlFor="use_for_tts" className="chk">
<input
@@ -828,6 +947,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
vendor !== VENDOR_WHISPER &&
vendor !== VENDOR_PLAYHT &&
vendor !== VENDOR_RIMELABS &&
vendor !== VENDOR_CARTESIA &&
vendor !== VENDOR_ELEVENLABS && (
<label htmlFor="use_for_stt" className="chk">
<input
@@ -851,26 +971,47 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setTtsCheck(e.target.checked);
if (!e.target.checked) {
setTmpCustomVendorTtsUrl(customVendorTtsUrl);
setTmpCustomVendorTtsStreamingUrl(
customVendorTtsStreamingUrl,
);
setCustomVendorTtsUrl("");
setCustomVendorTtsStreamingUrl("");
} else {
setCustomVendorTtsUrl(tmpCustomVendorTtsUrl);
setCustomVendorTtsStreamingUrl(
tmpCustomVendorTtsStreamingUrl,
);
}
}}
>
<label htmlFor="custom_vendor_use_for_tts">
TTS HTTP URL<span>*</span>
Http URL (non-streaming)
</label>
<input
id="custom_vendor_use_for_tts"
type="text"
name="custom_vendor_use_for_tts"
placeholder="Required"
required={ttsCheck}
required={ttsCheck && !customVendorTtsStreamingUrl}
value={customVendorTtsUrl}
onChange={(e) => {
setCustomVendorTtsUrl(e.target.value);
}}
/>
<label htmlFor="custom_vendor_use_for_tts_streaming_ws">
Ws URL (streaming)
</label>
<input
id="custom_vendor_use_for_tts_streaming_ws"
type="text"
name="custom_vendor_use_for_tts_streaming_ws"
placeholder="Required"
required={ttsCheck && !customVendorTtsUrl}
value={customVendorTtsStreamingUrl}
onChange={(e) => {
setCustomVendorTtsStreamingUrl(e.target.value);
}}
/>
</Checkzone>
<Checkzone
@@ -978,15 +1119,8 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
name="use_custom_voice"
type="checkbox"
onChange={(e) => {
if (customVoices.length === 0) {
setCustomVoices([
{
name: "",
reported_usage:
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
},
]);
if (e.target.checked && customVoices.length === 0) {
setCustomVoices([DEFAULT_GOOGLE_CUSTOM_VOICE]);
}
setUseCustomVoicesCheck(e.target.checked);
}}
@@ -1009,7 +1143,10 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
<div>
<div>
<label htmlFor="custom_voice_name">
Name / Reported Usage
Name
{!v.use_voice_cloning_key
? " / Reported Usage"
: ""}
</label>
</div>
</div>
@@ -1029,49 +1166,112 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</div>
<div>
<Selector
id={"google_custom_voices_reported_usage"}
name={"google_custom_voices_reported_usage"}
value={v.reported_usage}
options={GOOGLE_CUSTOM_VOICES_REPORTED_USAGE}
onChange={(e) => {
updateCustomVoices(
i,
"reported_usage",
e.target.value,
);
}}
/>
</div>
{!v.use_voice_cloning_key && (
<div>
<Selector
id={"google_custom_voices_reported_usage"}
name={"google_custom_voices_reported_usage"}
value={v.reported_usage}
options={GOOGLE_CUSTOM_VOICES_REPORTED_USAGE}
onChange={(e) => {
updateCustomVoices(
i,
"reported_usage",
e.target.value,
);
}}
/>
</div>
)}
</div>
<div>
<div>
<label htmlFor="custom_voice_name">Model</label>
</div>
</div>
<label
htmlFor={`use_voice_cloning_key_${i}`}
className="chk"
>
<input
id={`use_voice_cloning_key_${i}`}
name={`use_voice_cloning_key_${i}`}
type="checkbox"
onChange={(e) => {
updateCustomVoices(
i,
"use_voice_cloning_key",
e.target.checked ? 1 : 0,
);
}}
checked={v.use_voice_cloning_key ? true : false}
/>
<div>Use voice cloning key</div>
</label>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Model"
required
value={v.model}
style={{ maxWidth: "100%" }}
onChange={(e) => {
updateCustomVoices(
i,
"model",
e.target.value,
);
}}
/>
</div>
</div>
{!v.use_voice_cloning_key && (
<>
<div>
<div>
<label htmlFor="custom_voice_name">
Model
</label>
</div>
</div>
<div>
<div>
<input
id={`sip_ip_${i}`}
name={`sip_ip_${i}`}
type="text"
placeholder="Model"
required
value={v.model}
style={{ maxWidth: "100%" }}
onChange={(e) => {
updateCustomVoices(
i,
"model",
e.target.value,
);
}}
/>
</div>
</div>
</>
)}
{v.use_voice_cloning_key === 1 && (
<>
<div>
<div>
{hasValue(v.voice_cloning_key) && (
<pre>
<code>{v.voice_cloning_key}</code>
</pre>
)}
</div>
<div>
<FileUpload
id={`google_voice_cloning_key_${i}`}
name={`google_voice_cloning_key_${i}`}
handleFile={(file) => {
updateCustomVoices(
i,
"voice_cloning_key_file",
file,
);
file.text().then((text) => {
updateCustomVoices(
i,
"voice_cloning_key",
text.substring(0, 100) + "...",
);
});
}}
required={!v.voice_cloning_key}
/>
</div>
</div>
</>
)}
<button
className="btnty"
@@ -1112,12 +1312,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
setCustomVoicesMessage("");
setCustomVoices((prev) => [
...prev,
{
name: "",
reported_usage:
DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
model: "",
},
DEFAULT_GOOGLE_CUSTOM_VOICE,
]);
}}
>
@@ -1361,17 +1556,28 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
</fieldset>
)}
{(vendor === VENDOR_WELLSAID ||
vendor === VENDOR_ASSEMBLYAI ||
vendor == VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_SONIOX ||
vendor === VENDOR_SPEECHMATICS) && (
{vendor === VENDOR_PLAYHT && (
<fieldset>
{vendor === VENDOR_PLAYHT && (
<>
<Checkzone
disabled={hasValue(credential)}
hidden
name="use_hosted_playht_service"
label="Use hosted PlayHT Service"
initialCheck={!initialPlayhtOnpremCheck}
handleChecked={(e) => {
setInitialPlayhtOnpremCheck(!e.target.checked);
if (e.target.checked) {
setTmpPlayhtTtsUri(playhtTtsUri);
setPlayhtTtsUri("");
} else {
if (tmpPlayhtTtsUri) {
setPlayhtTtsUri(tmpPlayhtTtsUri);
}
}
}}
>
<fieldset>
<label htmlFor={`${vendor}_userid`}>
User ID<span>*</span>
</label>
@@ -1387,8 +1593,95 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}}
disabled={credential ? true : false}
/>
</>
)}
<label htmlFor={`${vendor}_apikey`}>
API key<span>*</span>
</label>
<Passwd
id={`${vendor}_apikey`}
required
name={`${vendor}_apikey`}
placeholder="API key"
value={apiKey ? getObscuredSecret(apiKey) : apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
/>
</fieldset>
</Checkzone>
<Checkzone
disabled={hasValue(credential)}
hidden
name="use_on-prem_playht_container"
label="Use on-prem PlayHT container"
initialCheck={initialPlayhtOnpremCheck}
handleChecked={(e) => {
setInitialPlayhtOnpremCheck(e.target.checked);
if (e.target.checked) {
if (tmpPlayhtTtsUri) {
setPlayhtTtsUri(tmpPlayhtTtsUri);
}
} else {
setTmpPlayhtTtsUri(playhtTtsUri);
setPlayhtTtsUri("");
}
}}
>
<fieldset>
<label htmlFor="playht_uri_for_tts">
TTS Container URI<span>*</span>
</label>
<input
id="playht_uri_for_tts"
required
type="text"
name="playht_uri_for_tts"
placeholder="http://"
value={playhtTtsUri}
onChange={(e) => setPlayhtTtsUri(e.target.value)}
/>
<label htmlFor={`${vendor}_userid`}>
User ID<span>*</span>
</label>
<input
id="playht_user_id"
type="text"
name="playht_user_id"
placeholder="User ID"
required
value={userId}
onChange={(e) => {
setUserId(e.target.value);
}}
disabled={credential ? true : false}
/>
<label htmlFor={`${vendor}_apikey`}>
Api key<span>*</span>
</label>
<Passwd
id={`${vendor}_apikey`}
name={`${vendor}_apikey`}
placeholder="API key"
required
value={apiKey ? getObscuredSecret(apiKey) : apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={credential ? true : false}
/>
</fieldset>
</Checkzone>
</fieldset>
)}
{(vendor === VENDOR_WELLSAID ||
vendor === VENDOR_ASSEMBLYAI ||
vendor === VENDOR_VOXIST ||
vendor == VENDOR_ELEVENLABS ||
vendor === VENDOR_WHISPER ||
vendor === VENDOR_RIMELABS ||
vendor === VENDOR_SONIOX ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_OPENAI ||
vendor === VENDOR_SPEECHMATICS) && (
<fieldset>
<label htmlFor={`${vendor}_apikey`}>
API key<span>*</span>
</label>
@@ -1403,26 +1696,16 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</fieldset>
)}
{vendor === VENDOR_PLAYHT && ttsModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_tts_model_id`}>Voice engine</label>
<Selector
id={"tts_model_id"}
name={"tts_model_id"}
value={ttsModelId}
options={ttsModels}
onChange={(e) => {
setTtsModelId(e.target.value);
}}
/>
</fieldset>
)}
{(vendor == VENDOR_ELEVENLABS ||
vendor == VENDOR_WHISPER ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_PLAYHT ||
vendor == VENDOR_RIMELABS) &&
ttsModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_tts_model_id`}>Model</label>
<label htmlFor={`${vendor}_tts_model_id`}>
{getModelLabelByVendor(vendor)}
</label>
<Selector
id={"tts_model_id"}
name={"tts_model_id"}
@@ -1434,8 +1717,25 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</fieldset>
)}
{vendor == VENDOR_OPENAI && sttModels.length > 0 && (
<fieldset>
<label htmlFor={`${vendor}_stt_model_id`}>
{getModelLabelByVendor(vendor)}
</label>
<Selector
id={"stt_model_id"}
name={"stt_model_id"}
value={sttModelId}
options={sttModels}
onChange={(e) => {
setSttModelId(e.target.value);
}}
/>
</fieldset>
)}
{(vendor === VENDOR_ELEVENLABS ||
vendor === VENDOR_PLAYHT ||
vendor === VENDOR_CARTESIA ||
vendor === VENDOR_RIMELABS) && (
<fieldset>
<Checkzone

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

@@ -61,6 +61,7 @@ export const UserForm = ({ user }: UserFormProps) => {
const [forceChange, setForceChange] = useState(true);
const [modal, setModal] = useState(false);
const [accountSid, setAccountSid] = useState("");
const [isViewOnly, setIsViewOnly] = useState(false);
const handleCancel = () => {
setModal(false);
@@ -115,6 +116,7 @@ export const UserForm = ({ user }: UserFormProps) => {
initial_password: initialPassword,
force_change: forceChange,
is_active: isActive,
is_view_only: isViewOnly,
service_provider_sid:
scope === USER_ADMIN && currentUser?.scope === USER_ADMIN
? null
@@ -145,6 +147,7 @@ export const UserForm = ({ user }: UserFormProps) => {
initial_password: initialPassword || null,
force_change: forceChange,
is_active: isActive,
is_view_only: isViewOnly,
service_provider_sid:
scope === USER_ADMIN && currentUser?.scope === USER_ADMIN
? null
@@ -172,6 +175,7 @@ export const UserForm = ({ user }: UserFormProps) => {
setName(user.data.name);
setForceChange(!!user.data.force_change);
setIsActive(!!user.data.is_active);
setIsViewOnly(!!user.data.is_view_only);
setEmail(user.data.email);
setScope(getUserScope(user.data));
if (user.data.account_sid) {
@@ -253,6 +257,16 @@ export const UserForm = ({ user }: UserFormProps) => {
/>
<div>User is active</div>
</label>
<label htmlFor="is_view_only" className="chk">
<input
id="is_view_only"
name="is_view_only"
type="checkbox"
checked={isViewOnly}
onChange={(e) => setIsViewOnly(e.target.checked)}
/>
<div>View-only User</div>
</label>
</fieldset>
)}
<fieldset>
@@ -283,6 +297,20 @@ export const UserForm = ({ user }: UserFormProps) => {
onChange={(e) => setEmail(e.target.value)}
/>
</fieldset>
{!user && (
<fieldset>
<label htmlFor="is_view_only" className="chk">
<input
id="is_view_only"
name="is_view_only"
type="checkbox"
checked={isViewOnly}
onChange={(e) => setIsViewOnly(e.target.checked)}
/>
<div>View-only User</div>
</label>
</fieldset>
)}
<fieldset>
<label htmlFor="initial_password">
Temporary password

View File

@@ -99,6 +99,7 @@ export const Login = () => {
value={password}
placeholder="Password"
setValue={setPassword}
ignorePasswordManager={false}
/>
{message && <Message message={message} />}
<Button type="submit">Log in</Button>

View File

@@ -86,7 +86,6 @@ export const parseJwt = (token: string) => {
})
.join(""),
);
return JSON.parse(jsonPayload);
};
@@ -107,7 +106,6 @@ export const useProvideAuth = (): AuthStateContext => {
token = response.json.token;
setToken(token);
userData = parseJwt(token);
if (ENABLE_HOSTED_SYSTEM) {
getMe()
.then(({ json }) => {

View File

@@ -44,7 +44,6 @@ const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
return serviceProvidersAction(state, action);
case "currentServiceProvider":
return currentServiceProviderAction(state, action);
default:
throw new Error();
}

View File

@@ -26,6 +26,7 @@ export enum Scope {
export interface UserData extends UserJWT {
access: Scope;
read_only_feature: boolean;
}
export interface State {

View File

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

15
src/vendor/index.tsx vendored
View File

@@ -20,10 +20,13 @@ 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 VENDOR_OPENAI = "openai";
export const vendors: VendorOptions[] = [
{
@@ -82,6 +85,10 @@ export const vendors: VendorOptions[] = [
name: "AssemblyAI",
value: VENDOR_ASSEMBLYAI,
},
{
name: "Voxist",
value: VENDOR_VOXIST,
},
{
name: "Whisper",
value: VENDOR_WHISPER,
@@ -98,6 +105,14 @@ export const vendors: VendorOptions[] = [
name: "Verbio",
value: VENDOR_VERBIO,
},
{
name: "Cartesia",
value: VENDOR_CARTESIA,
},
{
name: "OpenAI",
value: VENDOR_OPENAI,
},
].sort((a, b) => a.name.localeCompare(b.name)) as VendorOptions[];
export const AWS_CREDENTIAL_ACCESS_KEY = "access_key";

7
src/vendor/types.ts vendored
View File

@@ -13,10 +13,13 @@ export type Vendor =
| "Custom"
| "ElevenLabs"
| "assemblyai"
| "voxist"
| "whisper"
| "playht"
| "rimelabs"
| "verbio";
| "verbio"
| "openai"
| "Cartesia";
export interface VendorOptions {
name: Vendor;
@@ -107,6 +110,8 @@ export interface SynthesisVendors {
whisper: VoiceLanguage[];
deepgram: VoiceLanguage[];
playht: VoiceLanguage[];
cartesia: VoiceLanguage[];
rimelabs: VoiceLanguage[];
}
export interface MSRawSpeech {