Compare commits

..

43 Commits

Author SHA1 Message Date
Hoan Luu Huu
4eca59d9bd fix regression bug: new app does not save tts voice by default (#535) 2025-06-06 14:50:34 +02:00
Hoan Luu Huu
4a293ae7da appEnvs should support enum dropdown (#532) 2025-06-02 07:41:18 -04:00
Hoan Luu Huu
03e52e3dc5 fixed Cannot delete Carrier, show message that there is link to LCR (#533)
* fixed Cannot delete Carrier, show message that there is link to LCR

* wip
2025-06-02 07:14:01 -04:00
Hoan Luu Huu
9ab592a898 fixed admin filter phone number by SP (#531) 2025-05-30 07:24:31 -04:00
Hoan Luu Huu
1723326890 fix app crash when create new speech credential (#530)
* fix app crash when create new speech credential

* fix app crash when create new speech credential
2025-05-29 08:31:41 -04:00
Hoan Luu Huu
504825d699 fix app envs does not take default value and filepicker is required even value is available (#529) 2025-05-28 19:58:21 -04:00
Hoan Luu Huu
e65d9b9db6 some S3 compatible storage systems have a region parameter (#524)
* some S3 compatible storage systems have a region parameter

* wip

* wip

* replace current toastMethod by new toastProvider

* wip

* fix failing testcase

* wip
2025-05-28 10:03:39 -04:00
Hoan Luu Huu
10818493bc support deepgram stt model (#528)
* support deepgram stt model

* wip

* wip
2025-05-28 08:01:20 -04:00
Hoan Luu Huu
844eec953c UI improvement. (#521)
* don't remove service provider sid and filteredAccountSid when logout

* support fetching applications with pagination

* applications wip

* support pagination for voip carriers

* wip

* support phone number pagination

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
2025-05-28 07:28:52 -04:00
Hoan Luu Huu
19620116b5 support uiHInt property in env vars (#526)
* support uiHInt property in env vars

* wip

* remove placeholder for app envs input
2025-05-25 19:34:29 -04:00
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
80 changed files with 2971 additions and 574 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,9 @@ 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 DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
@@ -247,6 +254,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 +288,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 +307,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 +326,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 +448,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,10 @@ import type {
GoogleCustomVoice,
GoogleCustomVoicesQuery,
SpeechSupportedLanguagesAndVoices,
AppEnv,
PhoneNumberQuery,
ApplicationQuery,
VoipCarrierQuery,
} from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
@@ -225,6 +230,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 +507,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 +817,33 @@ 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}`);
};
export const getApplications = (
sid: string,
query: Partial<ApplicationQuery>,
) => {
const qryStr = getQuery<Partial<ApplicationQuery>>(query);
return getFetch<PagedResponse<Application>>(
`${API_ACCOUNTS}/${sid}/Applications?${qryStr}`,
);
};
export const getSPVoipCarriers = (
sid: string,
query: Partial<VoipCarrierQuery>,
) => {
const qryStr = getQuery<Partial<VoipCarrierQuery>>(query);
return getFetch<PagedResponse<Carrier>>(
`${API_SERVICE_PROVIDERS}/${sid}/VoipCarriers?${qryStr}`,
);
};
/** Wrappers for APIs that can have a mock dev server response */
@@ -804,17 +855,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 +924,12 @@ export const getPrice = () => {
return getFetch<PriceInfo[]>(API_PRICE);
};
export const getPhoneNumbers = (query: Partial<PhoneNumberQuery>) => {
const qryStr = getQuery<Partial<PhoneNumberQuery>>(query);
return getFetch<PagedResponse<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 {
@@ -546,16 +557,32 @@ export interface Client {
export interface PageQuery {
page: number;
page_size?: number;
count: number;
start?: string;
days?: number;
}
export interface PhoneNumberQuery extends PageQuery {
service_provider_sid?: string;
account_sid?: string;
filter?: string;
}
export interface CallQuery extends PageQuery {
direction?: string;
answered?: string;
}
export interface ApplicationQuery extends PageQuery {
name?: string;
}
export interface VoipCarrierQuery extends PageQuery {
name?: string;
account_sid?: string;
}
export interface GoogleCustomVoicesQuery {
speech_credential_sid?: string;
label?: string;
@@ -649,8 +676,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 +697,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 +751,7 @@ export interface SpeechSupportedLanguagesAndVoices {
tts: VoiceLanguage[];
stt: Language[];
models: Model[];
sttModels: Model[];
}
export interface ElevenLabsOptions {
@@ -748,3 +779,40 @@ 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;
uiHint?: "input" | "textarea" | "filepicker";
enum?: string[];
}
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,7 +1,7 @@
import React from "react";
import { Icons } from "src/components/icons";
import { toastError, toastSuccess } from "src/store";
import { useToast } from "../toast/toast-provider";
type ClipBoardProps = {
id?: string;
@@ -13,6 +13,7 @@ type ClipBoardProps = {
const hasClipboard = typeof navigator.clipboard !== "undefined";
export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
const { toastSuccess, toastError } = useToast();
const handleClick = () => {
navigator.clipboard
.writeText(text)

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

@@ -2,15 +2,18 @@ import React from "react";
import { H1 } from "@jambonz/ui-kit";
import { RequireAuth } from "./require-auth";
import { ToastProvider } from "./toast/toast-provider";
/** Wrapper to pass different auth contexts */
const RequireAuthTestWrapper = () => {
return (
<RequireAuth>
<div className="auth-div">
<H1>Protected Route</H1>
</div>
</RequireAuth>
<ToastProvider>
<RequireAuth>
<div className="auth-div">
<H1>Protected Route</H1>
</div>
</RequireAuth>
</ToastProvider>
);
};

View File

@@ -2,14 +2,15 @@ import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "src/router/auth";
import { toastError } from "src/store";
import { ROUTE_LOGIN } from "src/router/routes";
import { MSG_MUST_LOGIN } from "src/constants";
import { useToast } from "./toast/toast-provider";
/**
* Wrapper component that enforces valid authorization to the app
*/
export const RequireAuth = ({ children }: { children: React.ReactNode }) => {
const { toastError } = useToast();
const { authorized } = useAuth();
const navigate = useNavigate();

View File

@@ -0,0 +1,96 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
useRef,
} from "react";
import { Toast } from "./index";
import type { IMessage, Toast as ToastProps } from "src/store/types";
import { TOAST_TIME } from "src/constants";
// Define the context type
interface ToastContextType {
toastSuccess: (message: IMessage) => void;
toastError: (message: IMessage) => void;
}
// Create the context with a default value
const ToastContext = createContext<ToastContextType | undefined>(undefined);
/**
* Provider component that makes toast functionality available to any
* nested components that call useToast().
*/
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [toast, setToast] = useState<ToastProps | null>(null);
const timeoutRef = useRef<number | null>(null);
// Clear any existing toasts and timeouts
const clearToast = useCallback(() => {
setToast(null);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
// Show a toast with the specified type and message
const showToast = useCallback(
(type: "success" | "error", message: IMessage) => {
clearToast();
setToast({ type, message });
// Auto-hide after specified time
timeoutRef.current = window.setTimeout(() => {
setToast(null);
}, TOAST_TIME);
},
[clearToast],
);
// Exposed methods
const toastSuccess = useCallback(
(message: IMessage) => {
showToast("success", message);
},
[showToast],
);
const toastError = useCallback(
(message: IMessage) => {
showToast("error", message);
},
[showToast],
);
// Context value
const contextValue = useMemo(
() => ({
toastSuccess,
toastError,
}),
[toastSuccess, toastError],
);
return (
<ToastContext.Provider value={contextValue}>
{children}
{toast && <Toast type={toast.type} message={toast.message} />}
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (context === undefined) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
};

View File

@@ -1,12 +1,12 @@
import React, { useState } from "react";
import { P, Button } from "@jambonz/ui-kit";
import { toastSuccess, toastError } from "src/store";
import { useApiData, postApiKey, deleteApiKey } from "src/api";
import { Modal, ModalClose, Obscure, ClipBoard, Section } from "src/components";
import { getHumanDateTime, hasLength } from "src/utils";
import type { ApiKey, TokenResponse } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type ApiKeyProps = {
path: string;
@@ -18,6 +18,7 @@ type ApiKeyProps = {
};
export const ApiKeys = ({ path, post, label }: ApiKeyProps) => {
const { toastSuccess, toastError } = useToast();
const [apiKeys, apiKeysRefetcher] = useApiData<ApiKey[]>(path);
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null);
const [addedKey, setAddedKey] = useState<TokenResponse | null>(null);

View File

@@ -5,13 +5,12 @@ import { Link, useLocation, useNavigate } from "react-router-dom";
import { Icons, ModalForm } from "src/components";
import { naviTop, naviByo } from "./items";
import { UserMe } from "../user-me";
import { useSelectState, useDispatch } from "src/store";
import {
useSelectState,
useDispatch,
toastSuccess,
toastError,
} from "src/store";
import { getActiveSP, setActiveSP } from "src/store/localStore";
getActiveSP,
removeAccountFilter,
setActiveSP,
} from "src/store/localStore";
import { postServiceProviders } from "src/api";
import type { NaviItem } from "./items";
@@ -22,6 +21,7 @@ import { Scope, UserData } from "src/store/types";
import { USER_ADMIN } from "src/api/constants";
import { ROUTE_LOGIN } from "src/router/routes";
import { Lcr } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type CommonProps = {
handleMenu: () => void;
@@ -63,6 +63,7 @@ export const Navi = ({
handleMenu,
handleLogout,
}: NaviProps) => {
const { toastSuccess, toastError } = useToast();
const dispatch = useDispatch();
const navigate = useNavigate();
const user = useSelectState("user");
@@ -166,6 +167,7 @@ export const Navi = ({
onChange={(e) => {
setSid(e.target.value);
setActiveSP(e.target.value);
removeAccountFilter();
navigate(ROUTE_LOGIN);
}}
disabled={user?.scope !== USER_ADMIN}

View File

@@ -4,7 +4,7 @@ import { useParams } from "react-router-dom";
import { ApiKeys } from "src/containers/internal/api-keys";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { AccountForm } from "./form";
import type { Account, Application, Limit, TtsCache } from "src/api/types";
@@ -14,8 +14,10 @@ import {
} from "src/router/routes";
import { useScopedRedirect } from "src/utils";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditAccount = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Account>(

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react";
import { P, Button, ButtonGroup, MS, Icon, H1 } from "@jambonz/ui-kit";
import { Link, useNavigate, useParams } from "react-router-dom";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
putAccount,
postAccount,
@@ -75,6 +75,7 @@ import { EditBoard } from "src/components/editboard";
import { ModalLoader } from "src/components/modal";
import { useAuth } from "src/router/auth";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
type AccountFormProps = {
apps?: Application[];
@@ -89,6 +90,7 @@ export const AccountForm = ({
account,
ttsCache,
}: AccountFormProps) => {
const { toastError, toastSuccess } = useToast();
const params = useParams();
const navigate = useNavigate();
const user = useSelectState("user");
@@ -123,10 +125,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 +149,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... */
@@ -284,6 +291,7 @@ export const AccountForm = ({
endpoint: endpoint,
access_key_id: bucketAccessKeyId,
secret_access_key: bucketSecretAccessKey,
...(bucketRegion && { region: bucketRegion }),
}),
};
@@ -432,6 +440,9 @@ export const AccountForm = ({
access_key_id: bucketAccessKeyId || null,
secret_access_key: bucketSecretAccessKey || null,
...(hasLength(bucketTags) && { tags: bucketTags }),
...(bucketRegion && {
region: bucketRegion,
}),
},
}),
...(!bucketCredentialChecked && {
@@ -529,23 +540,38 @@ 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);
} else if (
account.data.bucket_credential?.vendor === BUCKET_VENDOR_S3_COMPATIBLE
) {
setBucketRegion("");
}
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) {
@@ -567,9 +593,7 @@ export const AccountForm = ({
JSON.parse(account.data.bucket_credential?.service_key),
);
}
setInitialCheckRecordAllCall(
hasValue(bucketVendor) && bucketVendor.length !== 0,
);
setInitialCheckRecordAllCall(hasValue(account.data.bucket_credential));
}
}, [account]);
@@ -1086,6 +1110,18 @@ export const AccountForm = ({
onChange={(e) => {
setBucketVendor(e.target.value);
setTmpBucketVendor(e.target.value);
if (
e.target.value === BUCKET_VENDOR_AWS &&
!regions?.aws.find((r) => r.value === bucketRegion)
) {
setBucketRegion("us-east-1");
setTmpBucketRegion("us-east-1");
} else if (
e.target.value === BUCKET_VENDOR_S3_COMPATIBLE
) {
setBucketRegion("");
setTmpBucketRegion("");
}
}}
/>
</div>
@@ -1103,6 +1139,18 @@ export const AccountForm = ({
value={endpoint}
onChange={(e) => {
setEndpoint(e.target.value);
setTmpEndpoint(e.target.value);
}}
/>
<label htmlFor="endpoint">Region (Optional)</label>
<input
id="aws_compatible_region"
type="text"
name="aws_compatible_region"
value={bucketRegion}
onChange={(e) => {
setBucketRegion(e.target.value);
setTmpBucketRegion(e.target.value);
}}
/>
</>
@@ -1150,7 +1198,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 +1217,7 @@ export const AccountForm = ({
value={bucketAccessKeyId}
onChange={(e) => {
setBucketAccessKeyId(e.target.value);
setTmpBucketAccessKeyId(e.target.value);
}}
/>
<label htmlFor="bucket_aws_secret_key">
@@ -1179,6 +1231,7 @@ export const AccountForm = ({
value={bucketSecretAccessKey}
onChange={(e) => {
setBucketSecretAccessKey(e.target.value);
setTmpBucketSecretAccessKey(e.target.value);
}}
/>
</>
@@ -1227,6 +1280,7 @@ export const AccountForm = ({
value={azureConnectionString}
onChange={(e) => {
setAzureConnectionString(e.target.value);
setTmpAzureConnectionString(e.target.value);
}}
/>
</>

View File

@@ -6,7 +6,7 @@ import { useServiceProviderData, deleteAccount } from "src/api";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { Section, Icons, Spinner, SearchFilter } from "src/components";
import { DeleteAccount } from "./delete";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
hasLength,
hasValue,
@@ -17,8 +17,10 @@ import { USER_ACCOUNT } from "src/api/constants";
import { Scope } from "src/store/types";
import type { Account } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
export const Accounts = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts, refetch] = useServiceProviderData<Account[]>("Accounts");
const [account, setAccount] = useState<Account | null>(null);

View File

@@ -10,11 +10,13 @@ import { postSubscriptions, useApiData } from "src/api";
import { CurrentUserData, Subscription } from "src/api/types";
import { Section } from "src/components";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { PaymentMethod } from "@stripe/stripe-js";
import { ModalLoader } from "src/components/modal";
import { useToast } from "src/components/toast/toast-provider";
export const ManagePaymentForm = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const stripe = useStripe();
const elements = useElements();

View File

@@ -19,10 +19,11 @@ import {
useStripe,
} from "@stripe/react-stripe-js";
import { PaymentMethod } from "@stripe/stripe-js";
import { toastError, toastSuccess } from "src/store";
import { ModalLoader } from "src/components/modal";
import { useToast } from "src/components/toast/toast-provider";
const SubscriptionForm = () => {
const { toastError, toastSuccess } = useToast();
const [userData] = useApiData<CurrentUserData>("Users/me");
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");
@@ -257,7 +258,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 +276,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 +303,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 +315,7 @@ const SubscriptionForm = () => {
}
});
setServiceData([...serviceData]);
return [...serviceData];
};
const getServicePrice = (
@@ -320,7 +341,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 +383,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

@@ -8,7 +8,7 @@ import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { hasLength, hasValue } from "src/utils";
import {
AccountFilter,
@@ -27,8 +27,10 @@ import {
setLocation,
} from "src/store/localStore";
import AlertDetailItem from "./alert-detail-item";
import { useToast } from "src/components/toast/toast-provider";
export const Alerts = () => {
const { toastError } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
@@ -47,7 +49,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)
@@ -63,10 +70,10 @@ export const Alerts = () => {
};
useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
if (getQueryFilter()) {
const [date] = getQueryFilter().split("/");
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
setDateFilter(date);
}
}, [accountSid]);
@@ -103,7 +110,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

@@ -3,15 +3,17 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { ApplicationForm } from "./form";
import type { Application } from "src/api/types";
import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { Scope } from "src/store/types";
import { ROUTE_INTERNAL_APPLICATIONS } from "src/router/routes";
import { useToast } from "src/components/toast/toast-provider";
export const EditApplication = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Application>(

View File

@@ -1,15 +1,16 @@
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 { useSelectState } from "src/store";
import { ClipBoard, Section, Tooltip } from "src/components";
import {
Selector,
Checkzone,
Passwd,
Message,
AccountSelect,
FileUpload,
} from "src/components/forms";
import {
vendors,
@@ -23,6 +24,7 @@ import {
putApplication,
useServiceProviderData,
useApiData,
getAppEnvSchema,
} from "src/api";
import {
ROUTE_INTERNAL_ACCOUNTS,
@@ -48,17 +50,26 @@ 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 {
hasLength,
hasValue,
isUserAccountScope,
useRedirect,
} from "src/utils";
import { setAccountFilter, setLocation } from "src/store/localStore";
import SpeechProviderSelection from "./speech-selection";
import ObscureInput from "src/components/obscure-input";
import { useToast } from "src/components/toast/toast-provider";
type ApplicationFormProps = {
application?: UseApiDataMap<Application>;
};
export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
@@ -77,11 +88,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 +133,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 +151,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tmpStateSet: setTmpCallWebhook,
initialCheck: initialCallWebhook,
required: true,
webhookEnv: appEnv,
},
{
label: "Call status",
@@ -150,16 +163,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 +205,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 +215,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 +492,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 +550,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 +588,45 @@ 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);
const defaultEnvVars = Object.keys(json).reduce((acc, key) => {
const value = json[key];
if (value?.default) {
return { ...acc, [key]: value.default };
}
return acc;
}, {});
setEnvVars((prev) => ({
...defaultEnvVars,
...(prev || {}),
}));
})
.catch((error) => {
setMessage(error.msg);
});
}, 500);
}
return () => {
if (appEnvTimeoutRef.current) {
clearTimeout(appEnvTimeoutRef.current);
appEnvTimeoutRef.current = null;
}
};
}, [callWebhook]);
return (
<Section slim>
<form
@@ -640,7 +688,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 +726,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
method: e.target.value as WebhookMethod,
});
}}
disabled={webhook.stateVal?.url.startsWith("ws")}
options={WEBHOOK_METHODS}
/>
</div>
@@ -707,6 +778,183 @@ 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 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((prev) => ({
...(prev || {}),
[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>
{(() => {
// Common props for both input types
const commonProps = {
id: `env_${key}`,
name: `env_${key}`,
required: webhook.webhookEnv![key].required,
value:
envVars && envVars[key] !== undefined
? String(envVars[key])
: defaultValue !== undefined
? String(defaultValue)
: "",
onChange: (
e: React.ChangeEvent<
HTMLInputElement | HTMLSelectElement
>,
) => {
// 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((prev) => ({
...(prev || {}),
[key]: newValue,
}));
},
};
// Extra props only for regular input
const inputSpecificProps = {
type: isNumber ? "number" : "text",
};
const isDropdown =
webhook.webhookEnv![key].type === "string" &&
(webhook.webhookEnv![key].enum?.length || 0) >
0;
const textAreaSpecificProps = {
rows: 6,
cols: 61,
};
// Choose component type based on obscure flag
const componentType = webhook.webhookEnv![key]
.obscure
? ObscureInput
: webhook.webhookEnv![key].uiHint || "input";
if (isDropdown) {
return (
<Selector
{...commonProps}
options={webhook.webhookEnv![
key
].enum!.map((option) => ({
name: option,
value: option,
}))}
/>
);
}
if (componentType === "filepicker") {
return (
<>
<FileUpload
id={`app_env_${key}`}
name={`app_env_${key}`}
handleFile={(file) => {
file
.text()
.then((content) => {
setEnvVars((prev) => ({
...(prev || {}),
[key]: content,
}));
})
.catch((err) => {
toastError(
`Failed to read file: ${err.message}`,
);
});
}}
placeholder="Choose a file"
required={
webhook.webhookEnv![key].required &&
!hasValue(envVars?.[key])
}
/>
{React.createElement("textarea", {
...commonProps,
...inputSpecificProps,
...textAreaSpecificProps,
})}
</>
);
}
// Create the component with appropriate props
return React.createElement(
componentType,
webhook.webhookEnv![key].obscure
? commonProps
: {
...commonProps,
...inputSpecificProps,
...(webhook.webhookEnv![key].uiHint ===
"textarea" && textAreaSpecificProps),
},
);
})()}
</>
)}
</div>
);
})}
</>
)}
</fieldset>
);
})}
@@ -714,6 +962,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
application_speech_synthesis_voice={
application?.data?.speech_synthesis_voice
}
accountSid={accountSid}
credentials={credentials}
ttsVendor={[synthVendor, setSynthVendor]}
@@ -744,6 +995,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
application_speech_synthesis_voice={
application?.data?.fallback_speech_synthesis_voice
}
credentials={credentials}
ttsVendor={[
fallbackSpeechSynthsisVendor,

View File

@@ -1,8 +1,12 @@
import React, { useEffect, useState } from "react";
import { H1, M, Button, Icon } from "@jambonz/ui-kit";
import React, { useEffect, useState, useRef } from "react";
import { H1, M, Button, Icon, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import { deleteApplication, useServiceProviderData, useApiData } from "src/api";
import {
deleteApplication,
useServiceProviderData,
getApplications,
} from "src/api";
import {
ROUTE_INTERNAL_APPLICATIONS,
ROUTE_INTERNAL_ACCOUNTS,
@@ -13,35 +17,71 @@ import {
Spinner,
AccountFilter,
SearchFilter,
Pagination,
SelectFilter,
} from "src/components";
import { DeleteApplication } from "./delete";
import { toastError, toastSuccess, useSelectState } from "src/store";
import {
isUserAccountScope,
hasLength,
hasValue,
useFilteredResults,
} from "src/utils";
import { useSelectState } from "src/store";
import { isUserAccountScope, hasLength, hasValue } from "src/utils";
import type { Application, Account } from "src/api/types";
import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { USER_ACCOUNT } from "src/api/constants";
import { PER_PAGE_SELECTION, USER_ACCOUNT } from "src/api/constants";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const Applications = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [application, setApplication] = useState<Application | null>(null);
const [apiUrl, setApiUrl] = useState("");
const [applications, refetch] = useApiData<Application[]>(apiUrl);
const [applications, setApplications] = useState<Application[] | null>(null);
const [filter, setFilter] = useState("");
const filteredApplications = useFilteredResults<Application>(
filter,
applications,
);
const [applicationsTotal, setApplicationsTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
// Track previous values to detect changes
const prevValuesRef = useRef({
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const fetchApplications = (resetPage = false) => {
// Don't fetch if no account is selected
if (!accountSid) return;
setApplications(null);
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
}
getApplications(accountSid, {
page: currentPage,
page_size: Number(perPageFilter),
...(filter && { name: filter }),
})
.then(({ json }) => {
setApplications(json.data);
setApplicationsTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
})
.catch((error) => {
setApplications([]);
toastError(error.msg);
});
};
const handleDelete = () => {
if (application) {
@@ -53,8 +93,7 @@ export const Applications = () => {
}
deleteApplication(application.application_sid)
.then(() => {
// getApplications();
refetch();
fetchApplications(false);
setApplication(null);
toastSuccess(
<>
@@ -68,18 +107,44 @@ export const Applications = () => {
}
};
// Set initial account
useEffect(() => {
setLocation();
if (user?.account_sid && user.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter() || accountSid);
setAccountSid(
getAccountFilter() || accountSid || accounts?.[0]?.account_sid || "",
);
}
setLocation();
}, [user, accounts]);
if (accountSid) {
setApiUrl(`Accounts/${accountSid}/Applications`);
}
}, [accountSid, user]);
// This single effect handles all data fetching triggers
useEffect(() => {
const accSid = accountSid || getAccountFilter() || "";
if (!accSid) return;
// Determine if the change requires a page reset
const prevValues = prevValuesRef.current;
const isFilterChange =
prevValues.accountSid !== accountSid || prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== ""; // Skip initial render
// Update ref with current values for next comparison
prevValuesRef.current = {
accountSid: accSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with page reset if needed
fetchApplications(isFilterChange || isPageSizeChange);
}, [accountSid, filter, pageNumber, perPageFilter]);
return (
<>
@@ -100,6 +165,7 @@ export const Applications = () => {
<SearchFilter
placeholder="Filter applications"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -108,12 +174,12 @@ export const Applications = () => {
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredApplications) && { slim: true })}>
<Section {...(hasLength(applications) && { slim: true })}>
<div className="list">
{!hasValue(applications) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredApplications) ? (
filteredApplications
) : hasLength(applications) ? (
applications
.sort((a, b) => a.name.localeCompare(b.name))
.map((application) => {
return (
@@ -189,6 +255,26 @@ export const Applications = () => {
</Button>
</Section>
)}
<footer>
<ButtonGroup>
<MS>
Total: {applicationsTotal} record
{applicationsTotal === 1 ? "" : "s"}
</MS>
{hasLength(applications) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
{application && (
<DeleteApplication
application={application}

View File

@@ -10,7 +10,8 @@ import {
} from "src/api/types";
import { Selector } from "src/components/forms";
import { SelectorOption } from "src/components/forms/selector";
import { toastError, useSelectState } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
import { useSelectState } from "src/store";
import { hasLength } from "src/utils";
import {
ELEVENLABS_LANG_EN,
@@ -30,6 +31,10 @@ import {
VENDOR_WHISPER,
VENDOR_SPEECHMATICS,
VENDOR_PLAYHT,
VENDOR_CARTESIA,
VENDOR_VOXIST,
VENDOR_RIMELABS,
VENDOR_OPENAI,
} from "src/vendor";
import {
LabelOptions,
@@ -40,6 +45,7 @@ import {
type SpeechProviderSelectionProbs = {
accountSid: string;
serviceProviderSid: string;
application_speech_synthesis_voice: string | null | undefined;
credentials: SpeechCredential[] | undefined;
ttsVendor: [
keyof SynthesisVendors,
@@ -63,6 +69,7 @@ type SpeechProviderSelectionProbs = {
export const SpeechProviderSelection = ({
accountSid,
serviceProviderSid,
application_speech_synthesis_voice,
credentials,
ttsVendor: [synthVendor, setSynthVendor],
ttsVendorOptions,
@@ -76,6 +83,7 @@ export const SpeechProviderSelection = ({
sttLabelOptions,
sttLabel: [recogLabel, setRecogLabel],
}: SpeechProviderSelectionProbs) => {
const { toastError } = useToast();
const user = useSelectState("user");
const [
synthesisSupportedLanguagesAndVoices,
@@ -131,7 +139,12 @@ export const SpeechProviderSelection = ({
ttsEffectTimer.current = setTimeout(() => {
configSynthesis();
}, 200);
}, [synthVendor, synthLabel, serviceProviderSid]);
}, [
synthVendor,
synthLabel,
serviceProviderSid,
application_speech_synthesis_voice,
]);
// Get Recognizer languages and voices
useEffect(() => {
@@ -198,7 +211,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
@@ -238,9 +251,14 @@ export const SpeechProviderSelection = ({
// Extract model
if (json.models && json.models.length) {
setSynthesisModelOptions(json.models);
if (synthVendor === VENDOR_DEEPGRAM) {
if (
synthVendor === VENDOR_DEEPGRAM &&
(!application_speech_synthesis_voice ||
!json.models.some(
(m) => m.value === application_speech_synthesis_voice,
))
) {
setSynthVoice(json.models[0].value);
return;
}
}
@@ -259,29 +277,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 +317,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 +349,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;
}
};
@@ -373,6 +401,7 @@ export const SpeechProviderSelection = ({
toastError(error.msg);
});
};
return (
<>
<fieldset>
@@ -384,9 +413,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

@@ -2,11 +2,11 @@ import React, { useEffect, useState } from "react";
import { P } from "@jambonz/ui-kit";
import { Modal, ModalClose } from "src/components";
import { getFetch } from "src/api";
import { getFetch, getLcrRoutes, getLcrs } from "src/api";
import { API_PHONE_NUMBERS } from "src/api/constants";
import { formatPhoneNumber, hasLength } from "src/utils";
import type { Carrier, PhoneNumber } from "src/api/types";
import type { Carrier, Lcr, PhoneNumber } from "src/api/types";
type DeleteProps = {
carrier: Carrier;
@@ -20,28 +20,63 @@ export const DeleteCarrier = ({
handleSubmit,
}: DeleteProps) => {
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumber[]>();
const [lcrs, setLcrs] = useState<Lcr[]>();
useEffect(() => {
let ignore = false;
getFetch<PhoneNumber[]>(API_PHONE_NUMBERS).then(({ json }) => {
Promise.all([
getFetch<PhoneNumber[]>(API_PHONE_NUMBERS),
new Promise<Lcr[]>((resolve, reject) => {
getLcrs()
.then(({ json }) => {
Promise.all(
json.map((lcr: Lcr) =>
getLcrRoutes(lcr.lcr_sid!)
.then(({ json }) => {
if (
json.some((route) =>
route.lcr_carrier_set_entries?.some(
(entry) =>
entry.voip_carrier_sid === carrier.voip_carrier_sid,
),
)
) {
return lcr;
}
})
.catch((error) => reject(error)),
),
)
.then((lcrs) => {
resolve(lcrs as Lcr[]);
})
.catch((error) => reject(error));
})
.catch((error) => reject(error));
}),
]).then(([numbers, fetchedLcrs]) => {
if (!ignore) {
setPhoneNumbers(
json.filter(
numbers.json.filter(
(phone) => phone.voip_carrier_sid === carrier.voip_carrier_sid,
),
);
setLcrs(fetchedLcrs);
}
});
return function cleanup() {
ignore = true;
};
}, []);
}, [carrier.voip_carrier_sid]);
const hasBlockingDependencies = hasLength(phoneNumbers) || hasLength(lcrs);
return (
<>
{phoneNumbers && !hasLength(phoneNumbers) && (
{phoneNumbers && lcrs && !hasBlockingDependencies && (
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
<P>
Are you sure you want to delete carrier{" "}
@@ -49,24 +84,49 @@ export const DeleteCarrier = ({
</P>
</Modal>
)}
{hasLength(phoneNumbers) && (
{hasBlockingDependencies && (
<ModalClose handleClose={handleCancel}>
<P>
In order to delete the carrier it cannot be in use by any{" "}
<span>Phone Numbers ({phoneNumbers.length})</span>.
{hasLength(phoneNumbers) && (
<span>Phone Numbers ({phoneNumbers.length})</span>
)}
{hasLength(phoneNumbers) && hasLength(lcrs) && " or "}
{hasLength(lcrs) && (
<span>Outbound call Routings ({lcrs.length})</span>
)}
.
</P>
<ul className="m">
<li>
<strong>Phone Numbers:</strong>
</li>
{phoneNumbers.map((phone) => {
return (
<li className="txt--teal" key={phone.phone_number_sid}>
{formatPhoneNumber(phone.number)}
</li>
);
})}
</ul>
{hasLength(phoneNumbers) && (
<ul className="m">
<li>
<strong>Phone Numbers:</strong>
</li>
{phoneNumbers.map((phone) => {
return (
<li className="txt--teal" key={phone.phone_number_sid}>
{formatPhoneNumber(phone.number)}
</li>
);
})}
</ul>
)}
{hasLength(lcrs) && (
<ul className="m">
<li>
<strong>Outbound Call Routing:</strong>
</li>
{lcrs.map((lcr) => {
return (
<li className="txt--teal" key={lcr.lcr_sid}>
{lcr.name || "Default route"}
</li>
);
})}
</ul>
)}
</ModalClose>
)}
</>

View File

@@ -3,15 +3,17 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { CarrierForm } from "./form";
import { Carrier, SipGateway, SmppGateway } from "src/api/types";
import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import { Scope } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditCarrier = () => {
const { toastError } = useToast();
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Carrier>(

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,
@@ -40,7 +41,7 @@ import {
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
checkSelectOptions,
getIpValidationType,
@@ -52,19 +53,21 @@ 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";
import { useToast } from "src/components/toast/toast-provider";
type CarrierFormProps = {
carrier?: UseApiDataMap<Carrier>;
@@ -77,6 +80,7 @@ export const CarrierForm = ({
carrierSipGateways,
carrierSmppGateways,
}: CarrierFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
@@ -101,6 +105,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 +121,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 +147,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 +284,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 +303,9 @@ export const CarrierForm = ({
if (obj.smpp_inbound_password) {
setSmppInboundPass(obj.smpp_inbound_password);
}
if (obj.dtmf_type) {
setDtmfType(obj.dtmf_type);
}
}
};
@@ -502,6 +592,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 +622,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 +864,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 +1064,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

@@ -1,15 +1,16 @@
import React, { useState, useMemo, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { Link } from "react-router-dom";
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
import { Button, ButtonGroup, H1, Icon, M, MS } from "@jambonz/ui-kit";
import {
deleteCarrier,
deleteSipGateway,
deleteSmppGateway,
getFetch,
getSPVoipCarriers,
useApiData,
useServiceProviderData,
} from "src/api";
import { toastSuccess, toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { ROUTE_INTERNAL_CARRIERS } from "src/router/routes";
import {
AccountFilter,
@@ -17,20 +18,18 @@ import {
Section,
Spinner,
SearchFilter,
Pagination,
SelectFilter,
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { Gateways } from "./gateways";
import {
isUserAccountScope,
hasLength,
hasValue,
useFilteredResults,
} from "src/utils";
import { isUserAccountScope, hasLength, hasValue } from "src/utils";
import {
API_SIP_GATEWAY,
API_SMPP_GATEWAY,
CARRIER_REG_OK,
ENABLE_HOSTED_SYSTEM,
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { DeleteCarrier } from "./delete";
@@ -44,37 +43,62 @@ import type {
} from "src/api/types";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const Carriers = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [apiUrl, setApiUrl] = useState("");
const [carrier, setCarrier] = useState<Carrier | null>(null);
const [carriers, refetch] = useApiData<Carrier[]>(apiUrl);
const [carriers, setCarriers] = useState<Carrier[] | null>(null);
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
const [filter, setFilter] = useState("");
const carriersFiltered = useMemo(() => {
setAccountSid(getAccountFilter());
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
const [carriersTotal, setCarriersTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
// Add a ref to track previous values
const prevValuesRef = useRef({
serviceProviderId: "",
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const fetchCarriers = (resetPage = false) => {
if (!currentServiceProvider) return;
setCarriers(null);
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
}
return carriers
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null,
)
: [];
}, [accountSid, carrier, carriers]);
const filteredCarriers = useFilteredResults<Carrier>(
filter,
carriersFiltered,
);
getSPVoipCarriers(currentServiceProvider.service_provider_sid, {
page: currentPage,
page_size: Number(perPageFilter),
...(filter && { name: filter }),
...(accountSid && { account_sid: accountSid }),
})
.then(({ json }) => {
setCarriers(json.data);
setCarriersTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
})
.catch((error) => {
setCarriers([]);
toastError(error.msg);
});
};
const handleDelete = () => {
if (carrier) {
@@ -113,7 +137,7 @@ export const Carriers = () => {
);
});
setCarrier(null);
refetch();
fetchCarriers(false);
toastSuccess(
<>
Deleted Carrier <strong>{carrier.name}</strong>
@@ -126,14 +150,45 @@ export const Carriers = () => {
}
};
// Initial account setup
useEffect(() => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
);
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter());
}
}, [user, currentServiceProvider, accountSid]);
setLocation();
}, [user, accounts]);
// Combined effect for all data fetching
useEffect(() => {
if (!currentServiceProvider) return;
const prevValues = prevValuesRef.current;
const currentSPId = currentServiceProvider.service_provider_sid;
// Determine if we should reset pagination
const isFilterOrProviderChange =
prevValues.serviceProviderId !== currentSPId ||
prevValues.accountSid !== accountSid ||
prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== "25"; // Skip initial render
// Update ref for next comparison
prevValuesRef.current = {
serviceProviderId: currentSPId,
accountSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with page reset if filters changed
fetchCarriers(isFilterOrProviderChange || isPageSizeChange);
}, [currentServiceProvider, accountSid, filter, pageNumber, perPageFilter]);
return (
<>
@@ -159,6 +214,7 @@ export const Carriers = () => {
<SearchFilter
placeholder="Filter carriers"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -169,12 +225,12 @@ export const Carriers = () => {
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredCarriers) && { slim: true })}>
<Section {...(hasLength(carriers) && { slim: true })}>
<div className="list">
{!hasValue(carriers) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredCarriers) ? (
filteredCarriers.map((carrier) => (
) : hasLength(carriers) ? (
carriers.map((carrier) => (
<div className="item" key={carrier.voip_carrier_sid}>
<div className="item__info">
<div className="item__title">
@@ -274,6 +330,26 @@ export const Carriers = () => {
Add carrier
</Button>
</Section>
<footer>
<ButtonGroup>
<MS>
Total: {carriersTotal} record
{carriersTotal === 1 ? "" : "s"}
</MS>
{hasLength(carriers) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
{carrier && (
<DeleteCarrier
carrier={carrier}

View File

@@ -6,9 +6,9 @@ import {
getPcap,
getServiceProviderPcap,
} from "src/api";
import { toastError } from "src/store";
import type { DownloadedBlob } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type PcapButtonProps = {
accountSid: string;
@@ -21,6 +21,7 @@ export const PcapButton = ({
serviceProviderSid,
sipCallId,
}: PcapButtonProps) => {
const { toastError } = useToast();
const [pcap, setPcap] = useState<DownloadedBlob>();
useEffect(() => {

View File

@@ -3,11 +3,12 @@ import React, { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { Client } from "src/api/types";
import { toastError } from "src/store";
import ClientsForm from "./form";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { useToast } from "src/components/toast/toast-provider";
export const ClientsEdit = () => {
const { toastError } = useToast();
const params = useParams();
const navigate = useNavigate();
const [data, refetch, error] = useApiData<Client>(

View File

@@ -13,16 +13,18 @@ import { Section, Tooltip } from "src/components";
import { AccountSelect, Message, Passwd } from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import ClientsDelete from "./delete";
import { hasValue } from "src/utils";
import { IMessage } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
type ClientsFormProps = {
client?: UseApiDataMap<Client>;
};
export const ClientsForm = ({ client }: ClientsFormProps) => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const navigate = useNavigate();

View File

@@ -12,13 +12,16 @@ import {
Spinner,
} from "src/components";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { Scope } from "src/store/types";
import { hasLength, hasValue, useFilteredResults } from "src/utils";
import ClientsDelete from "./delete";
import { USER_ACCOUNT } from "src/api/constants";
import { useToast } from "src/components/toast/toast-provider";
import { getAccountFilter } from "src/store/localStore";
export const Clients = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
@@ -32,6 +35,7 @@ export const Clients = () => {
const [client, setClient] = useState<Client | null>();
const tmpFilteredClients = useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
return clients;

View File

@@ -4,8 +4,8 @@ import Card from "./card";
import { hasLength } from "src/utils";
import update from "immutability-helper";
import { deleteLcrRoute } from "src/api";
import { toastError, toastSuccess } from "src/store";
import { SelectorOption } from "src/components/forms/selector";
import { useToast } from "src/components/toast/toast-provider";
type ContainerProps = {
lcrRoute: [LcrRoute[], React.Dispatch<React.SetStateAction<LcrRoute[]>>];
@@ -16,6 +16,7 @@ export const Container = ({
lcrRoute: [lcrRoutes, setLcrRoutes],
carrierSelectorOptions,
}: ContainerProps) => {
const { toastSuccess, toastError } = useToast();
const moveCard = (dragIndex: number, hoverIndex: number) => {
setLcrRoutes((prevCards) =>
update(prevCards, {

View File

@@ -2,12 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, ButtonGroup, Icon, MS, MXS } from "@jambonz/ui-kit";
import { Icons, Section } from "src/components";
import {
toastError,
toastSuccess,
useDispatch,
useSelectState,
} from "src/store";
import { useDispatch, useSelectState } from "src/store";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { setLocation } from "src/store/localStore";
import { AccountSelect, Message, Selector } from "src/components/forms";
@@ -35,6 +30,7 @@ import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import Container from "./container";
import { hasValue } from "src/utils";
import { useToast } from "src/components/toast/toast-provider";
type LcrFormProps = {
lcrDataMap?: UseApiDataMap<Lcr>;
@@ -56,6 +52,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
],
};
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const dispatch = useDispatch();
@@ -85,7 +82,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers${accountSid ? `?account_sid=${accountSid}` : ""}`,
);
}
}, [user, currentServiceProvider, accountSid]);
@@ -95,16 +92,8 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setAccountSid(user?.account_sid);
}
const carriersFiltered = carriers
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null,
)
: [];
const ret = carriersFiltered
? carriersFiltered.map((c: Carrier, i) => {
const ret = carriers
? carriers.map((c: Carrier, i) => {
if (i === 0) {
setDefaultCarrier(c.voip_carrier_sid);
}
@@ -126,11 +115,16 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
return ret;
}, [accountSid, carriers]);
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
setLcrName(lcrDataMap.data.name || "");
setIsActive(lcrDataMap.data.is_active);
setPreviousLcr(lcrDataMap.data);
}
useEffect(() => {
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data !== previouseLcr) {
setLcrName(lcrDataMap.data.name || "");
setIsActive(lcrDataMap.data.is_active);
setPreviousLcr(lcrDataMap.data);
if (lcrDataMap.data.account_sid) {
setAccountSid(lcrDataMap.data.account_sid);
}
}
}, [lcrDataMap?.data, previouseLcr]);
useMemo(() => {
let default_lcr_route_sid = "";

View File

@@ -14,7 +14,7 @@ import {
} from "src/components";
import { ScopedAccess } from "src/components/scoped-access";
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
import { toastSuccess, toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
// import { getAccountFilter, setLocation } from "src/store/localStore";
import { Scope } from "src/store/types";
import {
@@ -25,8 +25,10 @@ import {
} from "src/utils";
import { USER_ACCOUNT } from "src/api/constants";
import DeleteLcr from "./delete";
import { useToast } from "src/components/toast/toast-provider";
export const Lcrs = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
useScopedRedirect(
Scope.admin,

View File

@@ -3,12 +3,13 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { toastError } from "src/store";
import { MsTeamsTenantForm } from "./form";
import type { MSTeamsTenant } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditMsTeamsTenant = () => {
const { toastError } = useToast();
const params = useParams();
const [data, refetch, error] = useApiData<MSTeamsTenant>(
`MicrosoftTeamsTenants/${params.ms_teams_tenant_sid}`,

View File

@@ -15,7 +15,7 @@ import {
ApplicationSelect,
} from "src/components/forms";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
@@ -28,6 +28,7 @@ import type {
MSTeamsTenant,
UseApiDataMap,
} from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type MsTeamsTenantFormProps = {
msTeamsTenant?: UseApiDataMap<MSTeamsTenant>;
@@ -36,6 +37,7 @@ type MsTeamsTenantFormProps = {
export const MsTeamsTenantForm = ({
msTeamsTenant,
}: MsTeamsTenantFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");

View File

@@ -13,7 +13,6 @@ import {
withAccessControl,
useFilteredResults,
} from "src/utils";
import { toastError, toastSuccess } from "src/store";
import {
Icons,
Section,
@@ -29,8 +28,10 @@ import { DeleteMsTeamsTenant } from "./delete";
import type { Account, MSTeamsTenant, Application } from "src/api/types";
import type { ACLGetIMessage } from "src/utils/with-access-control";
import { useToast } from "src/components/toast/toast-provider";
export const MSTeamsTenants = () => {
const { toastSuccess, toastError } = useToast();
const [msTeamsTenant, setMsTeamsTenant] = useState<MSTeamsTenant | null>(
null,
);

View File

@@ -3,12 +3,13 @@ import { H1 } from "@jambonz/ui-kit";
import { useParams } from "react-router-dom";
import { useApiData } from "src/api";
import { toastError } from "src/store";
import { PhoneNumberForm } from "./form";
import type { PhoneNumber } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
export const EditPhoneNumber = () => {
const { toastError } = useToast();
const params = useParams();
const [data, refetch, error] = useApiData<PhoneNumber>(
`PhoneNumbers/${params.phone_number_sid}`,

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 {
@@ -20,7 +20,6 @@ import {
ROUTE_INTERNAL_CARRIERS,
ROUTE_INTERNAL_PHONE_NUMBERS,
} from "src/router/routes";
import { toastError, toastSuccess } from "src/store";
import { hasLength, useRedirect } from "src/utils";
import type {
@@ -31,22 +30,25 @@ import type {
UseApiDataMap,
} from "src/api/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
type PhoneNumberFormProps = {
phoneNumber?: UseApiDataMap<PhoneNumber>;
};
export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const { toastSuccess, toastError } = useToast();
const navigate = useNavigate();
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 +57,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 +140,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 +181,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 +210,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

@@ -1,13 +1,14 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import { Button, ButtonGroup, H1, Icon, MS } from "@jambonz/ui-kit";
import { Link } from "react-router-dom";
import {
deletePhoneNumber,
getPhoneNumbers,
putPhoneNumber,
useServiceProviderData,
} from "src/api";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
Icons,
Section,
@@ -15,34 +16,33 @@ import {
ApplicationFilter,
SearchFilter,
AccountFilter,
Pagination,
SelectFilter,
} from "src/components";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_CARRIERS,
ROUTE_INTERNAL_PHONE_NUMBERS,
} from "src/router/routes";
import {
hasLength,
hasValue,
formatPhoneNumber,
useFilteredResults,
} from "src/utils";
import { hasLength, hasValue, formatPhoneNumber } from "src/utils";
import { DeletePhoneNumber } from "./delete";
import type { Account, PhoneNumber, Carrier, Application } from "src/api/types";
import { USER_ACCOUNT } from "src/api/constants";
import { PER_PAGE_SELECTION, 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";
import { useToast } from "src/components/toast/toast-provider";
export const PhoneNumbers = () => {
const { toastSuccess, toastError } = useToast();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
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[]
>([]);
@@ -51,20 +51,54 @@ export const PhoneNumbers = () => {
const [applyMassEdit, setApplyMassEdit] = useState(false);
const [filter, setFilter] = useState("");
const [accountSid, setAccountSid] = useState("");
const [phoneNumbersTotal, setphoneNumbersTotal] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
const [maxPageNumber, setMaxPageNumber] = useState(1);
const phoneNumbersFiltered = useMemo(() => {
setAccountSid(getAccountFilter());
return phoneNumbers
? phoneNumbers.filter(
(phn) => !accountSid || phn.account_sid === accountSid,
)
: [];
}, [accountSid, phoneNumbers]);
// Add ref to track previous values
const prevValuesRef = useRef({
serviceProviderId: "",
accountSid: "",
filter: "",
pageNumber: 1,
perPageFilter: "25",
});
const filteredPhoneNumbers = useFilteredResults<PhoneNumber>(
filter,
phoneNumbersFiltered,
);
const fetchPhoneNumbers = (resetPage = false) => {
setPhoneNumbers(null);
// Calculate the correct page to use
const currentPage = resetPage ? 1 : pageNumber;
// If we're resetting the page, also update the state
if (resetPage && pageNumber !== 1) {
setPageNumber(1);
}
const accSid = accountSid || getAccountFilter() || "";
getPhoneNumbers({
page: currentPage,
page_size: Number(perPageFilter),
...(accSid && { account_sid: accSid }),
...(filter && { filter }),
...(currentServiceProvider?.service_provider_sid && {
service_provider_sid: currentServiceProvider.service_provider_sid,
}),
})
.then(({ json }) => {
if (json) {
setPhoneNumbers(json.data);
setphoneNumbersTotal(json.total);
setMaxPageNumber(Math.ceil(json.total / Number(perPageFilter)));
}
})
.catch((error) => {
setPhoneNumbers([]);
toastError(error.msg);
});
};
const handleMassEdit = () => {
Promise.all(
@@ -77,9 +111,11 @@ export const PhoneNumbers = () => {
}),
)
.then(() => {
refetch();
fetchPhoneNumbers(false);
setApplicationSid("");
setApplyMassEdit(false);
setSelectAll(false);
setSelectedPhoneNumbers([]);
toastSuccess("Number routing updated successfully");
})
.catch((error) => {
@@ -93,7 +129,7 @@ export const PhoneNumbers = () => {
if (phoneNumber) {
deletePhoneNumber(phoneNumber.phone_number_sid)
.then(() => {
refetch();
fetchPhoneNumbers(false);
setPhoneNumber(null);
toastSuccess(
<>
@@ -107,13 +143,44 @@ export const PhoneNumbers = () => {
}
};
// Initial account setup
useEffect(() => {
setLocation();
if (user?.account_sid && user.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
} else {
setAccountSid(getAccountFilter() || accountSid);
}
setLocation();
}, [user]);
// Combined effect for all data fetching
useEffect(() => {
const prevValues = prevValuesRef.current;
const currentSPId = currentServiceProvider?.service_provider_sid;
// Detect changes that require page reset
const isFilterOrProviderChange =
prevValues.serviceProviderId !== currentSPId ||
prevValues.accountSid !== accountSid ||
prevValues.filter !== filter;
const isPageSizeChange =
prevValues.perPageFilter !== perPageFilter &&
prevValues.perPageFilter !== "25"; // Skip initial render
// Update ref for next comparison
prevValuesRef.current = {
serviceProviderId: currentSPId || "",
accountSid,
filter,
pageNumber,
perPageFilter,
};
// Fetch data with appropriate reset parameter
fetchPhoneNumbers(isFilterOrProviderChange || isPageSizeChange);
}, [currentServiceProvider, accountSid, filter, pageNumber, perPageFilter]);
return (
<>
<section className="mast">
@@ -133,6 +200,7 @@ export const PhoneNumbers = () => {
<SearchFilter
placeholder="Filter phone numbers"
filter={[filter, setFilter]}
delay={1000}
/>
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
@@ -142,11 +210,11 @@ export const PhoneNumbers = () => {
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredPhoneNumbers) && { slim: true })}>
<Section {...(hasLength(phoneNumbers) && { slim: true })}>
<div className="list">
{!hasValue(phoneNumbers) ? (
<Spinner />
) : hasLength(filteredPhoneNumbers) ? (
) : hasLength(phoneNumbers) ? (
<>
<div className="item item--actions">
{accountSid ? (
@@ -160,7 +228,7 @@ export const PhoneNumbers = () => {
onChange={(e) => {
if (e.target.checked) {
setSelectAll(true);
setSelectedPhoneNumbers(filteredPhoneNumbers);
setSelectedPhoneNumbers(phoneNumbers);
} else {
setSelectAll(false);
setSelectedPhoneNumbers([]);
@@ -184,10 +252,8 @@ export const PhoneNumbers = () => {
<Button
small
onClick={() => {
handleMassEdit();
setSelectAll(false);
setApplyMassEdit(true);
setSelectedPhoneNumbers([]);
handleMassEdit();
}}
>
Apply
@@ -209,7 +275,7 @@ export const PhoneNumbers = () => {
</MS>
)}
</div>
{filteredPhoneNumbers.map((phoneNumber) => {
{phoneNumbers.map((phoneNumber) => {
return (
<div className="item" key={phoneNumber.phone_number_sid}>
<div className="item__info">
@@ -345,6 +411,26 @@ export const PhoneNumbers = () => {
</Button>
)}
</Section>
<footer>
<ButtonGroup>
<MS>
Total: {phoneNumbersTotal} record
{phoneNumbersTotal === 1 ? "" : "s"}
</MS>
{hasLength(phoneNumbers) && (
<Pagination
pageNumber={pageNumber}
setPageNumber={setPageNumber}
maxPageNumber={maxPageNumber}
/>
)}
<SelectFilter
id="page_filter"
filter={[perPageFilter, setPerPageFilter]}
options={PER_PAGE_SELECTION}
/>
</ButtonGroup>
</footer>
{phoneNumber && (
<DeletePhoneNumber
phoneNumber={phoneNumber}

View File

@@ -0,0 +1,132 @@
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 { hasValue } from "src/utils";
import utc from "dayjs/plugin/utc";
import { useToast } from "src/components/toast/toast-provider";
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 { toastError, toastSuccess } = useToast();
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

@@ -8,7 +8,7 @@ import {
PER_PAGE_SELECTION,
USER_ACCOUNT,
} from "src/api/constants";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
Section,
AccountFilter,
@@ -28,6 +28,7 @@ import {
getQueryFilter,
setLocation,
} from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
const directionSelection = [
{ name: "either", value: "io" },
@@ -42,6 +43,7 @@ const statusSelection = [
];
export const RecentCalls = () => {
const { toastError } = useToast();
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [accountSid, setAccountSid] = useState("");
@@ -63,7 +65,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 }),
@@ -82,10 +89,10 @@ export const RecentCalls = () => {
};
useMemo(() => {
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
if (getQueryFilter()) {
const [date, direction, status] = getQueryFilter().split("/");
setAccountSid(getAccountFilter() || accountSid);
if (!accountSid && user?.account_sid) setAccountSid(user?.account_sid);
setDateFilter(date);
setDirectionFilter(direction);
setStatusFilter(status);
@@ -157,7 +164,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

@@ -1,15 +1,16 @@
import React, { useEffect, useState } from "react";
import { getPcap } from "src/api";
import { toastError } from "src/store";
import type { DownloadedBlob, RecentCall } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type PcapButtonProps = {
call: RecentCall;
};
export const PcapButton = ({ call }: PcapButtonProps) => {
const { toastError } = useToast();
const [pcap, setPcap] = useState<DownloadedBlob | null>(null);
useEffect(() => {

View File

@@ -22,13 +22,14 @@ import {
getSpansByNameRegex,
getSpansFromJaegerRoot,
} from "./utils";
import { toastError, toastSuccess } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
type PlayerProps = {
call: RecentCall;
};
export const Player = ({ call }: PlayerProps) => {
const { toastSuccess, toastError } = useToast();
const { recording_url, call_sid } = call;
const url =
recording_url && recording_url.startsWith("http://")

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

@@ -13,7 +13,6 @@ import {
SystemInformation,
TtsCache,
} from "src/api/types";
import { toastError, toastSuccess } from "src/store";
import { Selector } from "src/components/forms";
import { hasValue, isvalidIpv4OrCidr } from "src/utils";
import {
@@ -22,8 +21,10 @@ import {
PASSWORD_MIN,
} from "src/api/constants";
import { Modal } from "src/components";
import { useToast } from "src/components/toast/toast-provider";
export const AdminSettings = () => {
const { toastSuccess, toastError } = useToast();
const [passwordSettings, passwordSettingsFetcher] =
useApiData<PasswordSettings>("PasswordSettings");
const [systemInformation, systemInformationFetcher] =

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react";
import { P, Button, ButtonGroup } from "@jambonz/ui-kit";
import { useDispatch, toastSuccess, toastError } from "src/store";
import { useDispatch } from "src/store";
import { hasLength } from "src/utils";
import {
putServiceProvider,
@@ -15,6 +15,7 @@ import { Checkzone, LocalLimits } from "src/components/forms";
import { withSelectState } from "src/utils";
import type { Limit, ServiceProvider } from "src/api/types";
import { removeActiveSP } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export type ServiceProviderSettingsProps = {
serviceProviders: ServiceProvider[];
@@ -25,6 +26,7 @@ export const ServiceProviderSettings = ({
serviceProviders,
currentServiceProvider,
}: ServiceProviderSettingsProps) => {
const { toastSuccess, toastError } = useToast();
const dispatch = useDispatch();
const [limits, refetchLimits] = useServiceProviderData<Limit[]>("Limits");
const [name, setName] = useState("");

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { H1 } from "@jambonz/ui-kit";
import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { SpeechServiceForm } from "./form";
import type { SpeechCredential } from "src/api/types";
@@ -11,6 +11,7 @@ import { useScopedRedirect } from "src/utils/use-scoped-redirect";
import { Scope } from "src/store/types";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import { useParams } from "react-router-dom";
import { useToast } from "src/components/toast/toast-provider";
export const EditSpeechService = () => {
const params = useParams();
@@ -18,6 +19,7 @@ export const EditSpeechService = () => {
const currentServiceProvider = useSelectState("currentServiceProvider");
const [url, setUrl] = useState("");
const [data, refetch, error] = useApiData<SpeechCredential>(url);
const { toastError } = useToast();
useScopedRedirect(
Scope.account,

View File

@@ -12,12 +12,13 @@ import {
Checkzone,
Message,
} from "src/components/forms";
import { toastError, toastSuccess, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import {
deleteGoogleCustomVoice,
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,
@@ -86,12 +91,14 @@ import {
GOOGLE_CUSTOM_VOICES_REPORTED_USAGE,
VERBIO_STT_MODELS,
} from "src/api/constants";
import { useToast } from "src/components/toast/toast-provider";
type SpeechServiceFormProps = {
credential?: UseApiDataMap<SpeechCredential>;
};
export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
const { toastError, toastSuccess } = useToast();
const navigate = useNavigate();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
@@ -120,6 +127,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 +150,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 +171,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 +194,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 +233,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 +249,25 @@ 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:
case VENDOR_DEEPGRAM:
return "Model ID";
default:
return "Model";
}
};
const handlePutGoogleCustomVoices = () => {
if (!credential || !credential.data) {
return;
@@ -243,14 +275,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 +326,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 +395,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,10 +431,15 @@ 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,
deepgram_stt_use_tls: deepgramSttUseTls ? 1 : 0,
model_id: sttModelId || null,
}),
...(vendor === VENDOR_SPEECHMATICS && {
speechmatics_stt_uri: speechmaticsEndpoint || null,
@@ -376,6 +447,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 +490,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 +515,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 +564,10 @@ 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 ||
vendor === VENDOR_DEEPGRAM
) {
getSpeechSupportedLanguagesAndVoices(
currentServiceProvider?.service_provider_sid,
@@ -477,12 +577,9 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
).then(({ json }) => {
if (json.models) {
setTtsModels(json.models);
if (
json.models.length > 0 &&
!json.models.find((m) => m.value === ttsModelId)
) {
setTtsModelId(json.models[0].value);
}
}
if (json.sttModels) {
setSttModels(json.sttModels);
}
});
} else {
@@ -490,6 +587,24 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
}
}, [vendor]);
useEffect(() => {
const modelId = credential?.data?.model_id || "";
if (sttModels.length > 0 && !sttModels.some((m) => m.value === modelId)) {
setSttModelId(sttModels[0].value);
} else {
setSttModelId(modelId);
}
}, [credential, sttModels]);
useEffect(() => {
const modelId = credential?.data?.model_id || "";
if (ttsModels.length > 0 && !ttsModels.some((m) => m.value === modelId)) {
setTtsModelId(ttsModels[0].value);
} else {
setTtsModelId(modelId);
}
}, [credential, ttsModels]);
useEffect(() => {
setLocation();
if (credential && credential.data) {
@@ -623,6 +738,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 +753,15 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
if (credential.data.model_id) {
setTtsModelId(credential.data.model_id);
}
if (
credential.data.model_id &&
(vendor === VENDOR_OPENAI || vendor === VENDOR_DEEPGRAM)
) {
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 +824,7 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
);
setSpeechmaticsEndpoint(credential.data.speechmatics_stt_uri);
}
setInitialPlayhtOnpremCheck(hasValue(credential?.data?.playht_tts_uri));
}, [credential]);
const updateCustomVoices = (
@@ -808,9 +939,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 +961,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 +985,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 +1133,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 +1157,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 +1180,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 +1326,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 +1570,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 +1607,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 +1710,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 +1731,26 @@ export const SpeechServiceForm = ({ credential }: SpeechServiceFormProps) => {
/>
</fieldset>
)}
{(vendor == VENDOR_OPENAI || vendor === VENDOR_DEEPGRAM) &&
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

@@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
import { USER_ACCOUNT } from "src/api/constants";
import { AccountFilter, Icons, Section, Spinner } from "src/components";
import { useSelectState, toastError, toastSuccess } from "src/store";
import { useSelectState } from "src/store";
import {
deleteSpeechService,
useServiceProviderData,
@@ -12,7 +12,6 @@ import {
} from "src/api";
import { ROUTE_INTERNAL_SPEECH } from "src/router/routes";
import {
getHumanDateTime,
isUserAccountScope,
hasLength,
hasValue,
@@ -27,8 +26,10 @@ import { ScopedAccess } from "src/components/scoped-access";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
import { VENDOR_CUSTOM } from "src/vendor";
import { useToast } from "src/components/toast/toast-provider";
export const SpeechServices = () => {
const { toastError, toastSuccess } = useToast();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [apiUrl, setApiUrl] = useState("");
@@ -178,24 +179,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

@@ -5,11 +5,12 @@ import { useParams } from "react-router-dom";
import { UserForm } from "./form";
import { useApiData } from "src/api";
import { User } from "src/api/types";
import { toastError } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
export const EditUser = () => {
const params = useParams();
const [data, refetch, error] = useApiData<User>(`Users/${params.user_sid}`);
const { toastError } = useToast();
/** Handle error toast at top level... */
useEffect(() => {

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect } 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 { useSelectState } from "src/store";
import {
deleteUser,
postFetch,
@@ -38,12 +38,14 @@ import type {
} from "src/api/types";
import type { IMessage } from "src/store/types";
import { setAccountFilter, setLocation } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
type UserFormProps = {
user?: UseApiDataMap<User>;
};
export const UserForm = ({ user }: UserFormProps) => {
const { toastSuccess, toastError } = useToast();
const { signout } = useAuth();
const navigate = useNavigate();
const currentUser = useSelectState("user");
@@ -61,6 +63,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 +118,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 +149,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 +177,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 +259,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 +299,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

@@ -8,9 +8,10 @@ import { useNavigate } from "react-router-dom";
import { MSG_SOMETHING_WRONG } from "src/constants";
import { ROUTE_LOGIN } from "src/router/routes";
import { toastSuccess } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
export const ForgotPassword = () => {
const { toastSuccess } = useToast();
const [message, setMessage] = useState("");
const [email, setEmail] = useState("");
const navigate = useNavigate();

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react";
import { Button, H1 } from "@jambonz/ui-kit";
import { useLocation, Navigate, Link } from "react-router-dom";
import { toastError, toastSuccess } from "src/store";
import { getToken, parseJwt, useAuth } from "src/router/auth";
import {
SESS_FLASH_MSG,
@@ -26,8 +25,10 @@ import { v4 as uuid } from "uuid";
import { setLocationBeforeOauth, setOauthState } from "src/store/localStore";
import { getGithubOauthUrl, getGoogleOauthUrl } from "./utils";
import { UserData } from "src/store/types";
import { useToast } from "src/components/toast/toast-provider";
export const Login = () => {
const { toastSuccess, toastError } = useToast();
const state = uuid();
setOauthState(state);
setLocationBeforeOauth("/sign-in");
@@ -99,6 +100,7 @@ export const Login = () => {
value={password}
placeholder="Password"
setValue={setPassword}
ignorePasswordManager={false}
/>
{message && <Message message={message} />}
<Button type="submit">Log in</Button>

View File

@@ -8,6 +8,7 @@ import {
BASE_URL,
} from "src/api/constants";
import { Spinner } from "src/components";
import { useToast } from "src/components/toast/toast-provider";
import { setToken } from "src/router/auth";
import {
ROUTE_INTERNAL_ACCOUNTS,
@@ -15,7 +16,6 @@ import {
ROUTE_REGISTER,
ROUTE_REGISTER_SUB_DOMAIN,
} from "src/router/routes";
import { toastError } from "src/store";
import {
getLocationBeforeOauth,
getOauthState,
@@ -25,6 +25,7 @@ import {
} from "src/store/localStore";
export const OauthCallback = () => {
const { toastError } = useToast();
const queryParams = new URLSearchParams(location.search);
const code = queryParams.get("code");
const newState = queryParams.get("state");

View File

@@ -7,10 +7,11 @@ import { Passwd } from "src/components/forms";
import { ROUTE_LOGIN, ROUTE_REGISTER_EMAIL_VERIFY } from "src/router/routes";
import { generateActivationCode } from "./utils";
import { setToken } from "src/router/auth";
import { toastError } from "src/store";
import { setRootDomain } from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
export const RegisterEmail = () => {
const { toastError } = useToast();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();

View File

@@ -2,15 +2,16 @@ import { Button, H1, MS } from "@jambonz/ui-kit";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { putActivationCode } from "src/api";
import { useToast } from "src/components/toast/toast-provider";
import { getToken, parseJwt } from "src/router/auth";
import {
ROUTE_REGISTER_EMAIL,
ROUTE_REGISTER_SUB_DOMAIN,
} from "src/router/routes";
import { toastError } from "src/store";
import { UserData } from "src/store/types";
export const EmailVerify = () => {
const { toastError } = useToast();
const [code, setCode] = useState("");
const userData: UserData = parseJwt(getToken());
const navigate = useNavigate();

View File

@@ -3,11 +3,12 @@ import React, { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { postChangepassword, postSignIn } from "src/api";
import { Message, Passwd } from "src/components/forms";
import { useToast } from "src/components/toast/toast-provider";
import { setToken } from "src/router/auth";
import { ROUTE_LOGIN } from "src/router/routes";
import { toastError, toastSuccess } from "src/store";
export const ResetPassword = () => {
const { toastError, toastSuccess } = useToast();
const params = useParams();
const resetId = params.id;
const [newPassword, setNewPassword] = useState("");

View File

@@ -7,17 +7,20 @@ import { AuthProvider } from "./router/auth";
import { Router } from "./router";
import "./styles/index.scss";
import { ToastProvider } from "./components/toast/toast-provider";
const root: Element = document.getElementById("root")!;
createRoot(root).render(
<React.StrictMode>
<StateProvider>
<BrowserRouter>
<AuthProvider>
<Router />
</AuthProvider>
</BrowserRouter>
</StateProvider>
<ToastProvider>
<StateProvider>
<BrowserRouter>
<AuthProvider>
<Router />
</AuthProvider>
</BrowserRouter>
</StateProvider>
</ToastProvider>
</React.StrictMode>,
);

View File

@@ -25,11 +25,12 @@ import {
import type { UserLogin } from "src/api/types";
import { ENABLE_HOSTED_SYSTEM, USER_ACCOUNT } from "src/api/constants";
import type { UserData } from "src/store/types";
import { toastError } from "src/store";
import {
clearLocalStorage,
removeLocationBeforeOauth,
removeOauthState,
} from "src/store/localStore";
import { useToast } from "src/components/toast/toast-provider";
interface SignIn {
(username: string, password: string): Promise<UserLogin>;
@@ -86,7 +87,6 @@ export const parseJwt = (token: string) => {
})
.join(""),
);
return JSON.parse(jsonPayload);
};
@@ -94,6 +94,7 @@ export const parseJwt = (token: string) => {
* Provider hook that creates auth object and handles state
*/
export const useProvideAuth = (): AuthStateContext => {
const { toastError } = useToast();
let token = getToken();
let userData: UserData;
const navigate = useNavigate();
@@ -107,7 +108,6 @@ export const useProvideAuth = (): AuthStateContext => {
token = response.json.token;
setToken(token);
userData = parseJwt(token);
if (ENABLE_HOSTED_SYSTEM) {
getMe()
.then(({ json }) => {
@@ -165,7 +165,7 @@ export const useProvideAuth = (): AuthStateContext => {
postLogout()
.then((response) => {
if (response.status === StatusCodes.NO_CONTENT) {
localStorage.clear();
clearLocalStorage();
sessionStorage.clear();
sessionStorage.setItem(SESS_FLASH_MSG, MSG_LOGGED_OUT);
window.location.href = ROUTE_LOGIN;

View File

@@ -1,6 +1,5 @@
import React, { useReducer, useContext } from "react";
import { TOAST_TIME } from "src/constants";
import {
genericAction,
userAsyncAction,
@@ -11,8 +10,6 @@ import {
} from "./actions";
import type {
IMessage,
Toast,
State,
Action,
MiddleWare,
@@ -44,28 +41,17 @@ const reducer: React.Reducer<State, Action<keyof State>> = (state, action) => {
return serviceProvidersAction(state, action);
case "currentServiceProvider":
return currentServiceProviderAction(state, action);
default:
throw new Error();
}
};
let toastTimeout: number;
/** Async middlewares */
/** Proxies dispatch to reducer */
const middleware: MiddleWare = (dispatch) => {
/** This generic implementation enforces global dispatch type-safety */
return <Type extends keyof State>(action: Action<Type>) => {
switch (action.type) {
case "toast":
if (toastTimeout) {
clearTimeout(toastTimeout);
}
toastTimeout = setTimeout(() => {
dispatch({ type: "toast" });
}, TOAST_TIME);
return dispatch(action);
case "user":
return userAsyncAction().then((payload) => {
dispatch({ ...action, payload });
@@ -107,28 +93,6 @@ export const useDispatch = (): GlobalDispatch => {
return globalDispatch;
};
/** Toast dispatch helpers to make component code less cumbersome */
const toastDispatch = (payload: Toast) => {
globalDispatch({
type: "toast",
payload,
});
};
export const toastError = (msg: IMessage) => {
toastDispatch({
type: "error",
message: msg,
});
};
export const toastSuccess = (msg: IMessage) => {
toastDispatch({
type: "success",
message: msg,
});
};
/** Wrapper hook for state context */
export const useStateContext = () => {
const { state } = useContext(StateContext);

View File

@@ -123,7 +123,18 @@ export const checkLocation = () => {
if (currentLocation !== storedLocation) {
localStorage.removeItem(storeQueryFilter);
localStorage.removeItem(storeAccountFilter);
// Keep storeAccountFilter in different location that user can search for same account
// in different location
// localStorage.removeItem(storeAccountFilter);
return;
}
};
export const clearLocalStorage = () => {
const toKeep = [storeActiveSP, storeAccountFilter];
Object.keys(localStorage).forEach((key) => {
if (!toKeep.includes(key)) {
localStorage.removeItem(key);
}
});
};

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

@@ -151,6 +151,14 @@ fieldset {
margin-top: ui-vars.$px01;
}
div + textarea {
margin-top: ui-vars.$px02;
}
textarea {
border-radius: ui-vars.$px01;
}
* + label {
margin-top: ui-vars.$px02;
}
@@ -329,6 +337,11 @@ fieldset {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
&:nth-child(5) {
grid-template-columns: [col] 100%;
margin-top: ui-vars.$px02;
}
}
> button {

View File

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

View File

@@ -1,7 +1,6 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toastError } from "src/store";
import { useToast } from "src/components/toast/toast-provider";
import type { IMessage } from "src/store/types";
@@ -11,6 +10,7 @@ export const useRedirect = <Type>(
message: IMessage,
) => {
const navigate = useNavigate();
const { toastError } = useToast();
useEffect(() => {
if (collection && !collection.length) {

View File

@@ -7,8 +7,9 @@ import {
SpeechCredential,
User,
} from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
import { toastError, useSelectState } from "src/store";
import { useSelectState } from "src/store";
import { IMessage, Scope, UserData } from "src/store/types";
@@ -21,6 +22,7 @@ export const useScopedRedirect = (
) => {
const navigate = useNavigate();
const currentServiceProvider = useSelectState("currentServiceProvider");
const { toastError } = useToast();
useEffect(() => {
if (
@@ -47,5 +49,5 @@ export const useScopedRedirect = (
navigate(redirect);
}
}, [user, currentServiceProvider, data]);
}, [user, currentServiceProvider, data, toastError]);
};

View File

@@ -2,11 +2,12 @@ import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { toastError, useSelectState, useAccessControl } from "src/store";
import { useSelectState, useAccessControl } from "src/store";
import { ROUTE_INTERNAL_SETTINGS } from "src/router/routes";
import type { ACL, IMessage } from "src/store/types";
import type { ServiceProvider } from "src/api/types";
import { useToast } from "src/components/toast/toast-provider";
type PassthroughProps = {
[key: string]: unknown;
@@ -22,6 +23,7 @@ export const withAccessControl = (
) => {
return function WithAccessControl(Component: React.ComponentType) {
return function ComponentWithAccessControl(props: PassthroughProps) {
const { toastError } = useToast();
const navigate = useNavigate();
const hasPermission = useAccessControl(acl);
const currentServiceProvider = useSelectState("currentServiceProvider");

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 {