Compare commits

...

99 Commits

Author SHA1 Message Date
Quan HL
5ed96314c2 support verbio speech 2024-05-20 21:30:17 +07:00
Hoan Luu Huu
6b9167e6b8 support speech aws polly by roleArn (#428)
* support speech aws polly by roleArn

* add 3 types of aws poly credential

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

* up options ping

* update review comments
2024-04-15 07:19:35 -04:00
Hoan Luu Huu
4df5709c10 print streaming tts span details (#426)
* print streaming tts span details

* wip
2024-04-14 09:15:52 -04:00
Hoan Luu Huu
760ddd64bb support mod_rimelabs_tts (#425) 2024-04-12 07:11:44 -04:00
Hoan Luu Huu
bd8612bb67 fix playht voice engine is not correctly display (#424) 2024-04-09 06:53:53 -04:00
Hoan Luu Huu
bc68eb8e71 support mod_playht_tts (#423)
* support mod_playht_tts

* wip

* wip

* fix code style
2024-04-08 10:24:06 -04:00
Dave Horton
ea2713a021 update deps (#422)
* update deps

* prettier

* wip
2024-04-07 18:42:42 -04:00
Dave Horton
1d1909732f Revert "register use tls (#418)"
This reverts commit d3354bbe9d.
2024-04-06 13:48:50 -04:00
Hoan Luu Huu
d3354bbe9d register use tls (#418)
* register use tls

* update Use SIP over TLS label
2024-04-04 08:03:12 -04:00
Hoan Luu Huu
d95b8073d3 update azure speech regions (#416) 2024-04-01 07:35:33 -04:00
Hoan Luu Huu
e8355a1dd3 issue 409, shoud not get Invoices in self-hosted system (#412) 2024-03-19 07:23:10 -04:00
Hoan Luu Huu
8be61ddfad remove use_stream in elevenlabs and whisper speech (#406) 2024-02-20 08:01:12 -05:00
Dave Horton
05c1d9efaa prettier fix 2024-02-12 09:03:12 -05:00
Dave Horton
01a5476dfe update github actions 2024-02-12 08:57:49 -05:00
Hoan Luu Huu
9d2fee64e6 Support deepgram onprem (#398)
* Support deepgram onprem

* wip

* wip
2024-02-12 08:52:54 -05:00
Hoan Luu Huu
3a87f5f1c2 support use_streaming elevenlabs and whisper (#396) 2024-02-12 08:16:31 -05:00
Hoan Luu Huu
a991b56a4e Fix/speech selection after updating application (#395)
* fix speech selection show app languge and voice

* wip

* wip

* wip

* wip
2024-01-27 11:26:37 -05:00
Dave Horton
6e14207327 sync with change of property name to time_to_first_byte_ms (#394) 2024-01-25 13:16:42 -05:00
Hoan Luu Huu
8d8d46e76e draw tts region label to lower possition (#393) 2024-01-24 19:48:56 -05:00
Hoan Luu Huu
7f72d739cd Fix/tts region use tts_time_to_first_byte streaming api (#391)
* tts region tts_time_to_first_byte_ms

* wip
2024-01-24 10:19:46 -05:00
Hoan Luu Huu
c804d60664 wavesurfer region use s instead of sec (#390) 2024-01-24 08:44:31 -05:00
Hoan Luu Huu
df3fc8f2b7 playback latency region on wavesurfer (#387)
* playback latency region on wavesurfer

* remove start-audio region
2024-01-22 20:31:02 -05:00
Hoan Luu Huu
65e5b511c3 gather speech verb hook latency (#383)
* gather speech verb hook latency

* wip
2024-01-17 12:37:36 -05:00
Hoan Luu Huu
dc519bdef9 fix remove speech label, cause application save wrong label (#384)
* fix remove speech label, cause application save wrong label

* fix microsoft custom_tts input
2024-01-17 09:17:25 -05:00
Hoan Luu Huu
af1ba3a15c fix app cannot set elevenlab tts (#386) 2024-01-17 07:30:36 -05:00
Hoan Luu Huu
67b7792d04 tts options should change if language is changed (#381) 2024-01-14 21:48:43 -05:00
Hoan Luu Huu
c5e7eb0d23 fix stt/tts regoin on wrong position (#380)
* fix stt/tts regoin on wrong position

* wip

* fix speech credentials does not choose default model

* fix speech credentials does not choose default model
2024-01-13 11:08:07 -05:00
Hoan Luu Huu
6ddcb82adc update jambonz cloud free plan subscription (#379) 2024-01-12 09:24:10 -05:00
Hoan Luu Huu
8b9c7ca9c0 Feat/tts stt voices languages from api server (#376)
* support fetching tts/stt language and voice from api server

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
2024-01-12 07:34:22 -05:00
Hoan Luu Huu
353c7cfff8 update google tts language (#375) 2024-01-05 07:18:39 -05:00
Hoan Luu Huu
7828dc3827 fix default value for elevenlab optimize_streaming_latency (#374) 2024-01-03 07:21:29 -05:00
Hoan Luu Huu
213267f682 fix, change synthLange does not update synthVoice (#372) 2023-12-27 09:37:19 -05:00
Hoan Luu Huu
cf056ae6f1 fix issue that when speech has label, save application should use the first label in the list (#371) 2023-12-27 09:33:44 -05:00
Hoan Luu Huu
1c16d707ca fix deepgram tts default model and update stt languages (#370) 2023-12-26 18:48:59 -05:00
Hoan Luu Huu
2f2e58e180 support deepgram (#369)
* support deepgram

* wip
2023-12-26 07:53:13 -05:00
Hoan Luu Huu
eae674b992 fix application is blank page if choosing custom speech vendor (#361) 2023-12-06 09:06:57 -05:00
Hoan Luu Huu
aa7889a0d8 tts latency feature (#359)
* tts latency feature

* fix review comment
2023-12-05 20:33:53 -05:00
Hoan Luu Huu
a892550b06 elevenlabs new model and options (#357)
* elevenlabs new model and options

* beautify

* fix review comments
2023-11-30 10:20:35 -05:00
Hoan Luu Huu
053f8e509f update azure voices (#356) 2023-11-24 09:18:06 -05:00
Hoan Luu Huu
fc40695828 fix carrier registration status (#354) 2023-11-23 22:01:37 -05:00
Hoan Luu Huu
42af4f6243 fix elevenlabs set voice (#353) 2023-11-21 08:27:47 -06:00
Hoan Luu Huu
7ec8065977 fix lcr create update generate too much request (#351)
* fix lcr create update generate too much request

* wip
2023-11-14 08:07:29 -05:00
Dave Horton
d8f05da6fd fix regression bug: not sending aws_region (#348) 2023-11-09 16:02:42 -05:00
Dave Horton
15c2b955ca 0.8.5 2023-11-09 12:37:59 -05:00
Hoan Luu Huu
87b3ca7e94 Support whisper TTS (#346)
* support tts whisper

* support tts whisper

* wip

* wip

* fix wrong language and voice
2023-11-09 09:50:38 -05:00
Hoan Luu Huu
adafff7ec3 disable update client username/password (#345)
* disable update client username/password

* fix application stt vendor for elevenlabs
2023-11-08 12:13:08 -05:00
Dave Horton
bc9a2464fd Fine tune speech latency (#344)
* divide speech into 10ms segments when evaluating peaks for speech latency

* minor

* minor

* minor change for clarity
2023-11-07 14:40:44 -05:00
Dave Horton
2a6f8c272c assemblyai is stt only (#343) 2023-11-06 12:34:26 -05:00
Dave Horton
f031c47228 changes to enable/disable direct calling from chrome extension (#342)
* changes to enable/disable direct calling from chrome extension

* wip

* wip

* wip

---------

Co-authored-by: Quan HL <quan.luuhoang8@gmail.com>
2023-11-06 09:34:33 -05:00
Hoan Luu Huu
2e9b86c0c4 feat calculate speech recognition latency (#341)
* feat calculate speech recognition latency

* fix review comments

* wip

* wip

* wip

* wip

* wip
2023-11-06 09:31:24 -05:00
Hoan Luu Huu
dd93bedd0e Feat/assemblyai (#340)
* feat assembly ai

* feat assembly ai
2023-11-01 08:03:24 -04:00
Hoan Luu Huu
e2157ce50e feat google custom voice (#338)
* feat google custom voice

* google custom voice

* wip

* wip

* wip
2023-10-30 20:28:34 -04:00
Dave Horton
a382f21f86 #335 - allow top level fqdn for outbound gateway (#336) 2023-10-21 11:22:18 +02:00
Hoan Luu Huu
a20e1513bc remove warning message if there is no device call application (#334) 2023-10-20 13:18:01 +02:00
Hoan Luu Huu
af8c09587c fix wrong css for filter on each component (#333) 2023-10-20 08:16:00 +02:00
Hoan Luu Huu
3a19ff6840 upgrade wavesuffer to 7.3.4 (#329)
* upgrade wavesuffer to 7.3.4

* fix typo issue for wavesurfer

* fix

* fix
2023-10-18 19:54:59 +02:00
Hoan Luu Huu
729cefb06c fix css for recent calls in small screen (#331) 2023-10-16 12:51:49 +02:00
Hoan Luu Huu
26e3856603 add elevenlabs (#330)
* add elevenlabs

* wip

* wip

* fix review comments

* fetch voice and language for eleven labs

* fix revciew comment

* fix revciew comment

* fix revciew comment

* fixed review comment
2023-10-16 10:21:19 +02:00
Hoan Luu Huu
f5302583b5 fix cobalt default language (#326) 2023-09-27 07:50:11 -04:00
Hoan Luu Huu
b5c27bb096 add cobalt stt (#324)
* add cobalt stt

* update languages for cobalt

* update languages for cobalt
2023-09-26 08:42:34 -04:00
Hoan Luu Huu
4a2c36ebba allow sip port is null (#323)
* allow sip port is null

* fix port placeholder when protocol is tls or tls/srtp
2023-09-18 20:15:53 -04:00
Hoan Luu Huu
62234f9f64 pad crypto sip gateway (#322) 2023-09-18 07:59:07 -04:00
Anton Voylenko
9ddafee2cc feat: support s3 compatible storage (#318)
* feat: support s3 compatible storage

* reorder vendor list
2023-09-12 12:28:03 -04:00
Hoan Luu Huu
24fc9d1bff feat: custom Microsoft need to have input text for tts voice (#316)
* feat: custom Microsoft need to have input text for tts voice

* fix
2023-09-08 08:15:44 -04:00
Hoan Luu Huu
08ab494cef feat azure fromhost (#302)
* feat azure fromhost

* wip

* wip

* wip

* wip

* fix review comment

* fix review comment

* wip

* wip
2023-08-30 21:07:03 -04:00
Hoan Luu Huu
75e7785061 fix choose speech dedential by label (#315) 2023-08-30 09:22:39 -04:00
Hoan Luu Huu
72de9178a2 support delete record file (#313)
* support delete record file

* fix
2023-08-23 19:58:59 -04:00
Hoan Luu Huu
9741e5601f feat fallback speech vendor (#310)
* feat fallback speech vendor

* fix

* fix

* fix

* wip

* wip
2023-08-22 08:08:22 -04:00
Hoan Luu Huu
346ac66440 support azure storage (#312) 2023-08-22 07:50:38 -04:00
Hoan Luu Huu
843d1eda1e feat support multiple speech credential with different labels but same vendor (#305)
* feat support multiple speech credential with different labels but same vendor

* fix

* fix review comment

* fix review comment

* fix label tooltip

* fixed
2023-08-15 09:00:00 -04:00
Hoan Luu Huu
27f02c2bb3 update preview description (#301) 2023-08-04 09:51:01 -04:00
Hoan Luu Huu
bb18556a6c allow only card type in stripe PaymentElement (#299) 2023-08-04 06:57:10 -04:00
Hoan Luu Huu
393dd7374f fix clients make user confuse (#298)
* fix clients make user confuse

* fix
2023-08-03 21:13:04 -04:00
Hoan Luu Huu
4ad2154337 fix choose/edit sub domain (#295)
* fix choose/edit sub domain

* add carrier instructions to send call to sip-realm
2023-08-02 07:42:33 -04:00
Hoan Luu Huu
08d1293e34 feat: add login with google and github (#278)
* feat: add login with google and github
* feat: hosted version
* add register pages
* feat: add verify email code page
* register by email
* fix login
* fix logout
* add all stripe pages
* subscription delete account
* fix edit account and edit sip realm
* when user account login, remove cancel in edit page
* remove name
* update .env configuraiton vars
2023-07-30 22:33:58 -04:00
Hoan Luu Huu
4eb2281b9a add polyglot for google (#288)
* add polyglot for google

* add news voice
2023-07-29 08:00:44 -04:00
Hoan Luu Huu
61bd1f9bab feat: google storage for record all call (#292)
* feat: google storage for record all call

* fix

* wip

* wip

* wip

* wip

* fix

* fix

* fix

* fix

* fix
2023-07-28 12:03:54 -04:00
Hoan Luu Huu
16629ba508 fix clients (#289) 2023-07-25 09:00:46 -04:00
Hoan Luu Huu
63f8a82443 only download jaeger trace when open detail for recent call (#287) 2023-07-17 19:14:55 -04:00
Hoan Luu Huu
9ce1d83c8f fix: update name for carrier register authentication fields (#283) 2023-07-03 15:25:22 +01:00
Hoan Luu Huu
961b7ecccb recent call filter (#282)
* recent call filter

* fix review comment

* fix eslint
2023-07-03 15:06:09 +01:00
Dave Horton
3fb63c82ac update version 2023-06-28 09:28:46 +01:00
Hoan Luu Huu
cb2d5926b2 Fix/alerts (#276)
* improve alert view

* improve alert view
2023-06-18 11:05:26 +01:00
Hoan Luu Huu
595e900468 fix showing client password (#274)
* fix showing client password

* fix client password validation
2023-06-15 20:53:13 -04:00
Hoan Luu Huu
0dd9548600 feat: clients (#272)
* feat: clients

* fix typo

* fix reivew comments

* add error message if account miss realm or device calling app

* fix: remove Showed By
2023-06-15 07:37:10 -04:00
Hoan Luu Huu
96ffce8cd1 feat: provision record all call (#254) 2023-06-13 09:03:44 -04:00
Hoan Luu Huu
b1fe033c12 feat/ update google tts voice for neutral and studio (#271) 2023-06-13 07:42:19 -04:00
Hoan Luu Huu
a8d12546d9 filter by from and to (#269) 2023-06-09 13:43:13 -04:00
EgleH
724d86821d hint passwordSettings to user when creating password (#267)
Co-authored-by: eglehelms <e.helms@cognigy.com>
2023-06-08 07:24:29 -04:00
Hoan Luu Huu
f91bbe9245 clean tts cache for account (#264) 2023-06-02 07:39:48 -04:00
Hoan Luu Huu
91625612d5 feat: add seconds to recent call sumary (#261) 2023-06-01 08:23:00 -04:00
Hoan Luu Huu
fbe71925b4 feat: admin clear cache (#263)
* feat: admin clear cache

* feat: admin clear cache
2023-06-01 07:49:26 -04:00
Dave Horton
377fd40e2c fix docker publish 2023-05-27 10:52:04 -04:00
Hoan Luu Huu
af37066201 fix: download pcap (#258) 2023-05-24 07:56:15 -04:00
Dave Horton
fffd86619d disabling lcr, jaeger, and custom speech for k8s (#256)
* disabling lcr, jaeger, and custom speech for k8s

* fix syntax error in entrypoint.sh
2023-05-22 15:03:43 -04:00
Dave Horton
77c270e078 fix docker build 2023-05-15 14:24:20 -04:00
Dave Horton
54ff53817f fix error when register status call-id is empty (#253) 2023-05-12 10:55:00 -04:00
148 changed files with 15882 additions and 19931 deletions

18
.env
View File

@@ -9,4 +9,20 @@ VITE_DEV_BASE_URL=http://127.0.0.1:3000/v1
## disables Least cost routing feature
#VITE_APP_LCR_DISABLED=true
## disables Jaeger Tracing feature
#VITE_APP_JAEGER_TRACING_DISABLED=true
#VITE_APP_JAEGER_TRACING_DISABLED=true
## enable record All Calls feature
#VITE_APP_DISABLE_CALL_RECORDING=true
## enable Forgot password
#VITE_APP_ENABLE_FORGOT_PASSWORD=true
## enable hosted system
#VITE_APP_ENABLE_HOSTED_SYSTEM=true
## Google Client ID
#VITE_APP_GOOGLE_CLIENT_ID=
## Github Client ID
#VITE_APP_GITHUB_CLIENT_ID=
## Default jambonz service provider SID
#VITE_APP_DEFAULT_SERVICE_PROVIDER_SID=
## Base url for jambomz webapp
#VITE_APP_BASE_URL="http://jambonz.one"
## Strip publishable key
#VITE_APP_STRIPE_PUBLISHABLE_KEY="pk_test_EChRaX9Tjk8csZZVSeoGqNvu00lsJzjaU1"

View File

@@ -17,7 +17,7 @@ jobs:
- name: prepare tag
id: prepare_tag
run: |
IMAGE_ID=$GITHUB_REPOSITORY
IMAGE_ID=jambonz/webapp
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')

View File

@@ -12,18 +12,18 @@ jobs:
pr-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Cache node_modules
id: node-cache
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: node_modules
key: node-modules-${{ hashFiles('package-lock.json') }}
- name: Cache cypress binary
id: cypress-cache
uses: actions/cache@v2
uses: actions/cache@v3
with:
path: /home/runner/.cache/Cypress
key: cypress-${{ hashFiles('package-lock.json') }}

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<meta charset="utf-8" />

View File

@@ -44,7 +44,7 @@ declare global {
*/
mountTestProvider(
component: React.ReactNode,
options?: MountOptions & { authProps?: TestProviderProps["authProps"] }
options?: MountOptions & { authProps?: TestProviderProps["authProps"] },
): Cypress.Chainable<MountReturn>;
}
}

View File

@@ -100,7 +100,7 @@ export const postAccount = (payload: Partial<Account>) => {
export const putAccount = (sid: string, payload: Partial<Account>) => {
return putFetch<EmptyResponse, Partial<Account>>(
`${API_ACCOUNTS}/${sid}`,
payload
payload,
);
};
```

View File

@@ -9,9 +9,16 @@ API_PORT="${API_PORT:-3000}"
API_VERSION="${API_VERSION:-v1}"
API_BASE_URL=${API_BASE_URL:-http://$PUBLIC_IPV4:$API_PORT/$API_VERSION}
# Default to "false" if not set
DISABLE_LCR=${DISABLE_LCR:-false}
DISABLE_JAEGER_TRACING=${DISABLE_JAEGER_TRACING:-false}
DISABLE_CUSTOM_SPEECH=${DISABLE_CUSTOM_SPEECH:-false}
ENABLE_FORGOT_PASSWORD=${ENABLE_FORGOT_PASSWORD:-false}
DISABLE_CALL_RECORDING=${DISABLE_CALL_RECORDING:-false}
# Serialize window global to provide the API URL to static frontend dist
# This is declared and utilized in the web app: src/api/constants.ts
SCRIPT_TAG="<script>window.JAMBONZ = { API_BASE_URL: \"${API_BASE_URL}\" };</script>"
SCRIPT_TAG="<script>window.JAMBONZ = {API_BASE_URL: \"${API_BASE_URL}\",DISABLE_LCR: \"${DISABLE_LCR}\",DISABLE_JAEGER_TRACING: \"${DISABLE_JAEGER_TRACING}\",DISABLE_CUSTOM_SPEECH: \"${DISABLE_CUSTOM_SPEECH}\",ENABLE_FORGOT_PASSWORD: \"${ENABLE_FORGOT_PASSWORD}\",DISABLE_CALL_RECORDING: \"${DISABLE_CALL_RECORDING}\"};</script>"
sed -i -e "\@</head>@i\ $SCRIPT_TAG" ./dist/index.html
# Start the frontend web app static server

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
@@ -6,7 +6,7 @@
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Simple provisioning webapp for jambonz."
content="Build innovative voice and collaboration services with jambonz, the open-source communication platform for conversational AI providers and CSPs."
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<link rel="icon" href="/favicon.ico" sizes="any" />
@@ -46,7 +46,7 @@
as="font"
type="font/woff"
/>
<title>Jambonz Web App</title>
<title>Jambonz Portal | Jambonz CPaaS</title>
</head>
<body>
<div id="root"></div>

10813
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "jambonz-webapp",
"description": "A simple provisioning web app for jambonz",
"version": "0.8.3",
"version": "0.9.0",
"license": "MIT",
"type": "module",
"engines": {
@@ -41,43 +41,46 @@
"deploy": "npm i && npm run build && npm run pm2"
},
"dependencies": {
"dayjs": "^1.11.5",
"@jambonz/ui-kit": "^0.0.21",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.3.0",
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.2.0",
"dayjs": "^1.11.10",
"immutability-helper": "^3.1.1",
"react": "^18.2.0",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"immutability-helper": "^3.1.1"
"react-dom": "^18.2.0",
"react-feather": "^2.0.10",
"react-router-dom": "^6.22.3",
"wavesurfer.js": "^7.7.9"
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/node": "^18.6.1",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@types/uuid": "^9.0.1",
"@typescript-eslint/eslint-plugin": "^5.30.6",
"@typescript-eslint/parser": "^5.30.6",
"@vitejs/plugin-react": "^1.3.0",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.12.5",
"@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.5.0",
"@typescript-eslint/parser": "^7.5.0",
"@vitejs/plugin-react": "^4.2.1",
"cors": "^2.8.5",
"cypress": "^10.8.0",
"cypress": "^13.7.2",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jsx-a11y": "^6.6.0",
"eslint-plugin-react": "^7.30.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"express": "^4.18.1",
"husky": "^8.0.1",
"lint-staged": "^13.0.3",
"nanoid": "^4.0.0",
"prettier": "^2.7.1",
"sass": "^1.53.0",
"serve": "^14.0.1",
"ts-node": "^10.9.1",
"typescript": "^4.6.3",
"vite": "^3.0.0"
"express": "^4.19.2",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"nanoid": "^5.0.7",
"prettier": "^3.2.5",
"sass": "^1.74.1",
"serve": "^14.2.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.4",
"vite": "^5.2.8"
},
"lint-staged": {
"*.{ts,tsx}": "eslint --max-warnings=0",

View File

@@ -31,9 +31,10 @@ app.get(
for (let i = 0; i < 500; i++) {
const attempted_at = new Date(start.getTime() + i * increment);
const failed = 0 === i % 5;
const call_sid = nanoid();
const call: RecentCall = {
account_sid: req.params.account_sid,
call_sid: nanoid(),
call_sid,
from: "15083084809",
to: "18882349999",
answered: !failed,
@@ -49,6 +50,7 @@ app.get(
direction: 0 === i % 2 ? "inbound" : "outbound",
trunk: 0 === i % 2 ? "twilio" : "user",
trace_id: nanoid(),
recording_url: `http://127.0.0.1:3002/api/Accounts/${req.params.account_sid}/RecentCalls/${call_sid}/record`,
};
data.push(call);
}
@@ -109,14 +111,14 @@ app.get(
page: query.page,
data: paged,
});
}
},
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/:call_sid",
(req: Request, res: Response) => {
res.status(200).json({ total: Math.random() > 0.5 ? 1 : 0 });
}
},
);
app.get(
@@ -124,7 +126,7 @@ app.get(
(req: Request, res: Response) => {
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
const pcap: Buffer = fs.readFileSync(
path.resolve(process.cwd(), "server", "sample-sip-rtp-traffic.pcap")
path.resolve(process.cwd(), "server", "sample-sip-rtp-traffic.pcap"),
);
res
@@ -134,7 +136,25 @@ app.get(
"Content-Disposition": "attachment",
})
.send(pcap); // server: Buffer => client: Blob
}
},
);
app.get(
"/api/Accounts/:account_sid/RecentCalls/:call_sid/record",
(req: Request, res: Response) => {
/** Sample pcap file from: https://wiki.wireshark.org/SampleCaptures#sip-and-rtp */
const wav: Buffer = fs.readFileSync(
path.resolve(process.cwd(), "server", "example.mp3"),
);
res
.status(200)
.set({
"Content-Type": "audio/wav",
"Content-Disposition": "attachment",
})
.send(wav); // server: Buffer => client: Blob
},
);
app.get(
@@ -142,10 +162,10 @@ app.get(
(req: Request, res: Response) => {
const json = fs.readFileSync(
path.resolve(process.cwd(), "server", "sample-jaeger.json"),
{ encoding: "utf8" }
{ encoding: "utf8" },
);
res.status(200).json(JSON.parse(json));
}
},
);
/** Alerts mock API responses for local dev */

BIN
server/example.mp3 Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,11 @@
import type {
Currency,
ElevenLabsOptions,
LimitField,
LimitUnitOption,
PasswordSettings,
PlayHTOptions,
RimelabsOptions,
SelectorOptions,
SipGateway,
SmppGateway,
@@ -13,6 +17,17 @@ import type {
/** The API url is constructed with the docker containers `ip:port` */
interface JambonzWindowObject {
API_BASE_URL: string;
DISABLE_LCR: string;
DISABLE_JAEGER_TRACING: string;
DISABLE_CUSTOM_SPEECH: string;
ENABLE_FORGOT_PASSWORD: string;
ENABLE_HOSTED_SYSTEM: string;
DISABLE_CALL_RECORDING: string;
GITHUB_CLIENT_ID: string;
GOOGLE_CLIENT_ID: string;
BASE_URL: string;
DEFAULT_SERVICE_PROVIDER_SID: string;
STRIPE_PUBLISHABLE_KEY: string;
}
declare global {
@@ -29,24 +44,50 @@ export const API_BASE_URL =
export const DEV_BASE_URL = import.meta.env.VITE_DEV_BASE_URL;
/** Disable custom speech vendor*/
export const DISABLE_CUSTOM_SPEECH: boolean = JSON.parse(
import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false"
);
export const DISABLE_CUSTOM_SPEECH: boolean =
window.JAMBONZ?.DISABLE_CUSTOM_SPEECH === "true" ||
JSON.parse(import.meta.env.VITE_DISABLE_CUSTOM_SPEECH || "false");
/** Enable Forgot Password */
export const ENABLE_FORGOT_PASSWORD: boolean = JSON.parse(
import.meta.env.VITE_ENABLE_FORGOT_PASSWORD || "false"
);
export const ENABLE_FORGOT_PASSWORD: boolean =
window.JAMBONZ?.ENABLE_FORGOT_PASSWORD === "true" ||
JSON.parse(import.meta.env.VITE_APP_ENABLE_FORGOT_PASSWORD || "false");
/** Enable Cloud version */
export const ENABLE_HOSTED_SYSTEM: boolean =
window.JAMBONZ?.ENABLE_HOSTED_SYSTEM === "true" ||
JSON.parse(import.meta.env.VITE_APP_ENABLE_HOSTED_SYSTEM || "false");
/** Disable Lcr */
export const DISABLE_LCR: boolean = JSON.parse(
import.meta.env.VITE_APP_LCR_DISABLED || "false"
);
export const DISABLE_LCR: boolean =
window.JAMBONZ?.DISABLE_LCR === "true" ||
JSON.parse(import.meta.env.VITE_APP_LCR_DISABLED || "false");
/** Disable jaeger tracing */
export const DISABLE_JAEGER_TRACING: boolean = JSON.parse(
import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false"
);
export const DISABLE_JAEGER_TRACING: boolean =
window.JAMBONZ?.DISABLE_JAEGER_TRACING === "true" ||
JSON.parse(import.meta.env.VITE_APP_JAEGER_TRACING_DISABLED || "false");
/** Enable Record All Call Feature */
export const DISABLE_CALL_RECORDING: boolean =
window.JAMBONZ?.DISABLE_CALL_RECORDING === "true" ||
JSON.parse(import.meta.env.VITE_APP_DISABLE_CALL_RECORDING || "false");
export const DEFAULT_SERVICE_PROVIDER_SID: string =
window.JAMBONZ?.DEFAULT_SERVICE_PROVIDER_SID ||
import.meta.env.VITE_APP_DEFAULT_SERVICE_PROVIDER_SID;
export const GITHUB_CLIENT_ID: string =
window.JAMBONZ?.GITHUB_CLIENT_ID || import.meta.env.VITE_APP_GITHUB_CLIENT_ID;
export const BASE_URL: string =
window.JAMBONZ?.BASE_URL || import.meta.env.VITE_APP_BASE_URL;
export const GOOGLE_CLIENT_ID: string =
window.JAMBONZ?.GOOGLE_CLIENT_ID || import.meta.env.VITE_APP_GOOGLE_CLIENT_ID;
export const STRIPE_PUBLISHABLE_KEY: string =
window.JAMBONZ?.STRIPE_PUBLISHABLE_KEY ||
import.meta.env.VITE_APP_STRIPE_PUBLISHABLE_KEY;
/** TCP Max Port */
export const TCP_MAX_PORT = 65535;
@@ -119,7 +160,94 @@ export const SIP_GATEWAY_PROTOCOL_OPTIONS = [
value: "tls/srtp",
},
];
/**
* Record bucket type
*/
export const BUCKET_VENDOR_AWS = "aws_s3";
export const BUCKET_VENDOR_S3_COMPATIBLE = "s3_compatible";
export const BUCKET_VENDOR_GOOGLE = "google";
export const BUCKET_VENDOR_AZURE = "azure";
export const BUCKET_VENDOR_OPTIONS = [
{
name: "NONE",
value: "",
},
{
name: "AWS S3",
value: BUCKET_VENDOR_AWS,
},
{
name: "AWS S3 Compatible",
value: BUCKET_VENDOR_S3_COMPATIBLE,
},
{
name: "Azure Cloud Storage",
value: BUCKET_VENDOR_AZURE,
},
{
name: "Google Cloud Storage",
value: BUCKET_VENDOR_GOOGLE,
},
];
export const AUDIO_FORMAT_OPTIONS = [
{
name: "mp3",
value: "mp3",
},
{
name: "wav",
value: "wav",
},
];
export const DEFAULT_ELEVENLABS_MODEL = "eleven_multilingual_v2";
export const DEFAULT_WHISPER_MODEL = "tts-1";
// VERBIO
export const VERBIO_STT_MODELS = [
{ name: "V1", value: "V1" },
{ name: "V2", value: "V2" },
];
export const DEFAULT_VERBIO_MODEL = "V1";
// Google Custom Voice reported usage options
export const DEFAULT_GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = "REALTIME";
export const GOOGLE_CUSTOM_VOICES_REPORTED_USAGE = [
{ name: "REPORTED_USAGE_UNSPECIFIED", value: "REPORTED_USAGE_UNSPECIFIED" },
{ name: "REALTIME", value: "REALTIME" },
{ name: "OFFLINE", value: "OFFLINE" },
];
// ElevenLabs options
export const DEFAULT_ELEVENLABS_OPTIONS: Partial<ElevenLabsOptions> = {
optimize_streaming_latency: 3,
voice_settings: {
stability: 0.5,
similarity_boost: 0.5,
use_speaker_boost: true,
},
};
// Rimelabs options
export const DEFAULT_RIMELABS_OPTIONS: Partial<RimelabsOptions> = {
speedAlpha: 1.0,
reduceLatency: true,
};
// PlayHT options
export const DEFAULT_PLAYHT_OPTIONS: Partial<PlayHTOptions> = {
quality: "medium",
speed: 1,
seed: 1,
temperature: 1,
emotion: "female_happy",
voice_guidance: 3,
style_guidance: 20,
text_guidance: 1,
};
/** Password Length options */
export const PASSWORD_MIN = 8;
@@ -211,6 +339,16 @@ export const DEFAULT_PSWD_SETTINGS: PasswordSettings = {
require_special_character: 0,
};
export const PlanType = {
PAID: "paid",
TRIAL: "trial",
FREE: "free",
};
export const CurrencySymbol: Currency = {
usd: "$",
};
/** User scope values values */
export const USER_ADMIN = "admin";
export const USER_SP = "service_provider";
@@ -225,6 +363,9 @@ export const CRED_NOT_TESTED = "not tested";
export const CARRIER_REG_OK = "ok";
export const CARRIER_REG_FAIL = "fail";
export const PRIVACY_POLICY = "https://jambonz.org/privacy";
export const TERMS_OF_SERVICE = "https://jambonz.org/terms";
/** API base paths */
export const API_LOGIN = `${API_BASE_URL}/login`;
export const API_LOGOUT = `${API_BASE_URL}/logout`;
@@ -245,3 +386,13 @@ export const API_SYSTEM_INFORMATION = `${API_BASE_URL}/SystemInformation`;
export const API_LCRS = `${API_BASE_URL}/Lcrs`;
export const API_LCR_ROUTES = `${API_BASE_URL}/LcrRoutes`;
export const API_LCR_CARRIER_SET_ENTRIES = `${API_BASE_URL}/LcrCarrierSetEntries`;
export const API_TTS_CACHE = `${API_BASE_URL}/TtsCache`;
export const API_CLIENTS = `${API_BASE_URL}/Clients`;
export const API_REGISTER = `${API_BASE_URL}/register`;
export const API_ACTIVATION_CODE = `${API_BASE_URL}/ActivationCode`;
export const API_AVAILABILITY = `${API_BASE_URL}/Availability`;
export const API_PRICE = `${API_BASE_URL}/Prices`;
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`;

View File

@@ -24,6 +24,16 @@ import {
API_LCR_ROUTES,
API_LCR_CARRIER_SET_ENTRIES,
API_LCRS,
API_TTS_CACHE,
API_CLIENTS,
API_REGISTER,
API_ACTIVATION_CODE,
API_AVAILABILITY,
API_PRICE,
API_SUBSCRIPTIONS,
API_CHANGE_PASSWORD,
API_SIGNIN,
API_GOOGLE_CUSTOM_VOICES,
} from "./constants";
import { ROUTE_LOGIN } from "src/router/routes";
import {
@@ -69,19 +79,35 @@ import type {
Lcr,
LcrRoute,
LcrCarrierSetEntry,
BucketCredential,
BucketCredentialTestResult,
Client,
RegisterRequest,
RegisterResponse,
ActivationCode,
CurrentUserData,
PriceInfo,
Subscription,
DeleteAccount,
ChangePassword,
SignIn,
GoogleCustomVoice,
GoogleCustomVoicesQuery,
SpeechSupportedLanguagesAndVoices,
} from "./types";
import { StatusCodes } from "./types";
import { Availability, StatusCodes } from "./types";
import { JaegerRoot } from "./jaeger-types";
/** Wrap all requests to normalize response handling */
const fetchTransport = <Type>(
url: string,
options: RequestInit
options: RequestInit,
): Promise<FetchTransport<Type>> => {
return new Promise(async (resolve, reject) => {
try {
const response = await fetch(url, options);
const transport: FetchTransport<Type> = {
headers: response.headers,
status: response.status,
json: <Type>{},
};
@@ -157,13 +183,13 @@ const getAuthHeaders = () => {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(token && { Authorization: `Bearer ${token}` }),
};
};
const getQuery = <Type>(query: Type) => {
return decodeURIComponent(
new URLSearchParams(query as unknown as Record<string, string>).toString()
new URLSearchParams(query as unknown as Record<string, string>).toString(),
);
};
@@ -209,7 +235,7 @@ export const getFetch = <Type>(url: string) => {
export const postFetch = <Type, Payload = undefined>(
url: string,
payload?: Payload
payload?: Payload,
) => {
return fetchTransport<Type>(url, {
method: "POST",
@@ -233,6 +259,17 @@ export const deleteFetch = <Type>(url: string) => {
});
};
export const deleteFetchWithPayload = <Type, Payload>(
url: string,
payload: Payload,
) => {
return fetchTransport<Type>(url, {
method: "DELETE",
headers: getAuthHeaders(),
body: JSON.stringify(payload),
});
};
/** All APIs need a wrapper utility that uses the FetchTransport */
export const postLogin = (payload: UserLoginPayload) => {
@@ -254,7 +291,7 @@ export const postLogout = () => {
export const postServiceProviders = (payload: Partial<ServiceProvider>) => {
return postFetch<SidResponse, Partial<ServiceProvider>>(
API_SERVICE_PROVIDERS,
payload
payload,
);
};
@@ -266,16 +303,26 @@ export const postAccount = (payload: Partial<Account>) => {
return postFetch<SidResponse, Partial<Account>>(API_ACCOUNTS, payload);
};
export const postAccountBucketCredentialTest = (
sid: string,
payload: Partial<BucketCredential>,
) => {
return postFetch<BucketCredentialTestResult, Partial<BucketCredential>>(
`${API_ACCOUNTS}/${sid}/BucketCredentialTest`,
payload,
);
};
export const postApplication = (payload: Partial<Application>) => {
return postFetch<SidResponse, Partial<Application>>(
API_APPLICATIONS,
payload
payload,
);
};
export const postSpeechService = (
sid: string,
payload: Partial<SpeechCredential>
payload: Partial<SpeechCredential>,
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -289,14 +336,14 @@ export const postSpeechService = (
export const postMsTeamsTentant = (payload: Partial<MSTeamsTenant>) => {
return postFetch<SidResponse, Partial<MSTeamsTenant>>(
API_MS_TEAMS_TENANTS,
payload
payload,
);
};
export const postPhoneNumber = (payload: Partial<PhoneNumber>) => {
return postFetch<SidResponse, Partial<PhoneNumber>>(
API_PHONE_NUMBERS,
payload
payload,
);
};
@@ -312,19 +359,19 @@ export const postCarrier = (sid: string, payload: Partial<Carrier>) => {
export const postPredefinedCarrierTemplate = (
currentServiceProviderSid: string,
predefinedCarrierSid: string
predefinedCarrierSid: string,
) => {
return postFetch<SidResponse>(
`${API_BASE_URL}/ServiceProviders/${currentServiceProviderSid}/PredefinedCarriers/${predefinedCarrierSid}`
`${API_BASE_URL}/ServiceProviders/${currentServiceProviderSid}/PredefinedCarriers/${predefinedCarrierSid}`,
);
};
export const postPredefinedCarrierTemplateAccount = (
accountSid: string,
predefinedCarrierSid: string
predefinedCarrierSid: string,
) => {
return postFetch<SidResponse>(
`${API_BASE_URL}/Accounts/${accountSid}/PredefinedCarriers/${predefinedCarrierSid}`
`${API_BASE_URL}/Accounts/${accountSid}/PredefinedCarriers/${predefinedCarrierSid}`,
);
};
@@ -335,45 +382,45 @@ export const postSipGateway = (payload: Partial<SipGateway>) => {
export const postSmppGateway = (payload: Partial<SmppGateway>) => {
return postFetch<SidResponse, Partial<SmppGateway>>(
API_SMPP_GATEWAY,
payload
payload,
);
};
export const postServiceProviderLimit = (
sid: string,
payload: Partial<Limit>
payload: Partial<Limit>,
) => {
return postFetch<SidResponse, Partial<Limit>>(
`${API_SERVICE_PROVIDERS}/${sid}/Limits`,
payload
payload,
);
};
export const postAccountLimit = (sid: string, payload: Partial<Limit>) => {
return postFetch<SidResponse, Partial<Limit>>(
`${API_ACCOUNTS}/${sid}/Limits`,
payload
payload,
);
};
export const postPasswordSettings = (payload: Partial<PasswordSettings>) => {
return postFetch<EmptyResponse, Partial<PasswordSettings>>(
API_PASSWORD_SETTINGS,
payload
payload,
);
};
export const postForgotPassword = (payload: Partial<ForgotPassword>) => {
return postFetch<EmptyResponse, Partial<ForgotPassword>>(
API_FORGOT_PASSWORD,
payload
payload,
);
};
export const postSystemInformation = (payload: Partial<SystemInformation>) => {
return postFetch<SystemInformation, Partial<SystemInformation>>(
API_SYSTEM_INFORMATION,
payload
payload,
);
};
@@ -381,16 +428,68 @@ export const postLcr = (payload: Partial<Lcr>) => {
return postFetch<SidResponse, Partial<Lcr>>(API_LCRS, payload);
};
export const postLcrCreateRoutes = (
sid: string,
payload: Partial<LcrRoute[]>,
) => {
return postFetch<EmptyResponse, Partial<LcrRoute[]>>(
`${API_LCRS}/${sid}/Routes`,
payload,
);
};
export const postLcrRoute = (payload: Partial<LcrRoute>) => {
return postFetch<SidResponse, Partial<LcrRoute>>(API_LCR_ROUTES, payload);
};
export const postLcrCarrierSetEntry = (
payload: Partial<LcrCarrierSetEntry>
payload: Partial<LcrCarrierSetEntry>,
) => {
return postFetch<SidResponse, Partial<LcrCarrierSetEntry>>(
API_LCR_CARRIER_SET_ENTRIES,
payload
payload,
);
};
export const postClient = (payload: Partial<Client>) => {
return postFetch<SidResponse, Partial<Client>>(API_CLIENTS, payload);
};
export const postRegister = (payload: Partial<RegisterRequest>) => {
return postFetch<RegisterResponse, Partial<RegisterRequest>>(
API_REGISTER,
payload,
);
};
export const postSipRealms = (accountSid: string, domain: string) => {
return postFetch<EmptyResponse>(
`${API_ACCOUNTS}/${accountSid}/SipRealms/${domain}`,
);
};
export const postSubscriptions = (payload: Partial<Subscription>) => {
return postFetch<Subscription, Partial<Subscription>>(
API_SUBSCRIPTIONS,
payload,
);
};
export const postChangepassword = (payload: Partial<ChangePassword>) => {
return postFetch<EmptyResponse, Partial<ChangePassword>>(
API_CHANGE_PASSWORD,
payload,
);
};
export const postSignIn = (payload: Partial<SignIn>) => {
return postFetch<SignIn, Partial<SignIn>>(API_SIGNIN, payload);
};
export const postGoogleCustomVoice = (payload: Partial<GoogleCustomVoice>) => {
return postFetch<SidResponse, Partial<GoogleCustomVoice>>(
API_GOOGLE_CUSTOM_VOICES,
payload,
);
};
/** Named wrappers for `putFetch` */
@@ -398,38 +497,38 @@ export const postLcrCarrierSetEntry = (
export const putUser = (sid: string, payload: Partial<UserUpdatePayload>) => {
return putFetch<EmptyResponse, Partial<UserUpdatePayload>>(
`${API_USERS}/${sid}`,
payload
payload,
);
};
export const putServiceProvider = (
sid: string,
payload: Partial<ServiceProvider>
payload: Partial<ServiceProvider>,
) => {
return putFetch<EmptyResponse, Partial<ServiceProvider>>(
`${API_SERVICE_PROVIDERS}/${sid}`,
payload
payload,
);
};
export const putAccount = (sid: string, payload: Partial<Account>) => {
return putFetch<EmptyResponse, Partial<Account>>(
`${API_ACCOUNTS}/${sid}`,
payload
payload,
);
};
export const putApplication = (sid: string, payload: Partial<Application>) => {
return putFetch<EmptyResponse, Partial<Application>>(
`${API_APPLICATIONS}/${sid}`,
payload
payload,
);
};
export const putSpeechService = (
sid1: string,
sid2: string,
payload: Partial<SpeechCredential>
payload: Partial<SpeechCredential>,
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -442,25 +541,25 @@ export const putSpeechService = (
export const putMsTeamsTenant = (
sid: string,
payload: Partial<MSTeamsTenant>
payload: Partial<MSTeamsTenant>,
) => {
return putFetch<EmptyResponse, Partial<MSTeamsTenant>>(
`${API_MS_TEAMS_TENANTS}/${sid}`,
payload
payload,
);
};
export const putPhoneNumber = (sid: string, payload: Partial<PhoneNumber>) => {
return putFetch<EmptyResponse, Partial<PhoneNumber>>(
`${API_PHONE_NUMBERS}/${sid}`,
payload
payload,
);
};
export const putCarrier = (
sid1: string,
sid2: string,
payload: Partial<Carrier>
payload: Partial<Carrier>,
) => {
const userData = parseJwt(getToken());
const apiUrl =
@@ -474,14 +573,14 @@ export const putCarrier = (
export const putSipGateway = (sid: string, payload: Partial<SipGateway>) => {
return putFetch<EmptyResponse, Partial<SipGateway>>(
`${API_SIP_GATEWAY}/${sid}`,
payload
payload,
);
};
export const putSmppGateway = (sid: string, payload: Partial<SmppGateway>) => {
return putFetch<EmptyResponse, Partial<SmppGateway>>(
`${API_SMPP_GATEWAY}/${sid}`,
payload
payload,
);
};
@@ -489,20 +588,57 @@ export const putLcr = (sid: string, payload: Partial<Lcr>) => {
return putFetch<EmptyResponse, Partial<Lcr>>(`${API_LCRS}/${sid}`, payload);
};
export const putLcrUpdateRoutes = (
sid: string,
payload: Partial<LcrRoute[]>,
) => {
return putFetch<EmptyResponse, Partial<LcrRoute[]>>(
`${API_LCRS}/${sid}/Routes`,
payload,
);
};
export const putLcrRoutes = (sid: string, payload: Partial<LcrRoute>) => {
return putFetch<EmptyResponse, Partial<LcrRoute>>(
`${API_LCR_ROUTES}/${sid}`,
payload
payload,
);
};
export const putLcrCarrierSetEntries = (
sid: string,
payload: Partial<LcrCarrierSetEntry>
payload: Partial<LcrCarrierSetEntry>,
) => {
return putFetch<EmptyResponse, Partial<LcrCarrierSetEntry>>(
`${API_LCR_CARRIER_SET_ENTRIES}/${sid}`,
payload
payload,
);
};
export const putClient = (sid: string, payload: Partial<Client>) => {
return putFetch<EmptyResponse, Partial<Client>>(
`${API_CLIENTS}/${sid}`,
payload,
);
};
export const putActivationCode = (
code: string,
payload: Partial<ActivationCode>,
) => {
return putFetch<EmptyResponse, Partial<ActivationCode>>(
`${API_ACTIVATION_CODE}/${code}`,
payload,
);
};
export const putGoogleCustomVoice = (
sid: string,
payload: Partial<GoogleCustomVoice>,
) => {
return putFetch<EmptyResponse, Partial<GoogleCustomVoice>>(
`${API_GOOGLE_CUSTOM_VOICES}/${sid}`,
payload,
);
};
@@ -520,8 +656,11 @@ export const deleteApiKey = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_API_KEYS}/${sid}`);
};
export const deleteAccount = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_ACCOUNTS}/${sid}`);
export const deleteAccount = (sid: string, payload: Partial<DeleteAccount>) => {
return deleteFetchWithPayload<EmptyResponse, Partial<DeleteAccount>>(
`${API_ACCOUNTS}/${sid}`,
payload,
);
};
export const deleteApplication = (sid: string) => {
@@ -530,7 +669,7 @@ export const deleteApplication = (sid: string) => {
export const deleteSpeechService = (sid1: string, sid2: string) => {
return deleteFetch<EmptyResponse>(
`${API_SERVICE_PROVIDERS}/${sid1}/SpeechCredentials/${sid2}`
`${API_SERVICE_PROVIDERS}/${sid1}/SpeechCredentials/${sid2}`,
);
};
@@ -556,16 +695,16 @@ export const deleteSmppGateway = (sid: string) => {
export const deleteServiceProviderLimit = (
sid: string,
cat: LimitCategories
cat: LimitCategories,
) => {
return deleteFetch<EmptyResponse>(
`${API_SERVICE_PROVIDERS}/${sid}/Limits?category=${cat}`
`${API_SERVICE_PROVIDERS}/${sid}/Limits?category=${cat}`,
);
};
export const deleteAccountLimit = (sid: string, cat: LimitCategories) => {
return deleteFetch<EmptyResponse>(
`${API_ACCOUNTS}/${sid}/Limits?category=${cat}`
`${API_ACCOUNTS}/${sid}/Limits?category=${cat}`,
);
};
@@ -577,6 +716,25 @@ export const deleteLcrRoute = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_LCR_ROUTES}/${sid}`);
};
export const deleteTtsCache = () => {
return deleteFetch<EmptyResponse>(API_TTS_CACHE);
};
export const deleteAccountTtsCache = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_BASE_URL}/Accounts/${sid}/TtsCache`);
};
export const deleteClient = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_CLIENTS}/${sid}`);
};
export const deleteRecord = (url: string) => {
return deleteFetch<EmptyResponse>(url);
};
export const deleteGoogleCustomVoice = (sid: string) => {
return deleteFetch<EmptyResponse>(`${API_GOOGLE_CUSTOM_VOICES}/${sid}`);
};
/** Named wrappers for `getFetch` */
export const getUser = (sid: string) => {
@@ -589,7 +747,7 @@ export const getServiceProviders = () => {
export const getAccountWebhook = (sid: string) => {
return getFetch<SecretResponse>(
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`
`${API_ACCOUNTS}/${sid}/WebhookSecret?regenerate=true`,
);
};
@@ -611,19 +769,44 @@ export const getLcrRoute = (sid: string) => {
export const getLcrCarrierSetEtries = (sid: string) => {
return getFetch<LcrCarrierSetEntry[]>(
`${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}`
`${API_LCR_CARRIER_SET_ENTRIES}?lcr_route_sid=${sid}`,
);
};
export const getClients = () => {
return getFetch<Client[]>(API_CLIENTS);
};
export const getClient = (sid: string) => {
return getFetch<Client[]>(`${API_CLIENTS}/${sid}`);
};
export const getAvailability = (domain: string) => {
return getFetch<Availability>(
`${API_AVAILABILITY}?type=subdomain&value=${domain}`,
);
};
export const getGoogleCustomVoices = (
query: Partial<GoogleCustomVoicesQuery>,
) => {
const qryStr = getQuery<Partial<GoogleCustomVoicesQuery>>(query);
return getFetch<GoogleCustomVoice[]>(`${API_GOOGLE_CUSTOM_VOICES}?${qryStr}`);
};
/** Wrappers for APIs that can have a mock dev server response */
export const getMe = () => {
return getFetch<CurrentUserData>(`${API_USERS}/me`);
};
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}`,
);
};
@@ -631,15 +814,15 @@ 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 getPcap = (sid: string, sipCallId: string) => {
export const getPcap = (sid: string, sipCallId: string, method: string) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/pcap`
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_ACCOUNTS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
);
};
@@ -647,26 +830,30 @@ export const getJaegerTrace = (sid: string, traceId: string) => {
return getFetch<JaegerRoot>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/RecentCalls/trace/${traceId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/trace/${traceId}`
: `${API_ACCOUNTS}/${sid}/RecentCalls/trace/${traceId}`,
);
};
export const getServiceProviderRecentCall = (
sid: string,
sipCallId: string
sipCallId: string,
) => {
return getFetch<TotalResponse>(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}`,
);
};
export const getServiceProviderPcap = (sid: string, sipCallId: string) => {
export const getServiceProviderPcap = (
sid: string,
sipCallId: string,
method: string,
) => {
return getBlob(
import.meta.env.DEV
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/pcap`
? `${DEV_BASE_URL}/ServiceProviders/${sid}/RecentCalls/${sipCallId}/${method}/pcap`
: `${API_SERVICE_PROVIDERS}/${sid}/RecentCalls/${sipCallId}/${method}/pcap`,
);
};
@@ -676,10 +863,30 @@ export const getAlerts = (sid: string, query: Partial<PageQuery>) => {
return getFetch<PagedResponse<Alert>>(
import.meta.env.DEV
? `${DEV_BASE_URL}/Accounts/${sid}/Alerts?${qryStr}`
: `${API_ACCOUNTS}/${sid}/Alerts?${qryStr}`
: `${API_ACCOUNTS}/${sid}/Alerts?${qryStr}`,
);
};
export const getPrice = () => {
return getFetch<PriceInfo[]>(API_PRICE);
};
export const getSpeechSupportedLanguagesAndVoices = (
sid: string | undefined,
vendor: string,
label: string,
) => {
const userData = parseJwt(getToken());
const apiUrl =
(userData.scope === USER_ACCOUNT
? `${API_ACCOUNTS}/${userData.account_sid}`
: `${API_SERVICE_PROVIDERS}/${sid}`) +
`/SpeechCredentials/speech/supportedLanguagesAndVoices?vendor=${vendor}${
label ? `&label=${label}` : ""
}`;
return getFetch<SpeechSupportedLanguagesAndVoices>(apiUrl);
};
/** Hooks for components to fetch data with refetch method */
/** :GET /{apiPath} -- this is generic for any fetch of data collections */
@@ -739,7 +946,7 @@ export const useServiceProviderData: UseApiData = <Type>(apiPath: string) => {
if (currentServiceProvider) {
getFetch<Type>(
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/${apiPath}`
`${API_SERVICE_PROVIDERS}/${currentServiceProvider.service_provider_sid}/${apiPath}`,
)
.then(({ json }) => {
if (!ignore) {

View File

@@ -37,6 +37,30 @@ export interface JaegerAttribute {
value: JaegerValue;
}
export interface WaveSurferSttResult {
vendor: string;
transcript: string;
confidence: number;
language_code: string;
latency?: number;
}
export interface WaveSurferTtsLatencyResult {
vendor: string;
latency: string;
isCached: string;
}
export interface WaveSurferGatherSpeechVerbHookLatencyResult {
statusCode: number;
latency: string;
}
export interface WaveSurferDtmfResult {
dtmf: string;
duration: string;
}
export interface JaegerValue {
stringValue: string;
doubleValue: string;

View File

@@ -1,4 +1,4 @@
import type { Vendor } from "src/vendor/types";
import type { Language, Model, Vendor, VoiceLanguage } from "src/vendor/types";
/** Simple types */
@@ -51,6 +51,7 @@ export enum StatusCodes {
/** Fetch transport interfaces */
export interface FetchTransport<Type> {
headers: Headers;
status: StatusCodes;
json: Type;
blob?: Blob;
@@ -62,11 +63,9 @@ export interface FetchError {
}
export interface UseApiData {
<Type>(apiPath: string): [
Type | undefined,
() => void,
FetchError | undefined
];
<Type>(
apiPath: string,
): [Type | undefined, () => void, FetchError | undefined];
}
/** API related interfaces */
@@ -87,7 +86,7 @@ export interface SelectorOptions {
value: string;
}
export interface Pcap {
export interface DownloadedBlob {
data_url: string;
file_name: string;
}
@@ -102,6 +101,11 @@ export interface CredentialTestResult {
tts: CredentialTest;
}
export interface BucketCredentialTestResult {
status: CredentialStatus;
reason: string;
}
export interface LimitField {
label: string;
category: LimitCategories;
@@ -123,6 +127,10 @@ export interface SystemInformation {
monitoring_domain_name: string;
}
export interface TtsCache {
size: number;
}
/** API responses/payloads */
export interface User {
@@ -138,6 +146,7 @@ export interface User {
service_provider_name?: string | null;
initial_password?: string;
permissions?: UserPermissions[];
provider?: null | string;
}
export interface UserLogin {
@@ -176,6 +185,8 @@ export interface UserJWT {
export interface CurrentUserData {
user: User;
account?: Account;
subscription?: null | Subscription;
}
export interface ServiceProvider {
@@ -235,6 +246,7 @@ export interface Smpp {
export interface Account {
name: string;
sip_realm: null | string;
root_domain?: null | string;
account_sid: string;
webhook_secret: string;
siprec_hook_sid: null | string;
@@ -242,7 +254,58 @@ export interface Account {
registration_hook: null | WebHook;
service_provider_sid: string;
device_calling_application_sid: null | string;
lcr_sid: null | string;
record_all_calls: number;
record_format?: null | string;
bucket_credential: null | BucketCredential;
plan_type?: string;
device_to_call_ratio?: number;
trial_end_date?: null | string;
is_active: boolean;
}
export interface Product {
price_id?: null | string;
product_sid?: null | string;
name?: string;
quantity?: number;
}
export interface Subscription {
action?: null | string;
payment_method_id?: null | string;
account_subscription_sid?: null | string;
stripe_customer_id?: null | string;
products?: null | Product[];
start_date?: string;
status?: string;
client_secret?: null | string;
last4?: null | string;
exp_month?: null | string;
exp_year?: null | string;
card_type?: null | string;
reason?: null | string;
dry_run?: boolean;
currency?: null | string;
prorated_cost?: number;
monthly_cost?: number;
next_invoice_date?: null | string;
}
export interface AwsTag {
Key: string;
Value: string;
}
export interface BucketCredential {
vendor: null | string;
region?: null | string;
name?: null | string;
access_key_id?: null | string;
secret_access_key?: null | string;
tags?: null | AwsTag[];
service_key?: null | string;
connection_string?: null | string;
endpoint?: null | string;
}
export interface Application {
@@ -256,8 +319,19 @@ export interface Application {
speech_synthesis_voice: null | string;
speech_synthesis_vendor: null | Lowercase<Vendor>;
speech_synthesis_language: null | string;
speech_synthesis_label: null | string;
speech_recognizer_vendor: null | Lowercase<Vendor>;
speech_recognizer_language: null | string;
speech_recognizer_label: null | string;
record_all_calls: number;
use_for_fallback_speech: number;
fallback_speech_synthesis_vendor: null | string;
fallback_speech_synthesis_language: null | string;
fallback_speech_synthesis_voice: null | string;
fallback_speech_synthesis_label: null | string;
fallback_speech_recognizer_vendor: null | string;
fallback_speech_recognizer_language: null | string;
fallback_speech_recognizer_label: null | string;
}
export interface PhoneNumber {
@@ -294,6 +368,15 @@ export interface RecentCall {
direction: string;
trunk: string;
trace_id: string;
recording_url?: string;
}
export interface GoogleCustomVoice {
google_custom_voice_sid?: string;
speech_credential_sid?: string;
name: string;
reported_usage: string;
model: string;
}
export interface SpeechCredential {
@@ -307,14 +390,19 @@ export interface SpeechCredential {
region: null | string;
aws_region: null | string;
api_key: null | string;
role_arn: null | string;
user_id: null | string;
access_key_id: null | string;
secret_access_key: null | string;
service_key: null | string;
use_custom_tts: number;
custom_tts_endpoint_url: null | string;
custom_tts_endpoint: null | string;
use_custom_stt: number;
custom_stt_endpoint_url: null | string;
custom_stt_endpoint: null | string;
client_id: null | string;
client_secret: null | string;
secret: null | string;
nuance_tts_uri: null | string;
nuance_stt_uri: null | string;
@@ -327,6 +415,15 @@ export interface SpeechCredential {
auth_token: null | string;
custom_stt_url: null | string;
custom_tts_url: null | string;
label: null | string;
cobalt_server_uri: null | string;
model_id: null | string;
voice_engine: null | string;
engine_version: null | string;
model: null | string;
options: null | string;
deepgram_stt_uri: null | string;
deepgram_stt_use_tls: number;
}
export interface Alert {
@@ -380,7 +477,6 @@ export interface PredefinedCarrier extends Carrier {
export interface Gateway {
voip_carrier_sid: string;
ipv4: string;
port: number;
netmask: number;
inbound: number;
outbound: number;
@@ -390,12 +486,16 @@ export interface SipGateway extends Gateway {
sip_gateway_sid?: null | string;
is_active: boolean;
protocol?: string;
port: number | null;
pad_crypto?: boolean;
send_options_ping?: boolean;
}
export interface SmppGateway extends Gateway {
smpp_gateway_sid?: null | string;
is_primary: boolean;
use_tls: boolean;
port: number;
}
export interface Lcr {
@@ -412,7 +512,7 @@ export interface LcrRoute {
lcr_route_sid?: null | string;
lcr_sid: null | string;
regex: null | string;
desciption?: null | string;
description?: null | string;
priority: number;
lcr_carrier_set_entries?: LcrCarrierSetEntry[];
}
@@ -425,6 +525,17 @@ export interface LcrCarrierSetEntry {
priority: number;
}
export interface Client {
client_sid?: null | string;
account_sid: null | string;
username: null | string;
password?: null | string;
is_active: boolean;
allow_direct_app_calling: boolean;
allow_direct_queue_calling: boolean;
allow_direct_user_calling: boolean;
}
export interface PageQuery {
page: number;
count: number;
@@ -437,6 +548,13 @@ export interface CallQuery extends PageQuery {
answered?: string;
}
export interface GoogleCustomVoicesQuery {
speech_credential_sid?: string;
label?: string;
account_sid?: string;
service_provider_sid: string;
}
export interface PagedResponse<Type> {
page_size: number;
total: number;
@@ -467,3 +585,158 @@ export interface EmptyResponse {
export interface TotalResponse {
total: number;
}
export interface RegisterRequest {
service_provider_sid: string;
provider: string;
oauth2_code?: string;
oauth2_state?: string;
oauth2_client_id?: string;
oauth2_redirect_uri?: string;
locationBeforeAuth?: string;
name?: string;
email?: string;
password?: string;
email_activation_code?: string;
inviteCode?: string;
}
export interface RegisterResponse {
jwt: string;
user_sid: string;
account_sid: string;
root_domain: string;
}
export interface ActivationCode {
user_sid: string;
type: string;
}
export interface Availability {
available: boolean;
}
export interface Invoice {
total: number;
currency: null | string;
next_payment_attempt: null | string;
}
export type Currency = {
[key: string]: null | string;
};
export interface Recurring {
aggregate_usage: null | string;
interval: null | string;
interval_count: number;
trial_period_days: null | string;
usage_type: string;
}
export interface Price {
billing_scheme: string;
currency: string;
recurring: Recurring;
stripe_price_id: null | string;
tiers_mode: null | string;
type: null | string;
unit_amount: number;
unit_amount_decimal: null | string;
}
export interface PriceInfo {
category: null | string;
description: null | string;
name: null | string;
prices: Price[];
product_sid: null | string;
stripe_product_id: null | string;
unit_label: null | string;
}
export interface StripeCustomerId {
stripe_customer_id: null | string;
}
export interface Tier {
up_to: number;
flat_amount: number;
unit_amount: number;
}
export interface ServiceData {
category: null | string;
name: null | string;
service: null | string;
fees: number;
feesLabel: null | string;
cost: number;
capacity: number;
invalid: boolean;
currency: null | string;
min: number;
max: number;
dirty: boolean;
visible: boolean;
required: boolean;
billing_scheme?: null | string;
stripe_price_id?: null | string;
unit_label?: null | string;
product_sid?: null | string;
stripe_product_id?: null | string;
tiers?: Tier[];
}
export interface DeleteAccount {
password: string;
}
export interface ChangePassword {
old_password: null | string;
new_password: null | string;
}
export interface SignIn {
link?: null | string;
jwt?: null | string;
account_sid?: null | string;
}
export interface GetLanguagesAndVoices {
vendor: string;
label: string;
}
export interface SpeechSupportedLanguagesAndVoices {
tts: VoiceLanguage[];
stt: Language[];
models: Model[];
}
export interface ElevenLabsOptions {
optimize_streaming_latency: number;
voice_settings: Partial<{
similarity_boost: number;
stability: number;
style: number;
use_speaker_boost: boolean;
}>;
}
export interface PlayHTOptions {
quality: string;
speed: number;
seed: number;
temperature: number;
emotion: string;
voice_guidance: number;
style_guidance: number;
text_guidance: number;
}
export interface RimelabsOptions {
speedAlpha: number;
reduceLatency: boolean;
}

View File

@@ -38,7 +38,7 @@ export const AccountFilter = ({
return (
<div className={classNames(classes)}>
<label htmlFor="account_filter">{label}:</label>
{label && <label htmlFor="account_filter">{label}:</label>}
<div>
<select
id="account_filter"

View File

@@ -12,7 +12,7 @@ import applications from "../../cypress/fixtures/applications.json";
/** Wrapper to perform React state setup */
const ApplicationFilterTestWrapper = (
props: Partial<ApplicationFilterProps>
props: Partial<ApplicationFilterProps>,
) => {
const [application, setApplication] = useState("");
@@ -47,7 +47,7 @@ describe("<ApplicationFilter>", () => {
/** Default value is properly set to first option */
cy.get("select").should(
"have.value",
applicationsSorted[0].application_sid
applicationsSorted[0].application_sid,
);
});
@@ -58,7 +58,7 @@ describe("<ApplicationFilter>", () => {
cy.get("select").select(applicationsSorted[1].application_sid);
cy.get("select").should(
"have.value",
applicationsSorted[1].application_sid
applicationsSorted[1].application_sid,
);
});
@@ -75,7 +75,7 @@ describe("<ApplicationFilter>", () => {
it("renders default option", () => {
/** Test with the `defaultOption` prop */
cy.mount(
<ApplicationFilterTestWrapper defaultOption="Choose Application" />
<ApplicationFilterTestWrapper defaultOption="Choose Application" />,
);
/** No default value is set when this prop is present */

View File

@@ -20,7 +20,7 @@ export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
toastSuccess(
<>
<strong>{text}</strong> copied to clipboard
</>
</>,
);
})
.catch(() => {
@@ -28,7 +28,7 @@ export const ClipBoard = ({ text, id = "", name = "" }: ClipBoardProps) => {
<>
Unable to copy <strong>{text}</strong>, please select the text and
right click to copy
</>
</>,
);
});
};

View File

@@ -0,0 +1,48 @@
import React from "react";
import { Icons } from "../icons";
import "./styles.scss";
type DomainInputProbs = {
id?: string;
name?: string;
value: string;
setValue: React.Dispatch<React.SetStateAction<string>>;
root_domain: string;
placeholder?: string;
is_valid: boolean;
};
export const DomainInput = ({
id,
name,
value,
setValue,
root_domain,
is_valid,
placeholder,
}: DomainInputProbs) => {
return (
<>
<div className="clipboard clipboard-domain">
<div className="input-container">
<input
id={id}
name={name}
type="text"
value={value}
placeholder={placeholder}
onChange={(e) => setValue(e.target.value)}
/>
<div className={`input-icon txt--${is_valid ? "teal" : "red"}`}>
{is_valid ? <Icons.CheckCircle /> : <Icons.XCircle />}
</div>
</div>
<div className="root-domain">
<p>{root_domain}</p>
</div>
</div>
</>
);
};
export default DomainInput;

View File

@@ -0,0 +1,55 @@
@use "../../styles/vars";
@use "../../styles/mixins";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
@use "@jambonz/ui-kit/src/styles/mixins" as ui-mixins;
.input-container {
position: relative;
display: inline-block;
width: 100%;
}
.clipboard-domain {
display: flex;
align-items: center;
input[type="text"],
input[type="number"] {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
width: 100%;
height: vars.$clipheight;
&:focus-visible {
outline: 0;
}
}
.internal form & {
max-width: calc(#{vars.$widthinput} - #{vars.$clipheight});
}
.input-icon {
position: absolute;
right: 5%;
top: 50%;
transform: translateY(-50%);
border-left: 0;
}
.root-domain {
height: vars.$clipheight;
border-bottom-right-radius: ui-vars.$px01;
border-top-right-radius: ui-vars.$px01;
border: 2px solid ui-vars.$grey;
border-left: 0;
background-color: ui-vars.$pink;
padding: ui-vars.$px01;
display: flex;
align-items: center;
&[disabled] {
@include mixins.disabled();
}
}
}

View File

@@ -0,0 +1,39 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Icons } from "src/components/icons";
type EditBoardProps = {
id?: string;
name?: string;
text: string;
path: string;
title?: string;
};
export const EditBoard = ({
text,
id = "",
name = "",
path,
title,
}: EditBoardProps) => {
const navigate = useNavigate();
const handleClick = () => {
navigate(path);
};
return (
<div className="clipboard inpbtn">
<input id={id} name={name} type="text" readOnly value={text} />
<button
className="btnty"
type="button"
title={title ? title : "Edit"}
onClick={handleClick}
>
<Icons.Edit />
</button>
</div>
);
};

View File

@@ -28,7 +28,7 @@ export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
defaultOption,
...restProps
}: AccountSelectProps,
ref
ref,
) => {
useEffect(() => {
if (hasLength(accounts) && !accountSid && !defaultOption) {
@@ -56,14 +56,14 @@ export const AccountSelect = forwardRef<SelectorRef, AccountSelectProps>(
name: account.name,
value: account.account_sid,
}))
: []
: [],
)}
onChange={(e) => setAccountSid(e.target.value)}
{...restProps}
/>
</>
);
}
},
);
AccountSelect.displayName = "AccountSelect";

View File

@@ -34,7 +34,7 @@ export const ApplicationSelect = forwardRef<
defaultOption,
...restProps
}: ApplicationSelectProps,
ref
ref,
) => {
useEffect(() => {
if (hasLength(applications) && !applicationSid && !defaultOption) {
@@ -62,14 +62,14 @@ export const ApplicationSelect = forwardRef<
name: application.name,
value: application.application_sid,
}))
: []
: [],
)}
onChange={(e) => setApplicationSid(e.target.value)}
{...restProps}
/>
</>
);
}
},
);
ApplicationSelect.displayName = "ApplicationSelect";

View File

@@ -7,9 +7,11 @@ type CheckzoneProps = {
id?: string;
name: string;
label: string;
labelNode?: React.ReactNode;
hidden?: boolean;
children: React.ReactNode;
initialCheck: boolean;
disabled?: boolean;
handleChecked?: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
@@ -22,12 +24,14 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
id,
name,
label,
labelNode,
hidden = false,
children,
initialCheck,
handleChecked,
disabled = false,
}: CheckzoneProps,
ref
ref,
) => {
const [checked, setChecked] = useState(false);
const classesTop = classNames({
@@ -47,26 +51,30 @@ export const Checkzone = forwardRef<CheckzoneRef, CheckzoneProps>(
return (
<div className={classesTop}>
<label>
<input
ref={ref}
type="checkbox"
name={name}
id={id || name}
onChange={(e) => {
setChecked(e.target.checked);
<div className="label-container">
<input
disabled={disabled}
ref={ref}
type="checkbox"
name={name}
id={id || name}
onChange={(e) => {
setChecked(e.target.checked);
if (handleChecked) {
handleChecked(e);
}
}}
checked={checked}
/>
<div>{label}</div>
if (handleChecked) {
handleChecked(e);
}
}}
checked={checked}
/>
{label && <div>{label}</div>}
{labelNode && labelNode}
</div>
</label>
{checked && <div className={classesIn}>{children}</div>}
</div>
);
}
},
);
Checkzone.displayName = "Checkzone";

View File

@@ -9,11 +9,14 @@
width: 100%;
max-width: vars.$widthinput;
> label {
.label-container {
display: flex;
align-items: center;
justify-content: center;
}
> label {
input {
margin-top: ui-vars.$px01;
margin-right: ui-vars.$px02;
}
}
@@ -35,6 +38,10 @@
margin-top: ui-vars.$px01;
}
> a {
width: 100%;
}
&.active {
cursor: auto;
opacity: 1;

View File

@@ -25,7 +25,7 @@ export const FileUpload = forwardRef<FileRef, FileProps>(
disabled,
...restProps
}: FileProps,
ref
ref,
) => {
const [fileName, setFileName] = useState("");
const [focus, setFocus] = useState(false);
@@ -73,7 +73,7 @@ export const FileUpload = forwardRef<FileRef, FileProps>(
</div>
</div>
);
}
},
);
FileUpload.displayName = "FileUpload";

View File

@@ -52,12 +52,12 @@ export const LocalLimits = ({
? LIMITS.filter((limit) =>
unit === LIMIT_SESS
? !limit.category.includes(LIMIT_MIN)
: limit.category.includes(LIMIT_MIN)
: limit.category.includes(LIMIT_MIN),
)
: LIMITS.filter(
(limit) =>
!limit.category.includes("license") &&
!limit.category.includes(LIMIT_MIN)
!limit.category.includes(LIMIT_MIN),
);
useEffect(() => {
@@ -130,7 +130,7 @@ export const LocalLimits = ({
}
onChange={(e) => {
const limit = localLimits.find(
(l) => l.category === category
(l) => l.category === category,
);
const value = e.target.value ? Number(e.target.value) : "";
@@ -139,8 +139,8 @@ export const LocalLimits = ({
localLimits.map((l) =>
l.category === category
? { ...l, quantity: value }
: l
)
: l,
),
);
} else {
setLocalLimits([

View File

@@ -24,7 +24,7 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
locked = false,
...restProps
}: PasswdProps,
ref
ref,
) => {
const [reveal, setReveal] = useState(false);
@@ -55,7 +55,7 @@ export const Passwd = forwardRef<PasswdRef, PasswdProps>(
)}
</div>
);
}
},
);
Passwd.displayName = "Passwd";

View File

@@ -20,7 +20,7 @@ type SelectorRef = HTMLSelectElement;
export const Selector = forwardRef<SelectorRef, SelectorProps>(
(
{ id, name, value, options, disabled, ...restProps }: SelectorProps,
ref
ref,
) => {
const [focus, setFocus] = useState(false);
const classes = {
@@ -42,7 +42,7 @@ export const Selector = forwardRef<SelectorRef, SelectorProps>(
{...restProps}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
<option key={`${id}_${option.value}`} value={option.value}>
{option.name}
</option>
))}
@@ -53,7 +53,7 @@ export const Selector = forwardRef<SelectorRef, SelectorProps>(
</span>
</div>
);
}
},
);
Selector.displayName = "Selector";

View File

@@ -42,6 +42,15 @@ import {
Share2,
ArrowUp,
ArrowDown,
Play,
Pause,
ChevronsLeft,
ChevronsRight,
Download,
Smartphone,
Youtube,
Mail,
Tag,
} from "react-feather";
import type { Icon } from "react-feather";
@@ -94,4 +103,13 @@ export const Icons: IconMap = {
Share2,
ArrowUp,
ArrowDown,
Play,
Pause,
ChevronsLeft,
ChevronsRight,
Download,
Smartphone,
Youtube,
Mail,
Tag,
};

View File

@@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
import { Button, ButtonGroup } from "@jambonz/ui-kit";
import "./styles.scss";
import { Spinner } from "../spinner";
type ModalProps = {
disabled?: boolean;
@@ -49,7 +50,7 @@ export const Modal = ({
</ButtonGroup>
</div>
</div>,
portal
portal,
);
};
@@ -69,6 +70,7 @@ export const ModalForm = ({
}}
>
<div className="modal__stuff">{children}</div>
<ButtonGroup right>
<Button
small
@@ -85,7 +87,7 @@ export const ModalForm = ({
</ButtonGroup>
</form>
</div>,
portal
portal,
);
};
@@ -111,6 +113,40 @@ export const ModalClose = ({ children, handleClose }: CloseProps) => {
</ButtonGroup>
</div>
</div>,
portal
portal,
);
};
type LoaderProps = {
children: React.ReactNode;
};
export const ModalLoader = ({ children }: LoaderProps) => {
return ReactDOM.createPortal(
<div className="modal" role="presentation">
<div
className="modal__box"
role="presentation"
onClick={(e) => e.stopPropagation()}
>
<div
className="modal__stuff"
role="presentation"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Spinner />
</div>
</div>
</div>,
portal,
);
};

View File

@@ -32,7 +32,7 @@ export const Pagination = ({
(num: number) => {
setPageNumber(Math.max(1, Math.min(maxPageNumber, num)));
},
[maxPageNumber, setPageNumber]
[maxPageNumber, setPageNumber],
);
const handleNumberMapping = useCallback(
@@ -100,7 +100,7 @@ export const Pagination = ({
);
}
},
[maxPageNumber, pageNumber]
[maxPageNumber, pageNumber],
);
return (

View File

@@ -29,7 +29,7 @@ describe("<ScopedAccess>", () => {
cy.mountTestProvider(
<ScopedAccessTestWrapper scope={Scope.admin} user={user}>
<H1>ScopedAccess: admin</H1>
</ScopedAccessTestWrapper>
</ScopedAccessTestWrapper>,
);
cy.get(".scope-div").should("exist");
});
@@ -44,7 +44,7 @@ describe("<ScopedAccess>", () => {
cy.mountTestProvider(
<ScopedAccessTestWrapper scope={Scope.admin} user={user}>
<H1>ScopedAccess: service_provider</H1>
</ScopedAccessTestWrapper>
</ScopedAccessTestWrapper>,
);
cy.get(".scope-div").should("not.exist");
});
@@ -59,7 +59,7 @@ describe("<ScopedAccess>", () => {
cy.mountTestProvider(
<ScopedAccessTestWrapper scope={Scope.admin} user={user}>
<H1>ScopedAccess: account</H1>
</ScopedAccessTestWrapper>
</ScopedAccessTestWrapper>,
);
cy.get(".scope-div").should("not.exist");
});

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useRef } from "react";
import { classNames } from "@jambonz/ui-kit";
import { Icons } from "src/components/icons";
@@ -7,14 +7,18 @@ import "./styles.scss";
type SearchFilterProps = JSX.IntrinsicElements["input"] & {
filter: [string, React.Dispatch<React.SetStateAction<string>>];
delay?: number | null;
};
export const SearchFilter = ({
placeholder,
filter: [filterValue, setFilterValue],
delay,
}: SearchFilterProps) => {
const [focus, setFocus] = useState(false);
const [tmpFilterValue, setTmpFilterValue] = useState(filterValue);
const [appearance, setAppearance] = useState(false);
const typingTimeoutRef = useRef<number | null>(null);
const classes = {
"search-filter": true,
focused: focus,
@@ -23,7 +27,18 @@ export const SearchFilter = ({
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setFilterValue(e.target.value.toLowerCase());
setTmpFilterValue(e.target.value.toLowerCase());
if (delay) {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
typingTimeoutRef.current = setTimeout(() => {
setFilterValue(e.target.value.toLowerCase());
}, delay);
} else {
setFilterValue(e.target.value.toLowerCase());
}
if (e.target.value) {
setAppearance(true);
@@ -31,7 +46,7 @@ export const SearchFilter = ({
setAppearance(false);
}
},
[setFilterValue]
[setFilterValue],
);
const handleActive = useCallback(() => {
@@ -51,7 +66,7 @@ export const SearchFilter = ({
type="search"
name="search_filter"
placeholder={placeholder}
value={filterValue}
value={tmpFilterValue}
onChange={handleChange}
onFocus={() => {
setFocus(true);

View File

@@ -42,7 +42,7 @@ export const SelectFilter = ({
setFilterValue(e.target.value);
const queryFilter = createFilterString(
e.target.value,
label as string
label as string,
);
setQueryFilter(queryFilter);

View File

@@ -23,6 +23,6 @@ export const Toast = ({ type, message }: ToastProps) => {
{message}
</div>
</div>,
portal
portal,
);
};

View File

@@ -9,14 +9,15 @@ import "./styles.scss";
type TooltipProps = {
text: IMessage;
children: React.ReactNode;
subStyle?: string;
};
export const Tooltip = ({ text, children }: TooltipProps) => {
export const Tooltip = ({ text, children, subStyle }: TooltipProps) => {
return (
<div className="tooltip">
<div className="tooltip__reveal">{text}</div>
{children}
<Icons.HelpCircle />
{subStyle === "info" ? <Icons.Info /> : <Icons.HelpCircle />}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React from "react";
export const TOAST_TIME = 3000;
export const TOAST_TIME = 5000;
export const SESS_FLASH_MSG = "SESS_FLASH_MSG";
export const SESS_USER_SID = "SESS_USER_SID";
export const SESS_OLD_PASSWORD = "SESS_OLD_PASSWORD";
@@ -13,16 +13,6 @@ export const MSG_PASSWD_MATCH = "Passwords do not match";
export const MSG_SERVER_DOWN = "The server cannot be reached";
export const MSG_LOGGED_OUT = "You've successfully logged out.";
export const MSG_MUST_LOGIN = "You must log in to view that page";
export const MSG_PASSWD_CRITERIA = (
<>
Password must:
<ul>
<li>Be at least 6 characters</li>
<li>Contain at least one letter</li>
<li>Contain at least one number</li>
</ul>
</>
);
export const MSG_REQUIRED_FIELDS = (
<>
Fields marked with an asterisk<span>*</span> are required.
@@ -34,4 +24,3 @@ export const MSG_WEBHOOK_FIELDS = (
<span>password</span> fields are required.
</>
);
export const NOT_AVAILABLE_PREFIX = "NotAvalable";

View File

@@ -76,7 +76,7 @@ export const Navi = ({
const naviByoFiltered = useMemo(() => {
return naviByo.filter(
(item) => !item.acl || (item.acl && accessControl[item.acl])
(item) => !item.acl || (item.acl && accessControl[item.acl]),
);
}, [accessControl, currentServiceProvider]);
@@ -100,7 +100,7 @@ export const Navi = ({
toastSuccess(
<>
Added new service provider <strong>{name}</strong>
</>
</>,
);
dispatch({ type: "serviceProviders" });
setSid(json.sid);
@@ -123,7 +123,7 @@ export const Navi = ({
setSid(getActiveSP());
if (sid) {
const serviceProvider = serviceProviders.find(
(sp) => sp.service_provider_sid === sid
(sp) => sp.service_provider_sid === sid,
);
if (serviceProvider) {

View File

@@ -10,6 +10,7 @@ import {
ROUTE_INTERNAL_PHONE_NUMBERS,
ROUTE_INTERNAL_MS_TEAMS_TENANTS,
ROUTE_INTERNAL_LEST_COST_ROUTING,
ROUTE_INTERNAL_CLIENTS,
} from "src/router/routes";
import { Icons } from "src/components";
import { Scope, UserData } from "src/store/types";
@@ -17,7 +18,10 @@ import { Scope, UserData } from "src/store/types";
import type { Icon } from "react-feather";
import type { ACL } from "src/store/types";
import { Lcr } from "src/api/types";
import { DISABLE_LCR } from "src/api/constants";
import {
DISABLE_LCR,
ENABLE_HOSTED_SYSTEM as ENABLE_HOSTED_SYSTEM,
} from "src/api/constants";
export interface NaviItem {
label: string;
@@ -29,11 +33,17 @@ export interface NaviItem {
}
export const naviTop: NaviItem[] = [
{
label: "Users",
icon: Icons.UserCheck,
route: () => ROUTE_INTERNAL_USERS,
},
// User is not allowed in hosted app
...(!ENABLE_HOSTED_SYSTEM
? [
{
label: "Users",
icon: Icons.UserCheck,
route: () => ROUTE_INTERNAL_USERS,
},
]
: []),
{
label: "Settings",
icon: Icons.Settings,
@@ -53,6 +63,11 @@ export const naviTop: NaviItem[] = [
scope: Scope.account,
restrict: true,
},
{
label: "Clients",
icon: Icons.Smartphone,
route: () => ROUTE_INTERNAL_CLIENTS,
},
{
label: "Applications",
icon: Icons.Grid,

View File

@@ -1,18 +1,32 @@
import React from "react";
import { Link } from "react-router-dom";
import React, { useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Icons } from "src/components";
import { ROUTE_INTERNAL_USERS } from "src/router/routes";
import {
ROUTE_INTERNAL_USERS,
ROUTE_REGISTER_SUB_DOMAIN,
} from "src/router/routes";
import { useApiData } from "src/api";
import { useSelectState } from "src/store";
import type { CurrentUserData } from "src/api/types";
import "./styles.scss";
import { ENABLE_HOSTED_SYSTEM } from "src/api/constants";
import { setRootDomain } from "src/store/localStore";
export const UserMe = () => {
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const navigate = useNavigate();
useEffect(() => {
// If hosted platform is enabled, the account should have sip realm
if (ENABLE_HOSTED_SYSTEM && userData && !userData.account?.sip_realm) {
setRootDomain(userData?.account?.root_domain || "");
navigate(ROUTE_REGISTER_SUB_DOMAIN);
}
}, [userData]);
return (
<div className="user">

View File

@@ -75,19 +75,19 @@ export const DeleteAccount = ({
getFetch<PhoneNumber[]>(API_PHONE_NUMBERS),
getFetch<MSTeamsTenant[]>(API_MS_TEAMS_TENANTS),
getFetch<ApiKey[]>(
`${API_BASE_URL}/Accounts/${account.account_sid}/ApiKeys`
`${API_BASE_URL}/Accounts/${account.account_sid}/ApiKeys`,
),
]).then(([appsRes, phonesRes, teamsRes, apiKeysRes]) => {
if (!ignore) {
const used = {
apps: appsRes.json.filter(
(app) => app.account_sid === account.account_sid
(app) => app.account_sid === account.account_sid,
),
phones: phonesRes.json.filter(
(phone) => phone.account_sid === account.account_sid
(phone) => phone.account_sid === account.account_sid,
),
teams: teamsRes.json.filter(
(team) => team.account_sid === account.account_sid
(team) => team.account_sid === account.account_sid,
),
apiKeys: apiKeysRes.json,
};

View File

@@ -0,0 +1,100 @@
import { Button, ButtonGroup, H1, MS } from "@jambonz/ui-kit";
import React, { useEffect, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { getAvailability, postSipRealms, useApiData } from "src/api";
import { CurrentUserData } from "src/api/types";
import { Section } from "src/components";
import DomainInput from "src/components/domain-input";
import { Message } from "src/components/forms";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { hasValue } from "src/utils";
export const EditSipRealm = () => {
const [name, setName] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const navigate = useNavigate();
const [userData] = useApiData<CurrentUserData>("Users/me");
const typingTimeoutRef = useRef<number | null>(null);
const [isValidDomain, setIsValidDomain] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const rootDomain = userData?.account?.root_domain;
const account_sid = userData?.account?.account_sid;
postSipRealms(account_sid || "", `${name}.${rootDomain}`)
.then(() => {
navigate(`${ROUTE_INTERNAL_ACCOUNTS}/${account_sid}/edit`);
})
.catch((error) => {
setErrorMessage(error.msg);
});
};
useEffect(() => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
if (!name || name.length < 3) {
setIsValidDomain(false);
return;
}
setIsValidDomain(false);
typingTimeoutRef.current = setTimeout(() => {
getAvailability(`${name}.${userData?.account?.root_domain}`)
.then(({ json }) =>
setIsValidDomain(
Boolean(json.available) && hasValue(name) && name.length != 0,
),
)
.catch((error) => {
setErrorMessage(error.msg);
setIsValidDomain(false);
});
}, 500);
}, [name]);
return (
<>
<H1 className="h2">Edit Sip Realm</H1>
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<fieldset>
<MS>
This is the domain name where your carrier will send calls, and
where you can register devices to.
</MS>
{errorMessage && <Message message={errorMessage} />}
<br />
<DomainInput
id="sip_realm"
name="sip_realm"
value={name}
setValue={setName}
placeholder="Your name here"
root_domain={`.${userData?.account?.root_domain || ""}`}
is_valid={isValidDomain}
/>
</fieldset>
<fieldset>
<ButtonGroup left>
<Button
small
subStyle="grey"
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
>
Cancel
</Button>
<Button type="submit" small disabled={!isValidDomain}>
Change Sip Realm
</Button>
</ButtonGroup>
</fieldset>
</form>
</Section>
</>
);
};
export default EditSipRealm;

View File

@@ -7,7 +7,7 @@ import { useApiData } from "src/api";
import { toastError, useSelectState } from "src/store";
import { AccountForm } from "./form";
import type { Account, Application, Limit } from "src/api/types";
import type { Account, Application, Limit, TtsCache } from "src/api/types";
import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
@@ -19,12 +19,15 @@ export const EditAccount = () => {
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Account>(
`Accounts/${params.account_sid}`
`Accounts/${params.account_sid}`,
);
const [limitsData, refetchLimits] = useApiData<Limit[]>(
`Accounts/${params.account_sid}/Limits`
`Accounts/${params.account_sid}/Limits`,
);
const [apps] = useApiData<Application[]>("Applications");
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>(
`Accounts/${params.account_sid}/TtsCache`,
);
useScopedRedirect(
Scope.account,
@@ -33,7 +36,7 @@ export const EditAccount = () => {
: ROUTE_INTERNAL_APPLICATIONS,
user,
"You do not have access to this resource",
data
data,
);
/** Handle error toast at top level... */
@@ -50,6 +53,7 @@ export const EditAccount = () => {
apps={apps}
account={{ data, refetch, error }}
limits={{ data: limitsData, refetch: refetchLimits }}
ttsCache={{ data: ttsCache, refetch: ttsCacheFetcher }}
/>
<ApiKeys
path={`Accounts/${params.account_sid}/ApiKeys`}

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ export const Accounts = () => {
Scope.service_provider,
`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`,
user,
"You do not have permissions to manage all accounts"
"You do not have permissions to manage all accounts",
);
const handleDelete = () => {
@@ -40,19 +40,19 @@ export const Accounts = () => {
user.account_sid !== account.account_sid
) {
toastError(
"You do not have permissions to make changes to this Account"
"You do not have permissions to make changes to this Account",
);
return;
}
deleteAccount(account.account_sid)
deleteAccount(account.account_sid, {})
.then(() => {
refetch();
setAccount(null);
toastSuccess(
<>
Deleted account <strong>{account.name}</strong>
</>
</>,
);
})
.catch((error) => {
@@ -71,7 +71,7 @@ export const Accounts = () => {
</Icon>
</Link>
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter accounts"
filter={[filter, setFilter]}

View File

@@ -0,0 +1,191 @@
import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
import {
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
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 { PaymentMethod } from "@stripe/stripe-js";
import { ModalLoader } from "src/components/modal";
export const ManagePaymentForm = () => {
const user = useSelectState("user");
const stripe = useStripe();
const elements = useElements();
const [userData] = useApiData<CurrentUserData>("Users/me");
const [isChangePayment, setIsChangePayment] = useState(false);
const [isSavingNewCard, setIsSavingNewCard] = useState(false);
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const navigate = useNavigate();
const createSubscription = async (paymentMethod: PaymentMethod) => {
const body: Subscription = {
action: "update-payment-method",
payment_method_id: paymentMethod.id,
};
postSubscriptions(body)
.then(({ json }) => {
if (json.status === "success") {
toastSuccess("Payment completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
} else if (json.status === "action required") {
if (stripe) {
const location = window.location;
stripe
.confirmPayment({
clientSecret: json.client_secret || "",
confirmParams: {
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
},
})
.then((error) => {
if (error) {
toastError(error.error.message || "");
return;
}
})
.finally(() => {
setIsSavingNewCard(false);
setIsShowModalLoader(false);
});
}
} else if (json.status === "card error") {
setIsSavingNewCard(false);
setIsShowModalLoader(false);
toastError(json.reason || "Something went wrong, please try again.");
}
})
.catch((error) => {
toastError(error.msg || "Something went wrong, please try again.");
})
.finally(() => {
setIsSavingNewCard(false);
setIsShowModalLoader(false);
});
};
const handleSaveNewCard = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
const card = elements.getElement(PaymentElement);
if (!card) {
return;
}
const { error: elementsError } = await elements.submit();
if (elementsError) {
toastError(elementsError.message || "");
return;
}
const { error, paymentMethod } = await stripe.createPaymentMethod({
element: card,
});
if (error) {
toastError(error.message || "Something went wrong, please try again.");
return;
}
setIsSavingNewCard(true);
setIsShowModalLoader(true);
createSubscription(paymentMethod);
};
return (
<>
<H1 className="h2">Manage Payment Information</H1>
{userData?.subscription && (
<Section>
<H1 className="h3">Current Payment Information</H1>
<div className="item__details">
<div className="pre-grid-white">
<div>Card Type:</div>
<div>{userData.subscription.card_type}</div>
<div>Card Number:</div>
<div>
{userData.subscription.last4
? `**** **** **** ${userData.subscription.last4}`
: ""}
</div>
<div>Expiration:</div>
<div>
{userData.subscription.exp_year
? `${userData.subscription.exp_month}/${userData.subscription.exp_year}`
: ""}
</div>
</div>
</div>
<ButtonGroup right>
<Button
type="button"
subStyle="grey"
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`}
small
>
Cancel
</Button>
<Button onClick={() => setIsChangePayment(true)} small>
Change Payment Info
</Button>
</ButtonGroup>
</Section>
)}
{isChangePayment && (
<Section>
<div className="grid--col4--users">
<H1 className="h3">New Payment Information</H1>
<div className="grid__row">
<div></div>
<div>
<PaymentElement
options={{
paymentMethodOrder: ["card"],
}}
/>
</div>
</div>
</div>
<ButtonGroup right>
<Button
type="button"
subStyle="grey"
onClick={() => setIsChangePayment(false)}
small
>
Cancel
</Button>
<Button
type="button"
onClick={handleSaveNewCard}
disabled={!stripe || isSavingNewCard}
small
>
Save New Card
</Button>
</ButtonGroup>
</Section>
)}
{isShowModalLoader && (
<ModalLoader>
<P>
Your requested changes are being processed. Please do not leave the
page or hit the back button until complete.
</P>
</ModalLoader>
)}
</>
);
};
export default ManagePaymentForm;

View File

@@ -0,0 +1,23 @@
import { Elements } from "@stripe/react-stripe-js";
import { stripePromise } from "./subscription";
import ManagePaymentForm from "./manage-payment-form";
import React from "react";
export const ManagePayment = () => {
return (
<>
<Elements
stripe={stripePromise}
options={{
mode: "setup",
currency: "usd",
paymentMethodCreation: "manual",
}}
>
<ManagePaymentForm />
</Elements>
</>
);
};
export default ManagePayment;

View File

@@ -0,0 +1,633 @@
import { Button, ButtonGroup, H1, P } from "@jambonz/ui-kit";
import React, { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { postSubscriptions, useApiData } from "src/api";
import { CurrencySymbol } from "src/api/constants";
import {
CurrentUserData,
PriceInfo,
ServiceData,
StripeCustomerId,
Subscription,
} from "src/api/types";
import { Modal, Section } from "src/components";
import { ROUTE_INTERNAL_ACCOUNTS } from "src/router/routes";
import { hasValue } from "src/utils";
import {
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { PaymentMethod } from "@stripe/stripe-js";
import { toastError, toastSuccess } from "src/store";
import { ModalLoader } from "src/components/modal";
const SubscriptionForm = () => {
const [userData] = useApiData<CurrentUserData>("Users/me");
const [priceInfo] = useApiData<PriceInfo[]>("/Prices");
const [userStripeInfo] = useApiData<StripeCustomerId>("/StripeCustomerId");
const [total, setTotal] = useState(0);
const [cardErrorCase, setCardErrorCase] = useState(false);
const [isReviewChanges, setIsReviewChanges] = useState(false);
const [isReturnToFreePlan, setIsReturnToFreePlan] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const isModifySubscription = location.pathname.includes(
"modify-subscription",
);
const [billingCharge, setBillingCharge] = useState<Subscription | null>(null);
const [isShowModalLoader, setIsShowModalLoader] = useState(false);
const [isDisableSubmitButton, setIsDisableSubmitButton] =
useState(isModifySubscription);
const stripe = useStripe();
const elements = useElements();
const createSubscription = async (paymentMethod: PaymentMethod) => {
let body: Subscription = {};
if (cardErrorCase) {
body = {
action: "update-payment-method",
payment_method_id: paymentMethod.id,
};
} else {
body = {
action: "upgrade-to-paid",
payment_method_id: paymentMethod.id,
stripe_customer_id: userStripeInfo?.stripe_customer_id,
products: serviceData.map((service) => ({
price_id: service.stripe_price_id,
product_sid: service.product_sid,
quantity: service.capacity || 0,
})),
};
}
postSubscriptions(body)
.then(({ json }) => {
if (json.status === "success") {
toastSuccess("Payment completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
} else if (json.status === "action required") {
if (stripe) {
const location = window.location;
stripe
.confirmPayment({
clientSecret: json.client_secret || "",
confirmParams: {
return_url: `${location.protocol}//${location.host}${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
},
})
.then((error) => {
if (error) {
toastError(error.error.message || "");
return;
}
})
.finally(() => {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
});
}
} else if (json.status === "card error") {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
setCardErrorCase(true);
}
})
.catch((error) => {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
toastError(error.msg || "Something went wrong, please try again.");
});
};
const retrieveBillingChanges = async () => {
const updatedProducts = serviceData.map((product) => ({
price_id: product.stripe_price_id,
product_sid: product.product_sid,
quantity: product.capacity || 0,
}));
postSubscriptions({
action: "update-quantities",
dry_run: true,
products: updatedProducts,
})
.then(({ json }) => {
setBillingCharge(json);
setIsReviewChanges(true);
})
.catch((error) => {
toastError(error.msg || "Something went wrong, please try again.");
setIsDisableSubmitButton(false);
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) {
return;
}
setIsDisableSubmitButton(true);
if (isModifySubscription) {
retrieveBillingChanges();
return;
}
setIsShowModalLoader(true);
const { error: elementsError } = await elements.submit();
if (elementsError) {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
toastError(elementsError.message || "");
return;
}
const card = elements.getElement(PaymentElement);
if (!card) {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
return;
}
const { error, paymentMethod } = await stripe.createPaymentMethod({
element: card,
});
if (error) {
setIsDisableSubmitButton(false);
setIsShowModalLoader(false);
toastError(error.message || "");
return;
}
createSubscription(paymentMethod);
};
const handleReturnToFreePlan = () => {
setIsReturnToFreePlan(false);
setIsShowModalLoader(true);
const body: Subscription = {
action: "downgrade-to-free",
};
postSubscriptions(body)
.then(() => {
toastSuccess("Downgrade to free plan completed successfully");
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
})
.catch((error) => {
toastError(error.msg);
})
.finally(() => setIsShowModalLoader(false));
};
const handleReviewChangeSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsShowModalLoader(true);
const updatedProducts = serviceData.map((product) => ({
price_id: product.stripe_price_id,
product_sid: product.product_sid,
quantity: product.capacity,
}));
postSubscriptions({
action: "update-quantities",
products: updatedProducts,
})
.then(() => {
toastSuccess(
"Your subscription capacity has been successfully modified.",
);
navigate(
`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`,
);
})
.catch(() => {
toastError(
`The additional capacity you that you requested could not be granted due to a failure processing payment.
Please configure a valid credit card for your account and the upgrade will be automatically processed`,
);
})
.finally(() => {
setIsShowModalLoader(false);
setIsDisableSubmitButton(false);
});
};
// subscription categories
const [serviceData, setServiceData] = useState<ServiceData[]>([
{
category: "voice_call_session",
name: "concurrent call session",
service: "Maximum concurrent call sessions",
fees: 0,
feesLabel: "",
cost: 0,
capacity: 0,
invalid: false,
currency: "usd",
min: 5,
max: 1000,
dirty: false,
visible: true,
required: true,
},
{
category: "device",
name: "registered device",
service: "Additional device registrations",
fees: 0,
feesLabel: "",
cost: 0,
capacity: 0,
invalid: false,
currency: "usd",
min: 0,
max: 200,
dirty: false,
visible: false,
required: false,
},
]);
const [originalServiceData, setOriginalServiceData] = useState<ServiceData[]>(
[],
);
const initFeesAndCost = (priceData: PriceInfo[]) => {
serviceData.forEach((service) => {
const record = priceData.find(
(item) => item.category === service.category,
);
if (record) {
const price = record.prices.find(
(item) => item.currency === service.currency,
);
if (price) {
let fees = 0;
switch (price.billing_scheme) {
case "per_unit":
fees = (price.unit_amount * 1) / 100;
break;
default:
break;
}
service.billing_scheme = price.billing_scheme;
service.stripe_price_id = price.stripe_price_id;
service.unit_label = record.unit_label;
service.product_sid = record.product_sid;
service.stripe_product_id = record.stripe_product_id;
service.fees = fees;
service.feesLabel = `${
CurrencySymbol[service.currency || "usd"]
}${fees} per ${
record.unit_label?.slice(0, 3) === "per"
? record.unit_label.slice(3)
: record.unit_label
}`;
}
}
});
setServiceData([...serviceData]);
};
const getServicePrice = (
service: ServiceData,
capacity: number,
): [number, string, number] => {
let fees = 0;
let feesLabel = "";
let cost = 0;
const capacityNum = capacity;
if (service.billing_scheme === "per_unit") {
fees = service.fees;
cost = fees * capacityNum;
} else if (service.billing_scheme === "tiered") {
const filteredTiers = service.tiers
? service.tiers.filter(
(item) => !item.up_to || item.up_to >= capacityNum,
)
: [];
if (filteredTiers.length) {
const tier = filteredTiers[0];
if (typeof tier.flat_amount === "number") {
fees = tier.flat_amount / 100;
cost = fees;
} else {
fees = tier.unit_amount / 100;
cost = fees * capacityNum;
}
}
}
feesLabel = `${CurrencySymbol[service.currency || "usd"]}${fees} per ${
service.unit_label && service.unit_label.slice(0, 3) === "per"
? service.unit_label.slice(3)
: service.unit_label
}`;
return [fees, feesLabel, cost];
};
const setProductsInfo = (data: CurrentUserData) => {
const { products } = data.subscription || {};
const services = serviceData.map((service) => {
const { quantity } = products
? products.find((item) => item.name === service.name) || {}
: { quantity: null };
const [fees, feesLabel, cost] = getServicePrice(service, quantity || 0);
return {
...service,
capacity: quantity || 0,
invalid: false,
fees,
feesLabel,
cost,
visible: hasValue(quantity) && quantity > 0,
};
});
setServiceData(services);
setOriginalServiceData([...services]);
};
const updateServiceData = (
index: number,
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,
),
);
};
useEffect(() => {
if (priceInfo) {
initFeesAndCost(priceInfo);
}
if (userData && priceInfo) {
setProductsInfo(userData);
}
}, [priceInfo, userData]);
useEffect(() => {
if (isModifySubscription && originalServiceData.length > 0) {
setIsDisableSubmitButton(
serviceData[0].capacity === originalServiceData[0].capacity &&
serviceData[1].capacity === originalServiceData[1].capacity,
);
}
setTotal(serviceData.reduce((res, service) => res + service.cost || 0, 0));
}, [serviceData]);
return (
<>
<H1 className="h2">
{isModifySubscription
? "Configure Your Subscription"
: "Upgrade your Subscription"}
</H1>
{isShowModalLoader && (
<ModalLoader>
<P>
Your requested changes are being processed. Please do not leave the
page or hit the back button until complete.
</P>
</ModalLoader>
)}
{isReviewChanges && !isShowModalLoader && (
<Modal
handleCancel={() => {
setIsReviewChanges(false);
setIsDisableSubmitButton(false);
}}
handleSubmit={handleReviewChangeSubmit}
>
<H1 className="h4">Confirm Changes</H1>
<P>
By pressing{" "}
<span>
<strong>Confirm</strong>
</span>{" "}
below, your plan will be immediately adjusted to the following
levels:
</P>
<ul className="m">
<li>{`- ${serviceData[0].capacity} simultaneous calls`}</li>
{userData?.account && userData?.account.device_to_call_ratio && (
<li>{`- ${
userData?.account.device_to_call_ratio *
(serviceData[0].capacity + serviceData[1].capacity)
} registered devices`}</li>
)}
</ul>
<P>
{(billingCharge?.prorated_cost || 0) > 0 &&
`Your new monthly charge will be $${
(billingCharge?.monthly_cost || 0) / 100
}, and you will immediately be charged a one-time prorated amount of $${
(billingCharge?.prorated_cost || 0) / 100
} to cover the remainder of the current billing period.`}
{billingCharge?.prorated_cost === 0 &&
`Your monthly charge will be $${
(billingCharge.monthly_cost || 0) / 100
}.`}
{(billingCharge?.prorated_cost || 0) < 0 &&
`Your new monthly charge will be $${
(billingCharge?.monthly_cost || 0) / 100
}, and you will receive a credit of $${
-(billingCharge?.prorated_cost || 0) / 100
} on your next invoice to reflect changes made during the current billing period.`}
</P>
</Modal>
)}
{isReturnToFreePlan && !isShowModalLoader && (
<Modal
handleCancel={() => setIsReturnToFreePlan(false)}
handleSubmit={handleReturnToFreePlan}
>
<H1 className="h4">Return to Free Plan</H1>
<P>
Returning to the free plan will reduce your capacity to a maximum of
1 simultaneous call session and 1 registered device. Your current
plan and capacity will continue through the rest of the billing
cycle and your plan change will take effect at the beginning of the
next billing cycle. Are you sure you want to continue?
</P>
</Modal>
)}
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<div className="grid grid--col4--users">
<div className="grid__row grid__th">
<div>Service</div>
<div>Capacity</div>
<div>Price</div>
<div>Cost</div>
</div>
{serviceData &&
serviceData
.filter((service) => service.visible)
.map((service, idx) => (
<React.Fragment key={`subscription-${idx}`}>
<div className="grid__row">
<div>
<label htmlFor={service.name || ""}>
{service.service}
<span>*</span>
</label>
</div>
<div>
<input
id="tech_prefix"
name="tech_prefix"
type="number"
value={service.capacity}
required
min={service.min}
max={service.max}
onChange={(e) => {
updateServiceData(
idx,
"capacity",
e.target.value ? Number(e.target.value) : "",
);
}}
/>
</div>
<div>
<em>{service.feesLabel}</em>
</div>
<div>
<P>
<strong>
{CurrencySymbol[service.currency || "usd"]}
{service.cost}
</strong>
</P>
</div>
</div>
</React.Fragment>
))}
{serviceData[0].capacity !== 0 && !serviceData[1].visible && (
<>
<div className="grid__row">
<label htmlFor="max_concurrent_call_sessons">
{`With ${
serviceData[0].capacity
} call sessions you can register ${
serviceData[0].capacity *
(userData?.account?.device_to_call_ratio || 0)
} concurrent devices`}
</label>
<div>
<Button
mainStyle="hollow"
onClick={() =>
setServiceData((prev) => {
prev[1].visible = true;
return [...prev];
})
}
>
Would you like to purchase additional device
registrations?
</Button>
</div>
</div>
</>
)}
<div className="grid__row">
<div>
<label htmlFor="total">Total Monthly Cost</label>
</div>
<div></div>
<div></div>
<div>
<P>
<strong>
{CurrencySymbol[serviceData[0].currency || "usd"]}
{total}
</strong>
</P>
</div>
</div>
{!isModifySubscription && (
<fieldset>
<label htmlFor="total">Payment Information</label>
<div className="grid__row">
<div></div>
<div>
<PaymentElement
options={{
paymentMethodOrder: ["card"],
}}
/>
</div>
</div>
</fieldset>
)}
</div>
<fieldset>
<>
<div className={isModifySubscription ? "mast" : ""}>
{isModifySubscription && (
<ButtonGroup right>
<Button
type="button"
subStyle="grey"
mainStyle="hollow"
onClick={() => setIsReturnToFreePlan(true)}
small
>
Return to free plan
</Button>
</ButtonGroup>
)}
<ButtonGroup right>
<Button
subStyle="grey"
as={Link}
to={`${ROUTE_INTERNAL_ACCOUNTS}/${userData?.account?.account_sid}/edit`}
small
>
Cancel
</Button>
<Button type="submit" disabled={isDisableSubmitButton} small>
{isModifySubscription
? "Review Changes"
: `Pay ${CurrencySymbol[serviceData[0].currency || "usd"]}
${total} and Upgrade to Paid Plan`}
</Button>
</ButtonGroup>
</div>
</>
</fieldset>
</form>
</Section>
</>
);
};
export default SubscriptionForm;

View File

@@ -0,0 +1,32 @@
import React from "react";
import {
ENABLE_HOSTED_SYSTEM,
STRIPE_PUBLISHABLE_KEY,
} from "src/api/constants";
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import SubscriptionForm from "./subscription-form";
export const stripePromise = ENABLE_HOSTED_SYSTEM
? loadStripe(STRIPE_PUBLISHABLE_KEY)
: null;
export const Subscription = () => {
return (
<>
<Elements
stripe={stripePromise}
options={{
mode: "setup",
currency: "usd",
paymentMethodCreation: "manual",
}}
>
<SubscriptionForm />
</Elements>
</>
);
};
export default Subscription;

View File

@@ -0,0 +1,59 @@
import dayjs from "dayjs";
import React, { useState } from "react";
import { Alert } from "src/api/types";
import { Icons } from "src/components";
type AlertDetailsItemProps = {
alert: Alert;
};
export const AlertDetailItem = ({ alert }: AlertDetailsItemProps) => {
const [open, setOpen] = useState(false);
return (
<div className="item">
<details
className="clean"
onToggle={(e: React.BaseSyntheticEvent) => {
if (e.target.open && !open) {
setOpen(e.target.open);
}
}}
>
<summary className="txt--jam">
<div className="item__info">
<div className="item__title">
<strong>
{dayjs(alert.time).format("YYYY MM.DD hh:mm:ss a")}
</strong>
</div>
<div className="item__meta">
<div>
<div className="i txt--teal">
<Icons.AlertCircle />
<span>{alert.message}</span>
</div>
</div>
</div>
</div>
</summary>
<div className="item__details">
<div className="pre-grid">
{Object.keys(alert).map((key) => (
<React.Fragment key={key}>
<div>{key}:</div>
<div>
{alert[key as keyof typeof alert]
? String(alert[key as keyof typeof alert])
: "null"}
</div>
</React.Fragment>
))}
</div>
</div>
</details>
</div>
);
};
export default AlertDetailItem;

View File

@@ -16,7 +16,6 @@ import {
Section,
SelectFilter,
Spinner,
Icons,
} from "src/components";
import type { Account, Alert, PageQuery } from "src/api/types";
@@ -27,6 +26,7 @@ import {
getQueryFilter,
setLocation,
} from "src/store/localStore";
import AlertDetailItem from "./alert-detail-item";
export const Alerts = () => {
const user = useSelectState("user");
@@ -112,21 +112,7 @@ export const Alerts = () => {
<Spinner />
) : hasLength(alerts) ? (
alerts.map((alert) => (
<div className="item" key={`${alert.alert_type}-${alert.time}`}>
<div className="item__info">
<div className="item__title txt--jam">
<strong>
{dayjs(alert.time).format("YYYY MM.DD hh:mm a")}
</strong>
</div>
<div className="item__meta">
<div className="i">
<Icons.AlertCircle />
<span>{alert.message}</span>
</div>
</div>
</div>
</div>
<AlertDetailItem key={alert.time} alert={alert} />
))
) : (
<M>No data.</M>

View File

@@ -66,10 +66,10 @@ export const DeleteApplication = ({
(account) =>
account.device_calling_application_sid ===
application.application_sid ||
account.siprec_hook_sid === application.application_sid
account.siprec_hook_sid === application.application_sid,
),
teams: msteamRes.json.filter(
(team) => team.application_sid === application.application_sid
(team) => team.application_sid === application.application_sid,
),
};
const deletable =

View File

@@ -15,7 +15,7 @@ export const EditApplication = () => {
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Application>(
`Applications/${params.application_sid}`
`Applications/${params.application_sid}`,
);
useScopedRedirect(
@@ -23,7 +23,7 @@ export const EditApplication = () => {
ROUTE_INTERNAL_APPLICATIONS,
user,
"You do not have access to this resource",
data
data,
);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import { Link, useNavigate } from "react-router-dom";
@@ -16,11 +16,6 @@ import {
LANG_EN_US,
VENDOR_GOOGLE,
LANG_EN_US_STANDARD_C,
VENDOR_AWS,
VENDOR_WELLSAID,
useSpeechVendors,
VENDOR_DEEPGRAM,
VENDOR_SONIOX,
VENDOR_CUSTOM,
} from "src/vendor";
import {
@@ -33,15 +28,17 @@ import {
ROUTE_INTERNAL_ACCOUNTS,
ROUTE_INTERNAL_APPLICATIONS,
} from "src/router/routes";
import { DEFAULT_WEBHOOK, WEBHOOK_METHODS } from "src/api/constants";
import {
DEFAULT_WEBHOOK,
DISABLE_CALL_RECORDING,
WEBHOOK_METHODS,
} from "src/api/constants";
import type {
RecognizerVendors,
SynthesisVendors,
Voice,
VoiceLanguage,
Language,
VendorOptions,
LabelOptions,
} from "src/vendor/types";
import type {
@@ -55,6 +52,7 @@ import type {
import { MSG_REQUIRED_FIELDS, MSG_WEBHOOK_FIELDS } from "src/constants";
import { hasLength, isUserAccountScope, useRedirect } from "src/utils";
import { setAccountFilter, setLocation } from "src/store/localStore";
import SpeechProviderSelection from "./speech-selection";
type ApplicationFormProps = {
application?: UseApiDataMap<Application>;
@@ -62,8 +60,8 @@ type ApplicationFormProps = {
export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const navigate = useNavigate();
const { synthesis, recognizers } = useSpeechVendors();
const user = useSelectState("user");
const currentServiceProvider = useSelectState("currentServiceProvider");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useApiData<Application[]>("Applications");
const [applicationName, setApplicationName] = useState("");
@@ -94,8 +92,41 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
const [message, setMessage] = useState("");
const [apiUrl, setApiUrl] = useState("");
const [credentials] = useApiData<SpeechCredential[]>(apiUrl);
const [softTtsVendor, setSoftTtsVendor] = useState<VendorOptions[]>(vendors);
const [softSttVendor, setSoftSttVendor] = useState<VendorOptions[]>(vendors);
const [ttsVendorOptions, setttsVendorOptions] =
useState<VendorOptions[]>(vendors);
const [sttVendorOptions, setSttVendorOptions] =
useState<VendorOptions[]>(vendors);
const [recogLabel, setRecogLabel] = useState("");
const [ttsLabelOptions, setTtsLabelOptions] = useState<LabelOptions[]>([]);
const [sttLabelOptions, setSttLabelOptions] = useState<LabelOptions[]>([]);
const [fallbackTtsLabelOptions, setFallbackTtsLabelOptions] = useState<
LabelOptions[]
>([]);
const [fallbackSttLabelOptions, setFallbackSttLabelOptions] = useState<
LabelOptions[]
>([]);
const [synthLabel, setSynthLabel] = useState("");
const [recordAllCalls, setRecordAllCalls] = useState(false);
const [useForFallbackSpeech, setUseForFallbackSpeech] = useState(false);
const [fallbackSpeechSynthsisVendor, setFallbackSpeechSynthsisVendor] =
useState<keyof SynthesisVendors>(VENDOR_GOOGLE);
const [fallbackSpeechSynthsisLanguage, setFallbackSpeechSynthsisLanguage] =
useState(LANG_EN_US);
const [fallbackSpeechSynthsisVoice, setFallbackSpeechSynthsisVoice] =
useState(LANG_EN_US_STANDARD_C);
const [fallbackSpeechSynthsisLabel, setFallbackSpeechSynthsisLabel] =
useState("");
const [fallbackSpeechRecognizerVendor, setFallbackSpeechRecognizerVendor] =
useState<keyof RecognizerVendors>(VENDOR_GOOGLE);
const [
fallbackSpeechRecognizerLanguage,
setFallbackSpeechRecognizerLanguage,
] = useState(LANG_EN_US);
const [fallbackSpeechRecognizerLabel, setFallbackSpeechRecognizerLabel] =
useState("");
const [initalCheckFallbackSpeech, setInitalCheckFallbackSpeech] =
useState(false);
/** This lets us map and render the same UI for each... */
const webhooks = [
@@ -134,7 +165,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
useRedirect<Account>(
accounts,
ROUTE_INTERNAL_ACCOUNTS,
"You must create an account before you can create an application."
"You must create an account before you can create an application.",
);
const handleSubmit = (e: React.FormEvent) => {
@@ -142,7 +173,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
if (isUserAccountScope(accountSid, user)) {
toastError(
"You do not have permissions to make changes to these Speech Credentials"
"You do not have permissions to make changes to these Speech Credentials",
);
return;
}
@@ -156,17 +187,17 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
a.name === applicationName &&
(!application ||
!application.data ||
a.application_sid !== application.data.application_sid)
a.application_sid !== application.data.application_sid),
)
) {
setMessage(
"The name you have entered is already in use on another one of your applications."
"The name you have entered is already in use on another one of your applications.",
);
return;
}
}
const payload = {
const payload: Partial<Application> = {
name: applicationName,
app_json: applicationJson || null,
call_hook: callWebhook || null,
@@ -175,9 +206,34 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
call_status_hook: statusWebhook || null,
speech_synthesis_vendor: synthVendor || null,
speech_synthesis_language: synthLang || null,
speech_synthesis_label: synthLabel || null,
speech_synthesis_voice: synthVoice || null,
speech_recognizer_vendor: recogVendor || null,
speech_recognizer_language: recogLang || null,
speech_recognizer_label: recogLabel || null,
record_all_calls: recordAllCalls ? 1 : 0,
use_for_fallback_speech: useForFallbackSpeech ? 1 : 0,
fallback_speech_synthesis_vendor: useForFallbackSpeech
? fallbackSpeechSynthsisVendor || null
: null,
fallback_speech_synthesis_language: useForFallbackSpeech
? fallbackSpeechSynthsisLanguage || null
: null,
fallback_speech_synthesis_voice: useForFallbackSpeech
? fallbackSpeechSynthsisVoice || null
: null,
fallback_speech_synthesis_label: useForFallbackSpeech
? fallbackSpeechSynthsisLabel || null
: null,
fallback_speech_recognizer_vendor: useForFallbackSpeech
? fallbackSpeechRecognizerVendor || null
: null,
fallback_speech_recognizer_language: useForFallbackSpeech
? fallbackSpeechRecognizerLanguage || null
: null,
fallback_speech_recognizer_label: useForFallbackSpeech
? fallbackSpeechRecognizerLabel || null
: null,
};
if (application && application.data) {
@@ -186,7 +242,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
application.refetch();
toastSuccess("Application updated successfully");
navigate(
`${ROUTE_INTERNAL_APPLICATIONS}/${application.data?.application_sid}/edit`
`${ROUTE_INTERNAL_APPLICATIONS}/${application.data?.application_sid}/edit`,
);
})
.catch((error) => {
@@ -205,7 +261,7 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}
};
useEffect(() => {
useMemo(() => {
if (credentials && hasLength(credentials)) {
const v = credentials
.filter((tv) => tv.vendor.startsWith(VENDOR_CUSTOM) && tv.use_for_tts)
@@ -215,9 +271,9 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
` (${VENDOR_CUSTOM})`,
value: tv.vendor,
})
}),
);
setSoftTtsVendor(vendors.concat(v));
setttsVendorOptions(vendors.concat(v));
const v2 = credentials
.filter((tv) => tv.vendor.startsWith(VENDOR_CUSTOM) && tv.use_for_stt)
@@ -227,11 +283,102 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
tv.vendor.substring(VENDOR_CUSTOM.length + 1) +
` (${VENDOR_CUSTOM})`,
value: tv.vendor,
})
}),
);
setSoftSttVendor(vendors.concat(v2));
setSttVendorOptions(vendors.concat(v2));
const noneLabelObject = {
name: "None",
value: "",
};
let c1 = credentials.filter(
(c) =>
c.vendor === synthVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_tts,
);
let c2 = c1
.filter((c) => c.label)
.map((c) =>
Object.assign({
name: c.label,
value: c.label,
}),
);
setTtsLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
c1 = fallbackSpeechSynthsisVendor
? credentials.filter(
(c) =>
c.vendor === fallbackSpeechSynthsisVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_tts,
)
: [];
c2 = c1
.filter((c) => c.label)
.map((c) =>
Object.assign({
name: c.label,
value: c.label,
}),
);
setFallbackTtsLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
c1 = credentials.filter(
(c) =>
c.vendor === recogVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_stt,
);
c2 = c1
.filter((c) => c.label)
.map((c) =>
Object.assign({
name: c.label,
value: c.label,
}),
);
setSttLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
c1 = fallbackSpeechRecognizerVendor
? credentials.filter(
(c) =>
c.vendor === fallbackSpeechRecognizerVendor &&
(!c.account_sid || c.account_sid === accountSid) &&
c.use_for_stt,
)
: [];
c2 = c1
.filter((c) => c.label)
.map((c) =>
Object.assign({
name: c.label,
value: c.label,
}),
);
setFallbackSttLabelOptions(
c1.length !== c2.length ? [noneLabelObject, ...c2] : c2,
);
}
}, [credentials]);
}, [
credentials,
synthVendor,
recogVendor,
fallbackSpeechRecognizerVendor,
fallbackSpeechSynthsisVendor,
]);
useEffect(() => {
if (accountSid) {
@@ -239,17 +386,65 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
}
}, [accountSid]);
useEffect(() => {
let label: string;
// Synthesis Label
label = application?.data?.speech_synthesis_label || "";
if (ttsLabelOptions && !ttsLabelOptions.find((l) => l.value === label)) {
label = ttsLabelOptions.length ? ttsLabelOptions[0].value : "";
}
setSynthLabel(label);
// fallback Synthesis Label
label = application?.data?.fallback_speech_synthesis_label || "";
if (
fallbackTtsLabelOptions &&
!fallbackTtsLabelOptions.find((l) => l.value === label)
) {
label = fallbackTtsLabelOptions.length
? fallbackTtsLabelOptions[0].value
: "";
}
setFallbackSpeechSynthsisLabel(label);
// regconizer label
label = application?.data?.speech_recognizer_label || "";
if (sttLabelOptions && !sttLabelOptions.find((l) => l.value === label)) {
label = sttLabelOptions.length ? sttLabelOptions[0].value : "";
}
setRecogLabel(label);
// fallback regconizer label
label = application?.data?.fallback_speech_recognizer_label || "";
if (
fallbackSttLabelOptions &&
!fallbackSttLabelOptions.find((l) => l.value === label)
) {
label = fallbackSttLabelOptions.length
? fallbackSttLabelOptions[0].value
: "";
}
setFallbackSpeechRecognizerLabel(label);
}, [
ttsLabelOptions,
sttLabelOptions,
fallbackTtsLabelOptions,
fallbackSttLabelOptions,
application,
]);
useEffect(() => {
setLocation();
if (application && application.data) {
setApplicationName(application.data.name);
setRecordAllCalls(application.data.record_all_calls ? true : false);
if (!applicationJson) {
setApplicationJson(application.data.app_json || "");
}
setTmpApplicationJson(applicationJson);
setInitialApplicationJson(
application.data.app_json != undefined &&
application.data.app_json.length !== 0
application.data.app_json.length !== 0,
);
if (application.data.call_hook) {
@@ -296,12 +491,12 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
if (application.data.speech_synthesis_vendor)
setSynthVendor(
application.data.speech_synthesis_vendor as keyof SynthesisVendors
application.data.speech_synthesis_vendor as keyof SynthesisVendors,
);
if (application.data.speech_synthesis_language)
setSynthLang(
application.data.speech_synthesis_language as keyof RecognizerVendors
application.data.speech_synthesis_language as keyof RecognizerVendors,
);
if (application.data.speech_synthesis_voice)
@@ -309,17 +504,89 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
if (application.data.speech_recognizer_vendor)
setRecogVendor(
application.data.speech_recognizer_vendor as keyof RecognizerVendors
application.data.speech_recognizer_vendor as keyof RecognizerVendors,
);
if (application.data.speech_recognizer_language)
setRecogLang(application.data.speech_recognizer_language);
if (application.data.use_for_fallback_speech) {
setUseForFallbackSpeech(application.data.use_for_fallback_speech > 0);
setInitalCheckFallbackSpeech(
application.data.use_for_fallback_speech > 0,
);
}
if (application.data.fallback_speech_recognizer_vendor) {
setFallbackSpeechRecognizerVendor(
application.data
.fallback_speech_recognizer_vendor as keyof RecognizerVendors,
);
}
if (application.data.fallback_speech_recognizer_language) {
setFallbackSpeechRecognizerLanguage(
application.data.fallback_speech_recognizer_language,
);
}
if (application.data.fallback_speech_synthesis_vendor) {
setFallbackSpeechSynthsisVendor(
application.data
.fallback_speech_synthesis_vendor as keyof SynthesisVendors,
);
}
if (application.data.fallback_speech_synthesis_language) {
setFallbackSpeechSynthsisLanguage(
application.data.fallback_speech_synthesis_language,
);
}
if (application.data.fallback_speech_synthesis_voice) {
setFallbackSpeechSynthsisVoice(
application.data.fallback_speech_synthesis_voice,
);
}
}
}, [application]);
const swapPrimaryAndfalloverSpeech = () => {
let tmp;
tmp = synthVendor;
setSynthVendor(fallbackSpeechSynthsisVendor);
setFallbackSpeechSynthsisVendor(tmp);
tmp = synthLang;
setSynthLang(fallbackSpeechSynthsisLanguage);
setFallbackSpeechSynthsisLanguage(synthLang);
tmp = synthVoice;
setSynthVoice(fallbackSpeechSynthsisVoice);
setFallbackSpeechSynthsisVoice(tmp);
tmp = synthLabel;
setSynthLabel(fallbackSpeechSynthsisLabel);
setFallbackSpeechSynthsisLabel(tmp);
tmp = recogVendor;
setRecogVendor(fallbackSpeechRecognizerVendor);
setFallbackSpeechRecognizerVendor(tmp);
tmp = recogLang;
setRecogLang(fallbackSpeechRecognizerLanguage);
setFallbackSpeechRecognizerLanguage(tmp);
tmp = recogLabel;
setRecogLabel(fallbackSpeechRecognizerLabel);
setFallbackSpeechRecognizerLabel(tmp);
};
return (
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!application?.data && application?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -443,216 +710,85 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
</fieldset>
);
})}
{synthesis && (
<fieldset>
<label htmlFor="synthesis_vendor">Speech synthesis vendor</label>
<Selector
id="synthesis_vendor"
name="synthesis_vendor"
value={synthVendor}
options={softTtsVendor.filter(
(vendor) =>
vendor.value != VENDOR_DEEPGRAM &&
vendor.value != VENDOR_SONIOX &&
vendor.value !== VENDOR_CUSTOM
)}
onChange={(e) => {
const vendor = e.target.value as keyof SynthesisVendors;
setSynthVendor(vendor);
<SpeechProviderSelection
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
credentials={credentials}
ttsVendor={[synthVendor, setSynthVendor]}
ttsVendorOptions={ttsVendorOptions}
ttsVoice={[synthVoice, setSynthVoice]}
ttsLang={[synthLang, setSynthLang]}
ttsLabelOptions={ttsLabelOptions}
ttsLabel={[synthLabel, setSynthLabel]}
sttVendor={[recogVendor, setRecogVendor]}
sttVendorOptions={sttVendorOptions}
sttLang={[recogLang, setRecogLang]}
sttLabelOptions={sttLabelOptions}
sttLabel={[recogLabel, setRecogLabel]}
/>
/** When Custom Vendor is used, user you have to input the lange and voice. */
if (vendor.toString().startsWith(VENDOR_CUSTOM)) {
setSynthVoice("");
return;
}
/** When using Google and en-US, ensure "Standard-C" is used as default */
if (
e.target.value === VENDOR_GOOGLE &&
synthLang === LANG_EN_US
) {
setSynthVoice(LANG_EN_US_STANDARD_C);
return;
}
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
let newLang = synthesis[vendor].find(
(lang) => lang.code === synthLang
);
if (newLang) {
setSynthVoice(newLang.voices[0].value);
return;
}
newLang = synthesis[vendor].find(
(lang) => lang.code === LANG_EN_US
);
setSynthLang(LANG_EN_US);
setSynthVoice(newLang!.voices[0].value);
}}
<fieldset>
<Checkzone
hidden
name="cz_fallback_speech"
label="Use a fallback speech vendor if primary fails"
initialCheck={initalCheckFallbackSpeech}
handleChecked={(e) => {
setUseForFallbackSpeech(e.target.checked);
}}
>
<SpeechProviderSelection
serviceProviderSid={
currentServiceProvider?.service_provider_sid || ""
}
accountSid={accountSid}
credentials={credentials}
ttsVendor={[
fallbackSpeechSynthsisVendor,
setFallbackSpeechSynthsisVendor,
]}
ttsVendorOptions={ttsVendorOptions}
ttsVoice={[
fallbackSpeechSynthsisVoice,
setFallbackSpeechSynthsisVoice,
]}
ttsLang={[
fallbackSpeechSynthsisLanguage,
setFallbackSpeechSynthsisLanguage,
]}
ttsLabelOptions={fallbackTtsLabelOptions}
ttsLabel={[
fallbackSpeechSynthsisLabel,
setFallbackSpeechSynthsisLabel,
]}
sttVendor={[
fallbackSpeechRecognizerVendor,
setFallbackSpeechRecognizerVendor,
]}
sttVendorOptions={sttVendorOptions}
sttLang={[
fallbackSpeechRecognizerLanguage,
setFallbackSpeechRecognizerLanguage,
]}
sttLabelOptions={fallbackSttLabelOptions}
sttLabel={[
fallbackSpeechRecognizerLabel,
setFallbackSpeechRecognizerLabel,
]}
/>
{synthVendor &&
!synthVendor.toString().startsWith(VENDOR_CUSTOM) &&
synthLang && (
<>
<label htmlFor="synthesis_lang">Language</label>
<Selector
id="synthesis_lang"
name="synthesis_lang"
value={synthLang}
options={synthesis[
synthVendor as keyof SynthesisVendors
].map((lang: VoiceLanguage) => ({
name: lang.name,
value: lang.code,
}))}
onChange={(e) => {
const language = e.target.value;
setSynthLang(language);
/** When using Google and en-US, ensure "Standard-C" is used as default */
if (
synthVendor === VENDOR_GOOGLE &&
language === LANG_EN_US
) {
setSynthVoice(LANG_EN_US_STANDARD_C);
return;
}
const newLang = synthesis[
synthVendor as keyof SynthesisVendors
].find((lang) => lang.code === language);
setSynthVoice(newLang!.voices[0].value);
}}
/>
<label htmlFor="synthesis_voice">Voice</label>
<Selector
id="synthesis_voice"
name="synthesis_voice"
value={synthVoice}
options={
synthesis[synthVendor as keyof SynthesisVendors]
.filter(
(lang: VoiceLanguage) => lang.code === synthLang
)
.flatMap((lang: VoiceLanguage) =>
lang.voices.map((voice: Voice) => ({
name: voice.name,
value: voice.value,
}))
) as Voice[]
}
onChange={(e) => setSynthVoice(e.target.value)}
/>
</>
)}
{synthVendor.toString().startsWith(VENDOR_CUSTOM) && (
<>
<label htmlFor="custom_vendor_synthesis_lang">Language</label>
<input
id="custom_vendor_synthesis_lang"
type="text"
name="custom_vendor_synthesis_lang"
placeholder="Required"
required
value={synthLang}
onChange={(e) => {
setSynthLang(e.target.value);
}}
/>
<label htmlFor="custom_vendor_synthesis_voice">Voice</label>
<input
id="custom_vendor_synthesis_voice"
type="text"
name="custom_vendor_synthesis_voice"
placeholder="Required"
required
value={synthVoice}
onChange={(e) => {
setSynthVoice(e.target.value);
}}
/>
</>
)}
</fieldset>
)}
{recognizers && (
<fieldset>
<label htmlFor="recognizer_vendor">Speech recognizer vendor</label>
<Selector
id="recognizer_vendor"
name="recognizer_vendor"
value={recogVendor}
options={softSttVendor.filter(
(vendor) =>
vendor.value != VENDOR_WELLSAID &&
vendor.value !== VENDOR_CUSTOM
)}
onChange={(e) => {
const vendor = e.target.value as keyof RecognizerVendors;
setRecogVendor(vendor);
/**When vendor is custom, Language is input by user */
if (vendor.toString() === VENDOR_CUSTOM) return;
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
const newLang = recognizers[vendor].find(
(lang: Language) => lang.code === recogLang
);
if (
(vendor === VENDOR_GOOGLE || vendor === VENDOR_AWS) &&
!newLang
) {
setRecogLang(LANG_EN_US);
}
}}
/>
{recogVendor &&
!recogVendor.toString().startsWith(VENDOR_CUSTOM) &&
recogLang && (
<>
<label htmlFor="recognizer_lang">Language</label>
<Selector
id="recognizer_lang"
name="recognizer_lang"
value={recogLang}
options={recognizers[
recogVendor as keyof RecognizerVendors
].map((lang: Language) => ({
name: lang.name,
value: lang.code,
}))}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
{recogVendor.toString().startsWith(VENDOR_CUSTOM) && (
<>
<label htmlFor="custom_vendor_recognizer_voice">Language</label>
<input
id="custom_vendor_recognizer_voice"
type="text"
name="custom_vendor_recognizer_voice"
placeholder="Required"
required
value={recogLang}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
</fieldset>
)}
<fieldset>
<Button
type="button"
small
onClick={swapPrimaryAndfalloverSpeech}
>
Swap primary and fallback
</Button>
</fieldset>
</Checkzone>
</fieldset>
{(import.meta.env.INITIAL_APP_JSON_ENABLED === undefined ||
import.meta.env.INITIAL_APP_JSON_ENABLED) && (
<fieldset>
@@ -683,6 +819,23 @@ export const ApplicationForm = ({ application }: ApplicationFormProps) => {
</Checkzone>
</fieldset>
)}
{!DISABLE_CALL_RECORDING &&
accounts?.filter((a) => a.account_sid === accountSid).length &&
!accounts?.filter((a) => a.account_sid === accountSid)[0]
.record_all_calls && (
<fieldset>
<label htmlFor="record_all_call" className="chk">
<input
id="record_all_call"
name="record_all_call"
type="checkbox"
onChange={(e) => setRecordAllCalls(e.target.checked)}
checked={recordAllCalls}
/>
<div>Record all calls</div>
</label>
</fieldset>
)}
{message && <fieldset>{<Message message={message} />}</fieldset>}
<fieldset>
<ButtonGroup left>

View File

@@ -40,14 +40,14 @@ export const Applications = () => {
const filteredApplications = useFilteredResults<Application>(
filter,
applications
applications,
);
const handleDelete = () => {
if (application) {
if (isUserAccountScope(accountSid, user)) {
toastError(
"You do not have permissions to make changes to this Application"
"You do not have permissions to make changes to this Application",
);
return;
}
@@ -59,7 +59,7 @@ export const Applications = () => {
toastSuccess(
<>
Deleted application <strong>{application.name}</strong>
</>
</>,
);
})
.catch((error) => {
@@ -96,7 +96,7 @@ export const Applications = () => {
</Link>
)}
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter applications"
filter={[filter, setFilter]}
@@ -139,7 +139,7 @@ export const Applications = () => {
{
accounts?.find(
(acct) =>
acct.account_sid === application.account_sid
acct.account_sid === application.account_sid,
)?.name
}
</span>

View File

@@ -0,0 +1,577 @@
import React, { useEffect, useRef, useState } from "react";
import {
getGoogleCustomVoices,
getSpeechSupportedLanguagesAndVoices,
} from "src/api";
import { USER_ADMIN } from "src/api/constants";
import {
SpeechCredential,
SpeechSupportedLanguagesAndVoices,
} from "src/api/types";
import { Selector } from "src/components/forms";
import { SelectorOption } from "src/components/forms/selector";
import { toastError, useSelectState } from "src/store";
import { hasLength } from "src/utils";
import {
ELEVENLABS_LANG_EN,
LANG_COBALT_EN_US,
LANG_EN_US,
LANG_EN_US_STANDARD_C,
VENDOR_AWS,
VENDOR_COBALT,
VENDOR_CUSTOM,
VENDOR_DEEPGRAM,
VENDOR_ASSEMBLYAI,
VENDOR_ELEVENLABS,
VENDOR_GOOGLE,
VENDOR_MICROSOFT,
VENDOR_SONIOX,
VENDOR_WELLSAID,
VENDOR_WHISPER,
} from "src/vendor";
import {
LabelOptions,
RecognizerVendors,
SynthesisVendors,
VendorOptions,
} from "src/vendor/types";
type SpeechProviderSelectionProbs = {
accountSid: string;
serviceProviderSid: string;
credentials: SpeechCredential[] | undefined;
ttsVendor: [
keyof SynthesisVendors,
React.Dispatch<React.SetStateAction<keyof SynthesisVendors>>,
];
ttsVendorOptions: VendorOptions[];
ttsVoice: [string, React.Dispatch<React.SetStateAction<string>>];
ttsLang: [string, React.Dispatch<React.SetStateAction<string>>];
ttsLabelOptions: LabelOptions[];
ttsLabel: [string, React.Dispatch<React.SetStateAction<string>>];
sttVendor: [
keyof RecognizerVendors,
React.Dispatch<React.SetStateAction<keyof RecognizerVendors>>,
];
sttVendorOptions: VendorOptions[];
sttLang: [string, React.Dispatch<React.SetStateAction<string>>];
sttLabelOptions: LabelOptions[];
sttLabel: [string, React.Dispatch<React.SetStateAction<string>>];
};
export const SpeechProviderSelection = ({
accountSid,
serviceProviderSid,
credentials,
ttsVendor: [synthVendor, setSynthVendor],
ttsVendorOptions,
ttsVoice: [synthVoice, setSynthVoice],
ttsLang: [synthLang, setSynthLang],
ttsLabelOptions,
ttsLabel: [synthLabel, setSynthLabel],
sttVendor: [recogVendor, setRecogVendor],
sttVendorOptions,
sttLang: [recogLang, setRecogLang],
sttLabelOptions,
sttLabel: [recogLabel, setRecogLabel],
}: SpeechProviderSelectionProbs) => {
const user = useSelectState("user");
const [
synthesisSupportedLanguagesAndVoices,
setSynthesisSupportedLanguagesAndVoices,
] = useState<SpeechSupportedLanguagesAndVoices | null>();
const [selectedCredential, setSelectedCredential] = useState<
SpeechCredential | undefined
>();
const [synthesisVoiceOptions, setSynthesisVoiceOptions] = useState<
SelectorOption[]
>([]);
const [synthesisLanguageOptions, setSynthesisLanguageOptions] = useState<
SelectorOption[]
>([]);
const [synthesisModelOptions, setSynthesisModelOptions] = useState<
SelectorOption[]
>([]);
const [
synthesisGoogleCustomVoiceOptions,
setSynthesisGoogleCustomVoiceOptions,
] = useState<SelectorOption[]>([]);
const [recogLanguageOptions, setRecogLanguageOptions] = useState<
SelectorOption[]
>([]);
const currentTtsVendor = useRef(synthVendor);
const currentSttVendor = useRef(recogVendor);
const shouldUpdateTtsVoice = useRef(false);
const shouldUpdateSttLanguage = useRef(false);
const ttsEffectTimer = useRef<number | null>(null);
const sttEffectTimer = useRef<number | null>(null);
// Get Synthesis languages and voices
useEffect(() => {
if (
!user ||
!synthVendor ||
(user?.scope === USER_ADMIN && !serviceProviderSid)
) {
return;
}
currentTtsVendor.current = synthVendor;
/** When Custom Vendor is used, user you have to input the lange and voice. */
if (synthVendor.toString().startsWith(VENDOR_CUSTOM)) {
setSynthVoice("");
return;
}
// just execute last change
if (ttsEffectTimer.current) {
clearTimeout(ttsEffectTimer.current);
}
ttsEffectTimer.current = setTimeout(() => {
configSynthesis();
}, 200);
}, [synthVendor, synthLabel, serviceProviderSid]);
// Get Recognizer languages and voices
useEffect(() => {
/** When Custom Vendor is used, user you have to input the lange and voice. */
if (recogVendor.toString().startsWith(VENDOR_CUSTOM)) {
setRecogLang(LANG_EN_US);
return;
}
if (
!user ||
!recogVendor ||
(user?.scope === USER_ADMIN && !serviceProviderSid)
) {
return;
}
currentSttVendor.current = recogVendor;
// just execute last change
if (sttEffectTimer.current) {
clearTimeout(sttEffectTimer.current);
}
sttEffectTimer.current = setTimeout(() => {
configRecognizer();
}, 200);
}, [recogVendor, recogLabel, serviceProviderSid]);
useEffect(() => {
if (credentials) {
setSelectedCredential(
credentials.find(
(c) => c.vendor === synthVendor && (c.label || "") === synthLabel,
),
);
}
}, [synthVendor, synthLabel, credentials]);
useEffect(() => {
if (!synthLabel && ttsLabelOptions?.length > 0) {
setSynthLabel(ttsLabelOptions[0].value);
}
if (!recogLabel && sttLabelOptions?.length > 0) {
setRecogLabel(sttLabelOptions[0].value);
}
}, [ttsLabelOptions, sttLabelOptions]);
useEffect(() => {
if (synthesisSupportedLanguagesAndVoices) {
// Extract Voice
const voicesOpts =
synthesisSupportedLanguagesAndVoices.tts?.find((lang) => {
if (synthVendor === VENDOR_ELEVENLABS && lang.voices.length > 0) {
return true;
}
return lang.value === synthLang;
})?.voices || [];
if (synthVendor === VENDOR_GOOGLE && synthesisGoogleCustomVoiceOptions) {
if (synthesisGoogleCustomVoiceOptions) {
setSynthesisVoiceOptions([
...synthesisGoogleCustomVoiceOptions,
...voicesOpts,
]);
} else {
setSynthesisVoiceOptions(voicesOpts);
}
if (synthesisGoogleCustomVoiceOptions.length > 0) {
updateTtsVoice(synthesisGoogleCustomVoiceOptions[0].value);
}
} else {
setSynthesisVoiceOptions(voicesOpts);
}
}
}, [
synthLang,
synthesisSupportedLanguagesAndVoices,
synthesisGoogleCustomVoiceOptions,
]);
const configSynthesis = () => {
getSpeechSupportedLanguagesAndVoices(
serviceProviderSid,
synthVendor,
synthLabel,
)
.then(({ json }) => {
// while fetching data, user might change the vendor
if (currentTtsVendor.current !== synthVendor) {
return;
}
setSynthesisSupportedLanguagesAndVoices(json);
// Extract model
if (json.models && json.models.length) {
setSynthesisModelOptions(json.models);
if (synthVendor === VENDOR_DEEPGRAM) {
setSynthVoice(json.models[0].value);
return;
}
}
if (json.tts && json.tts.length) {
// Extract Language
const langOpts = json.tts.map((lang) => ({
name: lang.name,
value: lang.value,
}));
setSynthesisLanguageOptions(langOpts);
// Default setting
const googleLang = json.tts.find((lang) => lang.value === synthLang);
if (
synthVendor === VENDOR_GOOGLE &&
(!googleLang ||
!googleLang.voices.find((v) => v.value === synthVoice))
) {
setSynthLang(LANG_EN_US);
updateTtsVoice(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);
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);
return;
}
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
let newLang = json.tts.find((lang) => lang.value === synthLang);
if (newLang) {
updateTtsVoice(newLang.voices[0].value);
return;
}
newLang = json.tts.find((lang) => lang.value === LANG_EN_US);
setSynthLang(LANG_EN_US);
updateTtsVoice(newLang!.voices[0].value);
}
})
.catch((error) => {
toastError(error.msg);
});
if (synthVendor === VENDOR_GOOGLE) {
getGoogleCustomVoices({
...(synthLabel && { label: synthLabel }),
account_sid: accountSid,
service_provider_sid: serviceProviderSid,
}).then(({ json }) => {
// If after successfully fetching data, vendor is still good, then apply value
if (currentTtsVendor.current !== VENDOR_GOOGLE) {
return;
}
const customVOices = json.map((v) => ({
name: `${v.name} (Custom)`,
value: `custom_${v.google_custom_voice_sid}`,
}));
setSynthesisGoogleCustomVoiceOptions(customVOices);
});
}
};
const updateTtsVoice = (value: string) => {
if (shouldUpdateTtsVoice.current) {
setSynthVoice(value);
shouldUpdateTtsVoice.current = false;
}
};
const configRecognizer = () => {
getSpeechSupportedLanguagesAndVoices(
serviceProviderSid,
recogVendor,
recogLabel,
)
.then(({ json }) => {
// while fetching data, the user might change the vendor
if (currentSttVendor.current !== recogVendor) {
return;
}
// Extract Language
const langOpts = json.stt.map((lang) => ({
name: lang.name,
value: lang.value,
}));
setRecogLanguageOptions(langOpts);
/**When vendor is custom, Language is input by user */
if (
recogVendor.toString() === VENDOR_CUSTOM ||
!shouldUpdateSttLanguage.current
)
return;
shouldUpdateSttLanguage.current = false;
/** Google and AWS have different language lists */
/** If the new language doesn't map then default to "en-US" */
const newLang = json.stt.find((lang) => lang.value === recogLang);
if (
(recogVendor === VENDOR_GOOGLE || recogVendor === VENDOR_AWS) &&
!newLang
) {
setRecogLang(LANG_EN_US);
} else if (recogVendor === VENDOR_COBALT && !newLang) {
setRecogLang(LANG_COBALT_EN_US);
} else if (langOpts.length && !newLang) {
setRecogLang(langOpts[0].value);
}
})
.catch((error) => {
toastError(error.msg);
});
};
return (
<>
<fieldset>
<label htmlFor="synthesis_vendor">Speech synthesis vendor</label>
<Selector
id="synthesis_vendor"
name="synthesis_vendor"
value={synthVendor}
options={ttsVendorOptions.filter(
(vendor) =>
vendor.value != VENDOR_ASSEMBLYAI &&
vendor.value != VENDOR_SONIOX &&
vendor.value !== VENDOR_CUSTOM &&
vendor.value !== VENDOR_COBALT,
)}
onChange={(e) => {
const vendor = e.target.value as keyof SynthesisVendors;
shouldUpdateTtsVoice.current = true;
setSynthVendor(vendor);
setSynthLabel("");
setSynthesisLanguageOptions([]);
setSynthesisVoiceOptions([]);
}}
/>
{hasLength(ttsLabelOptions) && (
<>
<label htmlFor="synthesis_label">Label</label>
<Selector
id="systhesis_label"
name="systhesis_label"
value={synthLabel}
options={ttsLabelOptions}
onChange={(e) => {
setSynthLabel(e.target.value);
}}
/>
</>
)}
{synthesisModelOptions && synthVendor === VENDOR_DEEPGRAM && (
<>
<label htmlFor="synthesis_lang">Model</label>
<Selector
id="synthesis_voice"
name="synthesis_voice"
value={synthVoice}
options={synthesisModelOptions}
onChange={(e) => setSynthVoice(e.target.value)}
/>
</>
)}
{synthVendor &&
!synthVendor.toString().startsWith(VENDOR_CUSTOM) &&
synthVendor !== VENDOR_DEEPGRAM &&
synthLang && (
<>
<label htmlFor="synthesis_lang">Language</label>
<Selector
id="synthesis_lang"
name="synthesis_lang"
value={synthLang}
options={synthesisLanguageOptions}
onChange={(e) => {
shouldUpdateTtsVoice.current = true;
const language = e.target.value;
setSynthLang(language);
/** When using Google and en-US, ensure "Standard-C" is used as default */
if (
synthVendor === VENDOR_GOOGLE &&
language === LANG_EN_US
) {
setSynthVoice(LANG_EN_US_STANDARD_C);
return;
}
const voices =
synthesisSupportedLanguagesAndVoices?.tts.find(
(lang) => lang.value === language,
)?.voices || [];
if (
synthVendor === VENDOR_GOOGLE &&
synthesisGoogleCustomVoiceOptions &&
synthesisGoogleCustomVoiceOptions.length
) {
setSynthesisVoiceOptions([
...synthesisGoogleCustomVoiceOptions,
...voices,
]);
} else {
setSynthesisVoiceOptions(voices);
}
setSynthVoice(voices[0].value);
}}
/>
<label htmlFor="synthesis_voice">Voice</label>
{synthVendor === VENDOR_MICROSOFT &&
selectedCredential &&
selectedCredential.use_custom_tts ? (
<input
id="custom_microsoft_synthesis_voice"
type="text"
name="custom_microsoft_synthesis_voice"
placeholder="Required"
required
value={synthVoice}
onChange={(e) => {
setSynthVoice(e.target.value);
}}
/>
) : (
<Selector
id="synthesis_voice"
name="synthesis_voice"
value={synthVoice}
options={synthesisVoiceOptions}
onChange={(e) => setSynthVoice(e.target.value)}
/>
)}
</>
)}
{synthVendor.toString().startsWith(VENDOR_CUSTOM) && (
<>
<label htmlFor="custom_vendor_synthesis_lang">Language</label>
<input
id="custom_vendor_synthesis_lang"
type="text"
name="custom_vendor_synthesis_lang"
placeholder="Required"
required
value={synthLang}
onChange={(e) => {
setSynthLang(e.target.value);
}}
/>
<label htmlFor="custom_vendor_synthesis_voice">Voice</label>
<input
id="custom_vendor_synthesis_voice"
type="text"
name="custom_vendor_synthesis_voice"
placeholder="Required"
required
value={synthVoice}
onChange={(e) => {
setSynthVoice(e.target.value);
}}
/>
</>
)}
</fieldset>
<fieldset>
<label htmlFor="recognizer_vendor">Speech recognizer vendor</label>
<Selector
id="recognizer_vendor"
name="recognizer_vendor"
value={recogVendor}
options={sttVendorOptions.filter(
(vendor) =>
vendor.value != VENDOR_WELLSAID &&
vendor.value != VENDOR_ELEVENLABS &&
vendor.value != VENDOR_WHISPER &&
vendor.value !== VENDOR_CUSTOM,
)}
onChange={(e) => {
const vendor = e.target.value as keyof RecognizerVendors;
shouldUpdateSttLanguage.current = true;
setRecogVendor(vendor);
setRecogLabel("");
setRecogLanguageOptions([]);
}}
/>
{hasLength(sttLabelOptions) && (
<>
<label htmlFor="recog_label">Label</label>
<Selector
id="recog_label"
name="recog_label"
value={recogLabel}
options={sttLabelOptions}
onChange={(e) => {
setRecogLabel(e.target.value);
}}
/>
</>
)}
{recogVendor &&
!recogVendor.toString().startsWith(VENDOR_CUSTOM) &&
recogLang && (
<>
<label htmlFor="recognizer_lang">Language</label>
<Selector
id="recognizer_lang"
name="recognizer_lang"
value={recogLang}
options={recogLanguageOptions}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
{recogVendor.toString().startsWith(VENDOR_CUSTOM) && (
<>
<label htmlFor="custom_vendor_recognizer_voice">Language</label>
<input
id="custom_vendor_recognizer_voice"
type="text"
name="custom_vendor_recognizer_voice"
placeholder="Required"
required
value={recogLang}
onChange={(e) => {
setRecogLang(e.target.value);
}}
/>
</>
)}
</fieldset>
</>
);
};
export default SpeechProviderSelection;

View File

@@ -28,8 +28,8 @@ export const DeleteCarrier = ({
if (!ignore) {
setPhoneNumbers(
json.filter(
(phone) => phone.voip_carrier_sid === carrier.voip_carrier_sid
)
(phone) => phone.voip_carrier_sid === carrier.voip_carrier_sid,
),
);
}
});

View File

@@ -15,13 +15,13 @@ export const EditCarrier = () => {
const params = useParams();
const user = useSelectState("user");
const [data, refetch, error] = useApiData<Carrier>(
`VoipCarriers/${params.voip_carrier_sid}`
`VoipCarriers/${params.voip_carrier_sid}`,
);
const [sipGateways, sipGatewaysRefetch] = useApiData<SipGateway[]>(
`SipGateways?voip_carrier_sid=${params.voip_carrier_sid}`
`SipGateways?voip_carrier_sid=${params.voip_carrier_sid}`,
);
const [smppGateways, smppGatewaysRefetch] = useApiData<SmppGateway[]>(
`SmppGateways?voip_carrier_sid=${params.voip_carrier_sid}`
`SmppGateways?voip_carrier_sid=${params.voip_carrier_sid}`,
);
useScopedRedirect(
@@ -29,7 +29,7 @@ export const EditCarrier = () => {
ROUTE_INTERNAL_CARRIERS,
user,
"You do not have access to this resource",
data
data,
);
useEffect(() => {

View File

@@ -22,6 +22,7 @@ import {
FQDN,
FQDN_TOP_LEVEL,
INVALID,
IP,
NETMASK_OPTIONS,
SIP_GATEWAY_PROTOCOL_OPTIONS,
TCP_MAX_PORT,
@@ -47,6 +48,8 @@ import {
hasLength,
isValidPort,
disableDefaultTrunkRouting,
hasValue,
isNotBlank,
} from "src/utils";
import type {
@@ -233,20 +236,33 @@ export const CarrierForm = ({
const updateSipGateways = (
index: number,
key: string,
value: typeof sipGateways[number][keyof SipGateway]
value: (typeof sipGateways)[number][keyof SipGateway],
) => {
setSipGateways(
sipGateways.map((g, i) => (i === index ? { ...g, [key]: value } : g))
sipGateways.map((g, i) =>
i === index
? {
...g,
[key]: value,
// If Change to ipv4 and port is null, change port to 5060
...(key === "ipv4" &&
value &&
typeof value === "string" &&
getIpValidationType(value) === IP &&
g.port === null && { port: 5060 }),
}
: g,
),
);
};
const updateSmppGateways = (
index: number,
key: string,
value: typeof smppGateways[number][keyof SmppGateway]
value: (typeof smppGateways)[number][keyof SmppGateway],
) => {
setSmppGateways(
smppGateways.map((g, i) => (i === index ? { ...g, [key]: value } : g))
smppGateways.map((g, i) => (i === index ? { ...g, [key]: value } : g)),
);
};
@@ -255,8 +271,8 @@ export const CarrierForm = ({
sipGateways.map(({ sip_gateway_sid, ...g }: SipGateway) =>
sip_gateway_sid
? putSipGateway(sip_gateway_sid, g)
: postSipGateway({ ...g, voip_carrier_sid })
)
: postSipGateway({ ...g, voip_carrier_sid }),
),
).then(() => {
if (carrierSipGateways) {
carrierSipGateways.refetch();
@@ -273,7 +289,7 @@ export const CarrierForm = ({
smpp_gateway_sid
? putSmppGateway(smpp_gateway_sid, g)
: postSmppGateway({ ...g, voip_carrier_sid });
})
}),
).then(() => {
if (carrierSmppGateways) {
carrierSmppGateways.refetch();
@@ -284,7 +300,7 @@ export const CarrierForm = ({
const handleSipGatewayDelete = (g?: SipGateway) => {
if (g && g.sip_gateway_sid) {
deleteSipGateway(g.sip_gateway_sid).then(() =>
toastSuccess("SIP gateway successfully deleted")
toastSuccess("SIP gateway successfully deleted"),
);
}
};
@@ -295,8 +311,8 @@ export const CarrierForm = ({
toastSuccess(
`SMPP ${
g.outbound ? "outbound" : "inbound"
} gateway successfully deleted`
)
} gateway successfully deleted`,
),
);
}
};
@@ -320,10 +336,13 @@ export const CarrierForm = ({
const gateway = sipGateways[i];
const type = getIpValidationType(gateway.ipv4);
/** DH: unclear why we had this restriction, removing for now
if (type === FQDN_TOP_LEVEL) {
refSipIp.current[i].focus();
return "When using an FQDN, you must use a subdomain (e.g. sip.example.com).";
} else if (type === FQDN && (!gateway.outbound || gateway.inbound)) {
*/
if (type === FQDN && (!gateway.outbound || gateway.inbound)) {
refSipIp.current[i].focus();
return "A fully qualified domain name may only be used for outbound calls.";
} else if (type === INVALID) {
@@ -409,7 +428,9 @@ export const CarrierForm = ({
/** When to switch to `sip` tab */
const emptySipIp = sipGateways.find((g) => g.ipv4.trim() === "");
const invalidSipPort = sipGateways.find((g) => !isValidPort(g.port));
const invalidSipPort = sipGateways.find(
(g) => hasValue(g.port) && !isValidPort(g.port),
);
const sipGatewayValidation = getSipValidation();
/** Empty SIP gateway */
@@ -509,7 +530,7 @@ export const CarrierForm = ({
putCarrier(
currentServiceProvider.service_provider_sid,
carrier.data.voip_carrier_sid,
carrierPayload
carrierPayload,
)
.then(() => {
if (carrier.data?.voip_carrier_sid) {
@@ -520,7 +541,7 @@ export const CarrierForm = ({
toastSuccess("Carrier updated successfully");
carrier.refetch();
navigate(
`${ROUTE_INTERNAL_CARRIERS}/${carrier.data?.voip_carrier_sid}/edit`
`${ROUTE_INTERNAL_CARRIERS}/${carrier.data?.voip_carrier_sid}/edit`,
);
})
.catch((error) => {
@@ -550,7 +571,7 @@ export const CarrierForm = ({
setLocation();
if (predefinedName && hasLength(predefinedCarriers)) {
const predefinedCarrierSid = predefinedCarriers.find(
(a) => a.name === predefinedName
(a) => a.name === predefinedName,
)?.predefined_carrier_sid;
if (currentServiceProvider && predefinedCarrierSid) {
@@ -558,11 +579,11 @@ export const CarrierForm = ({
user?.scope === USER_ACCOUNT
? postPredefinedCarrierTemplateAccount(
accountSid,
predefinedCarrierSid
predefinedCarrierSid,
)
: postPredefinedCarrierTemplate(
currentServiceProvider.service_provider_sid,
predefinedCarrierSid
predefinedCarrierSid,
);
postPredefinedCarrier
@@ -618,7 +639,12 @@ export const CarrierForm = ({
return (
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!carrier?.data && carrier?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -667,9 +693,9 @@ export const CarrierForm = ({
(carrier: PredefinedCarrier) => ({
name: carrier.name,
value: carrier.name,
})
}),
)
: []
: [],
)}
onChange={(e) => setPredefinedName(e.target.value)}
/>
@@ -722,7 +748,7 @@ export const CarrierForm = ({
accounts={
user?.scope === USER_ACCOUNT
? accounts?.filter(
(acct) => user.account_sid === acct.account_sid
(acct) => user.account_sid === acct.account_sid,
)
: accounts
}
@@ -734,8 +760,8 @@ export const CarrierForm = ({
user?.scope !== USER_ACCOUNT
? false
: user.account_sid !== accountSid
? true
: false
? true
: false
}
/>
{user &&
@@ -748,7 +774,7 @@ export const CarrierForm = ({
defaultOption="None"
application={[applicationSid, setApplicationSid]}
applications={applications.filter(
(application) => application.account_sid === accountSid
(application) => application.account_sid === accountSid,
)}
/>
</>
@@ -776,7 +802,7 @@ export const CarrierForm = ({
Does your carrier require authentication on outbound calls?
</MS>
<label htmlFor="sip_username">
Username {sipPass || sipRegister ? <span>*</span> : ""}
Auth username {sipPass || sipRegister ? <span>*</span> : ""}
</label>
<input
id="sip_username"
@@ -831,7 +857,7 @@ export const CarrierForm = ({
required={sipRegister}
onChange={(e) => setSipRealm(e.target.value)}
/>
<label htmlFor="from_user">SIP from user</label>
<label htmlFor="from_user">Username</label>
<input
id="from_user"
name="from_user"
@@ -962,13 +988,21 @@ export const CarrierForm = ({
type="number"
min="0"
max={TCP_MAX_PORT}
placeholder={DEFAULT_SIP_GATEWAY.port.toString()}
value={g.port}
placeholder={
g.protocol === "tls" || g.protocol === "tls/srtp"
? ""
: DEFAULT_SIP_GATEWAY.port?.toString()
}
value={g.port === null ? "" : g.port}
onChange={(e) => {
updateSipGateways(
i,
"port",
Number(e.target.value)
g.outbound > 0 &&
!isNotBlank(e.target.value) &&
getIpValidationType(g.ipv4) !== IP
? null
: Number(e.target.value),
);
}}
ref={(ref: HTMLInputElement) =>
@@ -981,7 +1015,6 @@ export const CarrierForm = ({
<Selector
id={`sip_protocol_${i}`}
name={`sip_protocol${i}`}
placeholder=""
value={g.protocol}
options={SIP_GATEWAY_PROTOCOL_OPTIONS}
onChange={(e) => {
@@ -994,7 +1027,6 @@ export const CarrierForm = ({
<Selector
id={`sip_netmask_${i}`}
name={`sip_netmask${i}`}
placeholder="32"
value={g.netmask}
options={NETMASK_OPTIONS}
onChange={(e) => {
@@ -1019,7 +1051,7 @@ export const CarrierForm = ({
updateSipGateways(
i,
"is_active",
e.target.checked ? 1 : 0
e.target.checked ? 1 : 0,
);
}}
/>
@@ -1038,7 +1070,7 @@ export const CarrierForm = ({
updateSipGateways(
i,
"inbound",
e.target.checked ? 1 : 0
e.target.checked ? 1 : 0,
);
}}
/>
@@ -1057,13 +1089,59 @@ export const CarrierForm = ({
updateSipGateways(
i,
"outbound",
e.target.checked
e.target.checked,
);
}}
/>
<div>Outbound</div>
</label>
</div>
{g.outbound > 0 && g.protocol === "tls/srtp" && (
<div>
<label
htmlFor={`sip_pad_crypto_${i}`}
className="chk"
>
<input
id={`sip_pad_crypto_${i}`}
name={`sip_pad_crypto_${i}`}
type="checkbox"
checked={g.pad_crypto ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"pad_crypto",
e.target.checked,
);
}}
/>
<div>Pad crypto</div>
</label>
</div>
)}
{Boolean(g.outbound) && (
<div>
<label
htmlFor={`send_options_ping_${i}`}
className="chk"
>
<input
id={`send_options_ping_${i}`}
name={`send_options_ping_${i}`}
type="checkbox"
checked={g.send_options_ping ? true : false}
onChange={(e) => {
updateSipGateways(
i,
"send_options_ping",
e.target.checked,
);
}}
/>
<div>Send OPTIONS ping</div>
</label>
</div>
)}
</div>
<button
@@ -1075,15 +1153,15 @@ export const CarrierForm = ({
if (sipGateways.length === 1) {
setSipMessage(
"You must provide at least one SIP Gateway."
"You must provide at least one SIP Gateway.",
);
} else {
handleSipGatewayDelete(
sipGateways.find((g2, i2) => i2 === i)
sipGateways.find((g2, i2) => i2 === i),
);
setSipGateways(
sipGateways.filter((g2, i2) => i2 !== i)
sipGateways.filter((g2, i2) => i2 !== i),
);
}
}}
@@ -1197,7 +1275,7 @@ export const CarrierForm = ({
updateSmppGateways(
i,
"port",
Number(e.target.value)
Number(e.target.value),
)
}
ref={(ref: HTMLInputElement) =>
@@ -1216,7 +1294,7 @@ export const CarrierForm = ({
updateSmppGateways(
i,
"use_tls",
e.target.checked
e.target.checked,
)
}
/>
@@ -1238,15 +1316,15 @@ export const CarrierForm = ({
(smppSystemId || smppPass)
) {
setSmppOutboundMessage(
"You must provide at least one Outbound Gateway."
"You must provide at least one Outbound Gateway.",
);
} else {
handleSmppGatewayDelete(
smppGateways.find((g2, i2) => i2 === i)
smppGateways.find((g2, i2) => i2 === i),
);
setSmppGateways(
smppGateways.filter((g2, i2) => i2 !== i)
smppGateways.filter((g2, i2) => i2 !== i),
);
}
}}
@@ -1340,7 +1418,6 @@ export const CarrierForm = ({
<Selector
id={`smpp_netmask_${i}`}
name={`smpp_netmask_${i}`}
placeholder="32"
options={NETMASK_OPTIONS}
value={g.netmask}
onChange={(e) =>
@@ -1355,11 +1432,11 @@ export const CarrierForm = ({
type="button"
onClick={() => {
handleSmppGatewayDelete(
smppGateways.find((g2, i2) => i2 === i)
smppGateways.find((g2, i2) => i2 === i),
);
setSmppGateways(
smppGateways.filter((g2, i2) => i2 !== i)
smppGateways.filter((g2, i2) => i2 !== i),
);
}}
>

View File

@@ -12,7 +12,7 @@ type GatewaysProps = {
export const Gateways = ({ carrier }: GatewaysProps) => {
const [gateways, , error] = useApiData<SipGateway[]>(
`SipGateways?voip_carrier_sid=${carrier.voip_carrier_sid}`
`SipGateways?voip_carrier_sid=${carrier.voip_carrier_sid}`,
);
const renderGateways = () => {

View File

@@ -30,16 +30,24 @@ import {
API_SIP_GATEWAY,
API_SMPP_GATEWAY,
CARRIER_REG_OK,
ENABLE_HOSTED_SYSTEM,
USER_ACCOUNT,
} from "src/api/constants";
import { DeleteCarrier } from "./delete";
import type { Account, Carrier, SipGateway, SmppGateway } from "src/api/types";
import type {
Account,
Carrier,
CurrentUserData,
SipGateway,
SmppGateway,
} from "src/api/types";
import { Scope } from "src/store/types";
import { getAccountFilter, setLocation } from "src/store/localStore";
export const Carriers = () => {
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);
@@ -58,14 +66,14 @@ export const Carriers = () => {
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null
: carrier.account_sid === null,
)
: [];
}, [accountSid, carrier, carriers]);
const filteredCarriers = useFilteredResults<Carrier>(
filter,
carriersFiltered
carriersFiltered,
);
const handleDelete = () => {
@@ -79,10 +87,10 @@ export const Carriers = () => {
.then(() => {
Promise.all([
getFetch<SipGateway[]>(
`${API_SIP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`
`${API_SIP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`,
),
getFetch<SmppGateway[]>(
`${API_SMPP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`
`${API_SMPP_GATEWAY}?voip_carrier_sid=${carrier.voip_carrier_sid}`,
),
]).then(([sipGatewaysRes, smppGatewaysRes]) => {
hasLength(sipGatewaysRes.json) &&
@@ -91,8 +99,8 @@ export const Carriers = () => {
g &&
g.sip_gateway_sid &&
deleteSipGateway(g.sip_gateway_sid).catch((error) =>
toastError(error.msg)
)
toastError(error.msg),
),
);
hasLength(smppGatewaysRes.json) &&
smppGatewaysRes.json.forEach(
@@ -100,8 +108,8 @@ export const Carriers = () => {
g &&
g.smpp_gateway_sid &&
deleteSmppGateway(g.smpp_gateway_sid).catch((error) =>
toastError(error.msg)
)
toastError(error.msg),
),
);
});
setCarrier(null);
@@ -109,7 +117,7 @@ export const Carriers = () => {
toastSuccess(
<>
Deleted Carrier <strong>{carrier.name}</strong>
</>
</>,
);
})
.catch((error) => {
@@ -122,7 +130,7 @@ export const Carriers = () => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
);
}
}, [user, currentServiceProvider, accountSid]);
@@ -130,7 +138,16 @@ export const Carriers = () => {
return (
<>
<section className="mast">
<H1 className="h2">Carriers</H1>
<div>
<H1 className="h2">Carriers</H1>
{ENABLE_HOSTED_SYSTEM && (
<M>
Have your carrier send calls to{" "}
<span>{userData?.account?.sip_realm}</span>
</M>
)}
</div>
<Link to={`${ROUTE_INTERNAL_CARRIERS}/add`} title="Add a Carrier">
{" "}
<Icon>
@@ -138,7 +155,7 @@ export const Carriers = () => {
</Icon>
</Link>
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter carriers"
filter={[filter, setFilter]}

View File

@@ -8,7 +8,7 @@ import {
} from "src/api";
import { toastError } from "src/store";
import type { Pcap } from "src/api/types";
import type { DownloadedBlob } from "src/api/types";
type PcapButtonProps = {
accountSid: string;
@@ -21,17 +21,18 @@ export const PcapButton = ({
serviceProviderSid,
sipCallId,
}: PcapButtonProps) => {
const [pcap, setPcap] = useState<Pcap>();
const [pcap, setPcap] = useState<DownloadedBlob>();
useEffect(() => {
if (!sipCallId) return;
const p = accountSid
? getRecentCall(accountSid, sipCallId)
: getServiceProviderRecentCall(serviceProviderSid, sipCallId);
p.then(({ json }) => {
if (json.total > 0) {
const p1 = accountSid
? getPcap(accountSid, sipCallId)
: getServiceProviderPcap(serviceProviderSid, sipCallId);
? getPcap(accountSid, sipCallId, "register")
: getServiceProviderPcap(serviceProviderSid, sipCallId, "register");
p1.then(({ blob }) => {
if (blob) {
setPcap({

View File

@@ -10,6 +10,13 @@ type CarrierProps = {
};
export const RegisterStatus = ({ carrier }: CarrierProps) => {
const getReason = () => {
return carrier.register_status.reason
? typeof carrier.register_status.reason === "string"
? carrier.register_status.reason
: "Not Started"
: "Not Started";
};
const renderStatus = () => {
return (
<div
@@ -20,7 +27,7 @@ export const RegisterStatus = ({ carrier }: CarrierProps) => {
: "jam"
: "jean"
}`}
title={carrier.register_status.reason || "Not Started"}
title={getReason()}
>
{carrier.register_status.status === CARRIER_REG_OK ? (
<Icons.CheckCircle />
@@ -40,8 +47,7 @@ export const RegisterStatus = ({ carrier }: CarrierProps) => {
<details className={carrier.register_status.status || "not-tested"}>
<summary>{renderStatus()}</summary>
<MS>
<strong>Reason:</strong>{" "}
{carrier.register_status.reason || "Not Started"}
<strong>Reason:</strong> {getReason()}
</MS>
<PcapButton
accountSid={carrier.account_sid || ""}

View File

@@ -0,0 +1,14 @@
import { H1 } from "@jambonz/ui-kit";
import React from "react";
import ClientsForm from "./form";
export const ClientsAdd = () => {
return (
<>
<H1 className="h2">Add sip client</H1>
<ClientsForm />
</>
);
};
export default ClientsAdd;

View File

@@ -0,0 +1,28 @@
import { P } from "@jambonz/ui-kit";
import React from "react";
import { Client } from "src/api/types";
import { Modal } from "src/components";
type ClientsDeleteProps = {
client: Client;
handleCancel: () => void;
handleSubmit: () => void;
};
export const ClientsDelete = ({
client,
handleCancel,
handleSubmit,
}: ClientsDeleteProps) => {
return (
<>
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
<P>
Are you sure you want to delete the sip client{" "}
<strong>{client.username}</strong>?
</P>
</Modal>
</>
);
};
export default ClientsDelete;

View File

@@ -0,0 +1,33 @@
import { H1 } from "@jambonz/ui-kit";
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";
export const ClientsEdit = () => {
const params = useParams();
const navigate = useNavigate();
const [data, refetch, error] = useApiData<Client>(
`Clients/${params.client_sid}`,
);
/** Handle error toast at top level... */
useEffect(() => {
if (error) {
toastError(error.msg);
navigate(ROUTE_INTERNAL_CLIENTS);
}
}, [error]);
return (
<>
<H1 className="h2">Edit sip client</H1>
<ClientsForm client={{ data, refetch, error }} />
</>
);
};
export default ClientsEdit;

View File

@@ -0,0 +1,272 @@
import { Button, ButtonGroup, MS } from "@jambonz/ui-kit";
import React, { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
deleteClient,
postClient,
putClient,
useServiceProviderData,
} from "src/api";
import { USER_ACCOUNT } from "src/api/constants";
import { Account, Client, UseApiDataMap } from "src/api/types";
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 ClientsDelete from "./delete";
import { hasValue } from "src/utils";
import { IMessage } from "src/store/types";
type ClientsFormProps = {
client?: UseApiDataMap<Client>;
};
export const ClientsForm = ({ client }: ClientsFormProps) => {
const user = useSelectState("user");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const navigate = useNavigate();
const [accountSid, setAccountSid] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState("");
const [isActive, setIsActive] = useState(
client ? client.data?.is_active : true,
);
const [allowDirectAppCalling, setAllowDirectAppCalling] = useState(true);
const [allowDirectQueueCalling, setAllowDirectQueueCalling] = useState(true);
const [allowDirectUserCalling, setAllowDirectUserCalling] = useState(true);
const [modal, setModal] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!client) {
postClient({
account_sid: accountSid,
username: username,
password: password,
is_active: isActive,
allow_direct_app_calling: allowDirectAppCalling,
allow_direct_queue_calling: allowDirectQueueCalling,
allow_direct_user_calling: allowDirectUserCalling,
})
.then(() => {
toastSuccess("Client created successfully");
navigate(ROUTE_INTERNAL_CLIENTS);
})
.catch((error: { msg: IMessage }) => {
toastError(error.msg);
});
} else {
putClient(client.data?.client_sid || "", {
is_active: isActive,
allow_direct_app_calling: allowDirectAppCalling,
allow_direct_queue_calling: allowDirectQueueCalling,
allow_direct_user_calling: allowDirectUserCalling,
})
.then(() => {
toastSuccess("Client updated successfully");
navigate(ROUTE_INTERNAL_CLIENTS);
})
.catch((error: { msg: IMessage }) => {
toastError(error.msg);
});
}
};
const handleCancel = () => {
setModal(false);
};
const handleDelete = () => {
if (client) {
deleteClient(client.data?.client_sid || "")
.then(() => {
toastSuccess("Client deleted successfully");
navigate(ROUTE_INTERNAL_CLIENTS);
})
.catch((error: { msg: IMessage }) => {
toastError(error.msg);
});
}
};
useEffect(() => {
if (client && client.data) {
if (client.data.username) {
setUsername(client.data.username);
}
if (client.data.account_sid) {
setAccountSid(client.data.account_sid);
}
if (client.data.password) {
setPassword(client.data.password);
}
setIsActive(client.data.is_active);
setAllowDirectAppCalling(client.data.allow_direct_app_calling);
setAllowDirectQueueCalling(client.data.allow_direct_queue_calling);
setAllowDirectUserCalling(client.data.allow_direct_user_calling);
}
}, [client]);
useEffect(() => {
const acc = accounts?.find((a) => a.account_sid === accountSid);
if (!accountSid || !accounts || !acc) return;
if (!acc?.sip_realm) {
setErrorMessage(`Sip realm is not set for the account.`);
} else {
setErrorMessage("");
}
}, [accountSid]);
return (
<>
<Section slim>
<form
className={`form form--internal ${
!client?.data && client?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
{errorMessage && <Message message={errorMessage} />}
</fieldset>
<fieldset>
<div className="multi">
<div className="inp">
<label htmlFor="lcr_name">
User Name<span>*</span>
</label>
<input
id="client_username"
name="client_username"
type="text"
placeholder="user name"
value={username}
required={true}
disabled={hasValue(client)}
autoComplete="off"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<label htmlFor="password">
Password{!hasValue(client) && <span>*</span>}
</label>
<Passwd
id="password"
required={!hasValue(client)}
name="password"
value={password}
placeholder="Password"
setValue={setPassword}
disabled={hasValue(client)}
autoComplete="off"
/>
</fieldset>
<fieldset>
<label htmlFor="is_active" className="chk">
<input
id="is_active"
name="is_active"
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
/>
<div>Active</div>
</label>
<label htmlFor="allow_direct_app_calling" className="chk">
<input
id="allow_direct_app_calling"
name="allow_direct_app_calling"
type="checkbox"
checked={allowDirectAppCalling}
onChange={(e) => setAllowDirectAppCalling(e.target.checked)}
/>
<div>Allow direct calling to applications</div>
<Tooltip text="Allow user to call applications without configuring an application for sip device calls.">
{" "}
</Tooltip>
</label>
<label htmlFor="allow_direct_queue_calling" className="chk">
<input
id="allow_direct_queue_calling"
name="allow_direct_queue_calling"
type="checkbox"
checked={allowDirectQueueCalling}
onChange={(e) => setAllowDirectQueueCalling(e.target.checked)}
/>
<div>Allow direct calling to queues</div>
<Tooltip text="Allow user to take calls from queues without configuring an application for sip device calls.">
{" "}
</Tooltip>
</label>
<label htmlFor="allow_direct_user_calling" className="chk">
<input
id="allow_direct_user_calling"
name="allow_direct_user_calling"
type="checkbox"
checked={allowDirectUserCalling}
onChange={(e) => setAllowDirectUserCalling(e.target.checked)}
/>
<div>Allow direct calling to other users</div>
<Tooltip text="Allow user to call other users without configuring an application for sip device calls.">
{" "}
</Tooltip>
</label>
</fieldset>
{user?.scope !== USER_ACCOUNT && (
<fieldset>
<AccountSelect
accounts={accounts}
account={[accountSid, setAccountSid]}
label="Belongs to"
required={true}
defaultOption={false}
disabled={hasValue(client)}
/>
</fieldset>
)}
<fieldset>
<ButtonGroup left className={client && "btns--spaced"}>
<Button
small
subStyle="grey"
as={Link}
to={ROUTE_INTERNAL_CLIENTS}
>
Cancel
</Button>
<Button type="submit" small disabled={errorMessage !== ""}>
Save
</Button>
{client && client.data && (
<Button
small
type="button"
subStyle="grey"
onClick={() => setModal(true)}
>
Delete User
</Button>
)}
</ButtonGroup>
</fieldset>
</form>
</Section>
{client && client.data && modal && (
<ClientsDelete
client={client.data}
handleCancel={handleCancel}
handleSubmit={handleDelete}
/>
)}
</>
);
};
export default ClientsForm;

View File

@@ -0,0 +1,229 @@
import { Button, H1, Icon, M } from "@jambonz/ui-kit";
import React, { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { deleteClient, useApiData, useServiceProviderData } from "src/api";
import { Account, Client, CurrentUserData } from "src/api/types";
import {
AccountFilter,
Icons,
ScopedAccess,
SearchFilter,
Section,
Spinner,
} from "src/components";
import { ROUTE_INTERNAL_CLIENTS } from "src/router/routes";
import { toastError, toastSuccess, 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";
export const Clients = () => {
const user = useSelectState("user");
const [userData] = useApiData<CurrentUserData>("Users/me");
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [clients, refetch] = useApiData<Client[]>("Clients");
const [accountSid, setAccountSid] = useState("");
const [selectedAccount, setSelectedAccount] = useState<
Account | null | undefined
>(null);
const [filter, setFilter] = useState("");
const [client, setClient] = useState<Client | null>();
const tmpFilteredClients = useMemo(() => {
if (user?.account_sid && user?.scope === USER_ACCOUNT) {
setAccountSid(user?.account_sid);
return clients;
}
setSelectedAccount(
accountSid
? accounts?.find((a: Account) => a.account_sid === accountSid)
: null,
);
return clients
? clients.filter((c) => {
return accountSid
? c.account_sid === accountSid
: accounts
? accounts.map((a) => a.account_sid).includes(c.account_sid || "")
: false;
})
: [];
}, [accountSid, clients, accounts]);
const filteredClients = useFilteredResults(filter, tmpFilteredClients);
const handleDelete = () => {
if (client) {
deleteClient(client.client_sid || "")
.then(() => {
toastSuccess(
<>
Deleted sip client <strong>{client.username}</strong>
</>,
);
setClient(null);
refetch();
})
.catch((error) => {
toastError(error.msg);
});
}
};
return (
<>
<section className="mast">
<div>
<H1 className="h2">SIP client credentials</H1>
{user?.scope === USER_ACCOUNT ? (
userData?.account?.sip_realm ? (
<>
<M>
Your sip realm is <span>{userData?.account?.sip_realm}</span>
</M>
<M>
You can add sip credentials below to allow sip devices to
register to this realm and make calls.
</M>
</>
) : (
<M>
You need to associate a sip realm to this account in order to
add sip credentials.
</M>
)
) : selectedAccount ? (
selectedAccount?.sip_realm ? (
<>
<M>
Your sip realm is <span>{selectedAccount.sip_realm}</span>
</M>
<M>
You can add sip credentials below to allow sip devices to
register to this realm and make calls.
</M>
</>
) : (
<M>
You need to associate a sip realm to this account in order to
add sip credentials.
</M>
)
) : (
<></>
)}
</div>
<Link to={`${ROUTE_INTERNAL_CLIENTS}/add`} title="Add sip client">
{" "}
<Icon>
<Icons.Plus />
</Icon>
</Link>
</section>
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter clients"
filter={[filter, setFilter]}
/>
<ScopedAccess user={user} scope={Scope.admin}>
<AccountFilter
account={[accountSid, setAccountSid]}
accounts={accounts}
label=""
defaultOption
/>
</ScopedAccess>
</section>
<Section {...(hasLength(filteredClients) && { slim: true })}>
<div className="list">
{!hasValue(filteredClients) && hasLength(accounts) ? (
<Spinner />
) : hasLength(filteredClients) ? (
filteredClients.map((c) => (
<div className="item" key={c.client_sid}>
<div className="item__info">
<div className="item__title">
<Link
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
title="Edit outbound call routes"
className="i"
>
<strong>{c.username}</strong>
<Icons.ArrowRight />
</Link>
</div>
<div className="item__meta">
<div>
<div
className={`i txt--${c.is_active ? "teal" : "grey"}`}
>
{c.is_active ? (
<Icons.CheckCircle />
) : (
<Icons.XCircle />
)}
<span>{c.is_active ? "Active" : "Inactive"}</span>
</div>
</div>
<div>
<div
className={`i txt--${c.account_sid ? "teal" : "grey"}`}
>
<Icons.Activity />
<span>
{
accounts?.find(
(acct) => acct.account_sid === c.account_sid,
)?.name
}
</span>
</div>
</div>
</div>
</div>
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_CLIENTS}/${c.client_sid}/edit`}
title="Edit Client"
>
<Icons.Edit3 />
</Link>
<button
type="button"
title="Delete client"
onClick={() => setClient(c)}
className="btnty"
>
<Icons.Trash />
</button>
</div>
</div>
))
) : (
<M>No sip clients.</M>
)}
</div>
</Section>
<Section clean>
<Button small as={Link} to={`${ROUTE_INTERNAL_CLIENTS}/add`}>
Add sip client
</Button>
</Section>
{client && (
<ClientsDelete
client={client}
handleCancel={() => setClient(null)}
handleSubmit={handleDelete}
/>
)}
</>
);
};
export default Clients;

View File

@@ -27,7 +27,7 @@ type CardProps = {
index1: number,
index2: number,
key: string,
value: unknown
value: unknown,
) => void;
handleRouteDelete: (lr: LcrRoute, index: number) => void;
carrierSelectorOptions: SelectorOption[];
@@ -122,6 +122,7 @@ export const Card = ({
className={`lcr lcr--route lcr-card lcr-card-${
isDragging ? "disappear" : "appear"
}`}
// eslint-disable-next-line react/no-unknown-property
handler-id={handlerId}
>
<div>
@@ -140,7 +141,6 @@ export const Card = ({
<Selector
id={`lcr_carrier_set_entry_carrier_${index}`}
name={`lcr_carrier_set_entry_carrier_${index}`}
placeholder="Carrier"
value={
lr.lcr_carrier_set_entries && lr.lcr_carrier_set_entries.length > 0
? lr.lcr_carrier_set_entries[0].voip_carrier_sid
@@ -155,7 +155,7 @@ export const Card = ({
index,
0,
"voip_carrier_sid",
e.target.value
e.target.value,
);
}}
/>

View File

@@ -6,7 +6,6 @@ import update from "immutability-helper";
import { deleteLcrRoute } from "src/api";
import { toastError, toastSuccess } from "src/store";
import { SelectorOption } from "src/components/forms/selector";
import { NOT_AVAILABLE_PREFIX } from "src/constants";
type ContainerProps = {
lcrRoute: [LcrRoute[], React.Dispatch<React.SetStateAction<LcrRoute[]>>];
@@ -24,13 +23,13 @@ export const Container = ({
[dragIndex, 1],
[hoverIndex, 0, prevCards[dragIndex]],
],
})
}),
);
};
const updateLcrRoute = (index: number, key: string, value: unknown) => {
setLcrRoutes(
lcrRoutes.map((lr, i) => (i === index ? { ...lr, [key]: value } : lr))
lcrRoutes.map((lr, i) => (i === index ? { ...lr, [key]: value } : lr)),
);
};
@@ -38,7 +37,7 @@ export const Container = ({
index1: number,
index2: number,
key: string,
value: unknown
value: unknown,
) => {
setLcrRoutes(
lcrRoutes.map((lr, i) =>
@@ -52,20 +51,16 @@ export const Container = ({
...entry,
[key]: value,
}
: entry
: entry,
),
}
: lr
)
: lr,
),
);
};
const handleRouteDelete = (r: LcrRoute | undefined, index: number) => {
if (
r &&
r.lcr_route_sid &&
!r.lcr_route_sid.startsWith(NOT_AVAILABLE_PREFIX)
) {
if (r && r.lcr_route_sid) {
deleteLcrRoute(r.lcr_route_sid)
.then(() => {
toastSuccess("Least cost routing rule successfully deleted");
@@ -82,7 +77,7 @@ export const Container = ({
{hasLength(lcrRoutes) &&
lcrRoutes.map((lr, i) => (
<Card
key={lr.lcr_route_sid}
key={lr.lcr_route_sid || i}
lr={lr}
index={i}
moveCard={moveCard}

View File

@@ -7,10 +7,10 @@ import { useParams } from "react-router-dom";
export const EditLcr = () => {
const params = useParams();
const [lcrData, lcrRefect, lcrError] = useApiData<Lcr>(
`Lcrs/${params.lcr_sid}`
`Lcrs/${params.lcr_sid}`,
);
const [lcrRouteData, lcrRouteRefect, lcrRouteError] = useApiData<LcrRoute[]>(
`LcrRoutes?lcr_sid=${params.lcr_sid}`
`LcrRoutes?lcr_sid=${params.lcr_sid}`,
);
return (
<>

View File

@@ -8,37 +8,33 @@ import {
useDispatch,
useSelectState,
} from "src/store";
import { MSG_REQUIRED_FIELDS, NOT_AVAILABLE_PREFIX } from "src/constants";
import { MSG_REQUIRED_FIELDS } from "src/constants";
import { setLocation } from "src/store/localStore";
import { AccountSelect, Message, Selector } from "src/components/forms";
import type {
Account,
Carrier,
Lcr,
LcrCarrierSetEntry,
LcrRoute,
UseApiDataMap,
} from "src/api/types";
import { ROUTE_INTERNAL_LEST_COST_ROUTING } from "src/router/routes";
import {
deleteLcr,
postLcrCarrierSetEntry,
putLcrCarrierSetEntries,
putLcrRoutes,
putLcr,
postLcrCreateRoutes,
putLcrUpdateRoutes,
useApiData,
useServiceProviderData,
getLcrRoute,
} from "src/api";
import { USER_ACCOUNT, USER_ADMIN } from "src/api/constants";
import { postLcr } from "src/api";
import { postLcrRoute } from "src/api";
import DeleteLcr from "./delete";
import { Scope } from "src/store/types";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import Container from "./container";
import { v4 } from "uuid";
import { hasValue } from "src/utils";
type LcrFormProps = {
lcrDataMap?: UseApiDataMap<Lcr>;
@@ -47,7 +43,7 @@ type LcrFormProps = {
export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const LCR_ROUTE_TEMPLATE: LcrRoute = {
lcr_route_sid: `${NOT_AVAILABLE_PREFIX}${v4()}`,
lcr_route_sid: "",
regex: "",
lcr_sid: "",
priority: 0,
@@ -68,7 +64,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const [defaultLcrCarrier, setDefaultLcrCarrier] = useState("");
const [defaultLcrCarrierSetEntrySid, setDefaultLcrCarrierSetEntrySid] =
useState<string | null>();
const [defaultLcrRouteSid, setDefaultLcrRouteSid] = useState("");
const [defaultLcrRoute, setDefaultLcrRoute] = useState<LcrRoute | null>(null);
const [defaultCarrier, setDefaultCarrier] = useState("");
const [apiUrl, setApiUrl] = useState("");
const [accountSid, setAccountSid] = useState("");
@@ -89,7 +85,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setLocation();
if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`
`ServiceProviders/${currentServiceProvider.service_provider_sid}/VoipCarriers`,
);
}
}, [user, currentServiceProvider, accountSid]);
@@ -103,7 +99,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
? carriers.filter((carrier) =>
accountSid
? carrier.account_sid === accountSid
: carrier.account_sid === null
: carrier.account_sid === null,
)
: [];
@@ -122,7 +118,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setErrorMessage(
accountSid
? "There are no available carriers defined for this account"
: "There are no available carriers"
: "There are no available carriers",
);
} else {
setErrorMessage("");
@@ -136,44 +132,53 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
setPreviousLcr(lcrDataMap.data);
}
if (
lcrRouteDataMap &&
lcrRouteDataMap.data &&
lcrRouteDataMap.data !== previousLcrRoutes
) {
setPreviousLcrRoutes(lcrRouteDataMap.data);
// Find default carrier
lcrRouteDataMap.data.forEach((lr) => {
lr.lcr_carrier_set_entries?.forEach((entry) => {
if (
entry.lcr_carrier_set_entry_sid ===
lcrDataMap?.data?.default_carrier_set_entry_sid
) {
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
setDefaultLcrCarrierSetEntrySid(
entry.lcr_carrier_set_entry_sid || null
);
setDefaultLcrRouteSid(entry.lcr_route_sid || "");
}
});
});
}
useMemo(() => {
let default_lcr_route_sid = "";
if (
lcrRouteDataMap &&
lcrRouteDataMap.data &&
lcrRouteDataMap.data !== previousLcrRoutes
) {
setPreviousLcrRoutes(lcrRouteDataMap.data);
// Find default carrier
lcrRouteDataMap.data.forEach((lr) => {
lr.lcr_carrier_set_entries?.forEach((entry) => {
if (
entry.lcr_carrier_set_entry_sid ===
lcrDataMap?.data?.default_carrier_set_entry_sid
) {
setDefaultLcrCarrier(entry.voip_carrier_sid || defaultCarrier);
setDefaultLcrCarrierSetEntrySid(
entry.lcr_carrier_set_entry_sid || null,
);
default_lcr_route_sid = entry.lcr_route_sid || "";
setDefaultLcrRoute(lr);
}
});
});
}
if (lcrRouteDataMap && lcrRouteDataMap.data)
setLcrRoutes(
lcrRouteDataMap.data.filter(
(route) => route.lcr_route_sid !== defaultLcrRouteSid
)
(route) => route.lcr_route_sid !== default_lcr_route_sid,
),
);
}, [defaultLcrRouteSid]);
}, [lcrRouteDataMap?.data]);
const addLcrRoutes = () => {
const newLcrRoute = LCR_ROUTE_TEMPLATE;
const ls = [
...lcrRoutes,
{
...LCR_ROUTE_TEMPLATE,
...newLcrRoute,
priority: lcrRoutes.length,
lcr_carrier_set_entries: newLcrRoute.lcr_carrier_set_entries?.map(
(r) => ({
...r,
voip_carrier_sid: defaultCarrier || carrierSelectorOptions[0].value,
}),
),
},
];
setLcrRoutes(ls);
@@ -194,11 +199,48 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
const lcrPayload: Lcr = getLcrPayload();
postLcr(lcrPayload)
.then(({ json }) => {
Promise.all(
lcrRoutes.map((route, i) => handleLcrRoutePost(json.sid, route, i))
)
const lcrsPayload = lcrRoutes.map((l, i) => ({
...l,
lcr_carrier_set_entries: l.lcr_carrier_set_entries?.map((e) => ({
...e,
voip_carrier_sid:
e.voip_carrier_sid ||
defaultCarrier ||
carrierSelectorOptions[0]?.value,
})),
lcr_sid: json.sid,
priority: i,
}));
lcrsPayload.push({
lcr_sid: json.sid,
regex: ".*",
description: "System Default Route",
priority: 9999,
lcr_carrier_set_entries: [
{
lcr_route_sid: "",
voip_carrier_sid:
defaultLcrCarrier || carrierSelectorOptions[0]?.value,
priority: 0,
},
],
});
postLcrCreateRoutes(json.sid, lcrsPayload)
.then(() => {
handleLcrDefaultCarrierPost(json.sid);
if (lcrDataMap) {
toastSuccess("Least cost routing successfully updated");
} else {
toastSuccess("Least cost routing successfully created");
if (user?.access === Scope.admin) {
navigate(ROUTE_INTERNAL_LEST_COST_ROUTING);
} else {
navigate(
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${json.sid}/edit`,
);
}
// Update global state
dispatch({ type: "lcr" });
}
})
.catch(({ msg }) => {
toastError(msg);
@@ -209,209 +251,38 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
});
};
const handleLcrDefaultCarrierPost = (lcr_sid: string) => {
const defaultRoute = {
lcr_sid: lcr_sid,
regex: ".*",
desciption: "System Default Route",
priority: 9999,
lcr_carrier_set_entries: [
{
lcr_route_sid: "",
voip_carrier_sid: defaultLcrCarrier,
priority: 0,
},
],
};
handleLcrRoutePost(lcr_sid, defaultRoute, 9999).then((lcr_route_sid) => {
// There is small hack here to wait the data commited in bd for lcr entries
new Promise(async (r) => setTimeout(() => r(0), 300)).then(() => {
getLcrRoute(lcr_route_sid).then(({ json }) => {
if (json.lcr_carrier_set_entries?.length) {
const lcr_carrier_set_entry_sid =
json.lcr_carrier_set_entries[0].lcr_carrier_set_entry_sid;
putLcr(lcr_sid, {
default_carrier_set_entry_sid: lcr_carrier_set_entry_sid,
})
.then(() => {
if (lcrDataMap) {
toastSuccess("Least cost routing successfully updated");
} else {
toastSuccess("Least cost routing successfully created");
if (user?.access === Scope.admin) {
navigate(ROUTE_INTERNAL_LEST_COST_ROUTING);
} else {
navigate(
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr_sid}/edit`
);
}
// Update global state
dispatch({ type: "lcr" });
}
})
.catch((error) => {
toastError(error);
});
}
});
});
});
};
const handleLcrRoutePost = (
lcr_sid: string,
route: LcrRoute,
priority: number
): Promise<string> => {
return new Promise(async (resolve, reject) => {
const lcrRoutePayload: LcrRoute = {
lcr_sid,
regex: route.regex,
priority,
};
postLcrRoute(lcrRoutePayload)
.then(({ json }) => {
if (route.lcr_carrier_set_entries) {
Promise.all(
route.lcr_carrier_set_entries.map((entry) => {
handleLcrCarrierSetEntryPost(json.sid, entry);
})
)
.then(() => {
resolve(json.sid);
})
.catch((error) => {
reject(error);
});
}
})
.catch((error) => {
reject(error);
});
});
};
const handleLcrCarrierSetEntryPost = (
lcr_route_sid: string,
entry: LcrCarrierSetEntry
): Promise<string> => {
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
...entry,
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
lcr_route_sid,
};
return new Promise<string>(async (r, e) => {
postLcrCarrierSetEntry(lcrCarrierSetEntryPayload)
.then(({ json }) => r(json.sid))
.catch((error) => e(error));
});
};
const handleLcrPut = () => {
if (lcrDataMap && lcrDataMap.data && lcrDataMap.data.lcr_sid) {
// update LCR
const lcrPayload: Lcr = getLcrPayload();
putLcr(lcrDataMap.data.lcr_sid, lcrPayload).then(() => {
Promise.all(
lcrRoutes.map((route, i) => {
if (
route.lcr_route_sid &&
!route.lcr_route_sid?.startsWith(NOT_AVAILABLE_PREFIX)
) {
handleLcrRoutePut(
lcrDataMap.data?.lcr_sid || "",
route.lcr_route_sid,
route,
i
);
} else {
handleLcrRoutePost(lcrDataMap.data?.lcr_sid || "", route, i);
}
})
)
putLcrUpdateRoutes(lcrDataMap.data?.lcr_sid || "", [
...lcrRoutes.map((r, i) => ({
...r,
priority: i,
})),
...(hasValue(defaultLcrRoute)
? [
{
...defaultLcrRoute,
lcr_carrier_set_entries:
defaultLcrRoute.lcr_carrier_set_entries?.map((r) => ({
...r,
voip_carrier_sid:
defaultLcrCarrier ||
r.voip_carrier_sid ||
carrierSelectorOptions[0].value,
})),
},
]
: []),
])
.then(() => {
if (defaultLcrCarrierSetEntrySid) {
const defaultEntry: LcrCarrierSetEntry = {
lcr_route_sid: defaultLcrRouteSid,
voip_carrier_sid: defaultLcrCarrier,
priority: 0,
};
handleLcrCarrierEntryPut(
defaultLcrRouteSid,
defaultLcrCarrierSetEntrySid,
defaultEntry
).then(() => {
toastSuccess("Least cost routing rule successfully updated");
});
}
toastSuccess("Least cost routing rule successfully updated");
})
.catch((error) => toastError(error));
});
}
const handleLcrRoutePut = (
lcr_sid: string,
lcr_route_sid: string,
route: LcrRoute,
priority: number
): Promise<string> => {
return new Promise(async (resolve, reject) => {
const lcrRoutePayload: LcrRoute = {
lcr_sid,
regex: route.regex,
priority,
};
putLcrRoutes(lcr_route_sid, lcrRoutePayload).then(() => {
if (
route.lcr_carrier_set_entries &&
route.lcr_carrier_set_entries.length > 0
) {
Promise.all(
route.lcr_carrier_set_entries.map((entry) => {
if (entry.lcr_carrier_set_entry_sid) {
return handleLcrCarrierEntryPut(
entry.lcr_route_sid || lcr_route_sid,
entry.lcr_carrier_set_entry_sid,
entry
);
} else {
return handleLcrCarrierSetEntryPost(lcr_route_sid, entry);
}
})
)
.then(() => {
resolve("Least cost routing rule successfully updated");
})
.catch((error) => {
reject(error);
});
}
});
});
};
const handleLcrCarrierEntryPut = (
lcr_route_sid: string,
lcr_carrier_set_entry_sid: string,
entry: LcrCarrierSetEntry
): Promise<string> => {
const lcrCarrierSetEntryPayload: LcrCarrierSetEntry = {
lcr_route_sid,
workload: entry.workload,
voip_carrier_sid: entry.voip_carrier_sid || defaultCarrier,
priority: entry.priority,
};
return new Promise<string>(async (r, e) => {
putLcrCarrierSetEntries(
lcr_carrier_set_entry_sid,
lcrCarrierSetEntryPayload
)
.then(() => r("success"))
.catch((error) => e(error));
});
};
};
const handleSubmit = (e: React.FormEvent) => {
@@ -430,7 +301,7 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
toastSuccess(
<>
Deleted least cost routing <strong>{lcrForDelete?.name}</strong>
</>
</>,
);
setLcrForDelete(null);
if (user?.access === Scope.admin) {
@@ -449,7 +320,12 @@ export const LcrForm = ({ lcrDataMap, lcrRouteDataMap }: LcrFormProps) => {
return (
<>
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!lcrDataMap?.data && lcrDataMap?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
{errorMessage && <Message message={errorMessage} />}

View File

@@ -32,7 +32,7 @@ export const Lcrs = () => {
Scope.admin,
`${ROUTE_INTERNAL_LEST_COST_ROUTING}/add`,
user,
"You do not have permissions to manage all outbound call routes"
"You do not have permissions to manage all outbound call routes",
);
const [lcrs, refetch] = useApiData<Lcr[]>("Lcrs");
const [filter, setFilter] = useState("");
@@ -53,9 +53,9 @@ export const Lcrs = () => {
accountSid
? lcr.account_sid === accountSid
: currentServiceProvider?.service_provider_sid
? lcr.service_provider_sid ==
currentServiceProvider.service_provider_sid
: lcr.account_sid === null
? lcr.service_provider_sid ==
currentServiceProvider.service_provider_sid
: lcr.account_sid === null,
)
: [];
}, [accountSid, lcrs]);
@@ -68,7 +68,7 @@ export const Lcrs = () => {
toastSuccess(
<>
Deleted outbound call route <strong>{lcr?.name}</strong>
</>
</>,
);
setLcr(null);
refetch();
@@ -99,7 +99,7 @@ export const Lcrs = () => {
multiple carriers available.
</M>
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter placeholder="Filter lcrs" filter={[filter, setFilter]} />
<ScopedAccess user={user} scope={Scope.admin}>
<AccountFilter
@@ -156,7 +156,7 @@ export const Lcrs = () => {
<span>
{lcr.account_sid
? accounts?.find(
(acct) => acct.account_sid === lcr.account_sid
(acct) => acct.account_sid === lcr.account_sid,
)?.name
: currentServiceProvider?.name}
</span>
@@ -183,7 +183,7 @@ export const Lcrs = () => {
<div className="item__actions">
<Link
to={`${ROUTE_INTERNAL_LEST_COST_ROUTING}/${lcr.lcr_sid}/edit`}
title="Edit carrier"
title="Edit Client"
>
<Icons.Edit3 />
</Link>

View File

@@ -4,7 +4,9 @@
}
.lcr-card:hover {
box-shadow: -7px 7px 5px #d5d7db, -5px -5px 10px #ffffff;
box-shadow:
-7px 7px 5px #d5d7db,
-5px -5px 10px #ffffff;
transform: translateY(-3px) translateX(-3px);
}

View File

@@ -11,7 +11,7 @@ import type { MSTeamsTenant } from "src/api/types";
export const EditMsTeamsTenant = () => {
const params = useParams();
const [data, refetch, error] = useApiData<MSTeamsTenant>(
`MicrosoftTeamsTenants/${params.ms_teams_tenant_sid}`
`MicrosoftTeamsTenants/${params.ms_teams_tenant_sid}`,
);
useEffect(() => {

View File

@@ -49,7 +49,7 @@ export const MsTeamsTenantForm = ({
useRedirect<Account>(
accounts,
ROUTE_INTERNAL_ACCOUNTS,
"You must create an account before you can create an Microsoft Teams Tenant."
"You must create an account before you can create an Microsoft Teams Tenant.",
);
const handleSubmit = (e: React.FormEvent) => {
@@ -63,7 +63,7 @@ export const MsTeamsTenantForm = ({
? msTeamsTenants.filter(
(a) =>
a.ms_teams_tenant_sid !==
msTeamsTenant.data!.ms_teams_tenant_sid
msTeamsTenant.data!.ms_teams_tenant_sid,
)
: msTeamsTenants;
@@ -120,7 +120,12 @@ export const MsTeamsTenantForm = ({
return (
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!msTeamsTenant?.data && msTeamsTenant?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -151,7 +156,7 @@ export const MsTeamsTenantForm = ({
applications={
applications
? applications.filter(
(application) => application.account_sid === accountSid
(application) => application.account_sid === accountSid,
)
: []
}

View File

@@ -32,10 +32,10 @@ import type { ACLGetIMessage } from "src/utils/with-access-control";
export const MSTeamsTenants = () => {
const [msTeamsTenant, setMsTeamsTenant] = useState<MSTeamsTenant | null>(
null
null,
);
const [msTeamsTenants, refetch] = useApiData<MSTeamsTenant[]>(
"MicrosoftTeamsTenants"
"MicrosoftTeamsTenants",
);
const [accounts] = useServiceProviderData<Account[]>("Accounts");
const [applications] = useServiceProviderData<Application[]>("Applications");
@@ -45,14 +45,14 @@ export const MSTeamsTenants = () => {
const msTeamsTenantsFiltered = useMemo(() => {
return msTeamsTenants
? msTeamsTenants.filter(
(mst) => !accountSid || mst.account_sid === accountSid
(mst) => !accountSid || mst.account_sid === accountSid,
)
: [];
}, [accountSid, msTeamsTenants]);
const filteredMsTeamsTenants = useFilteredResults<MSTeamsTenant>(
filter,
msTeamsTenantsFiltered
msTeamsTenantsFiltered,
);
const handleDelete = () => {
@@ -65,7 +65,7 @@ export const MSTeamsTenants = () => {
<>
Deleted Microsoft Teams Tenant{" "}
<strong>{msTeamsTenant.tenant_fqdn}</strong>
</>
</>,
);
})
.catch((error) => {
@@ -89,7 +89,7 @@ export const MSTeamsTenants = () => {
</Link>
)}
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter ms teams tenants"
filter={[filter, setFilter]}
@@ -131,7 +131,8 @@ export const MSTeamsTenants = () => {
{
accounts?.find(
(acct) =>
acct.account_sid === msTeamsTenant.account_sid
acct.account_sid ===
msTeamsTenant.account_sid,
)?.name
}
</span>
@@ -148,7 +149,7 @@ export const MSTeamsTenants = () => {
{applications?.find(
(app) =>
app.application_sid ===
msTeamsTenant.application_sid
msTeamsTenant.application_sid,
)?.name || "None"}
</span>
</div>
@@ -217,5 +218,5 @@ const getAclIMessage: ACLGetIMessage = (currentServiceProvider) => {
export default withAccessControl(
"hasMSTeamsFqdn",
getAclIMessage
getAclIMessage,
)(MSTeamsTenants);

View File

@@ -11,7 +11,7 @@ import type { PhoneNumber } from "src/api/types";
export const EditPhoneNumber = () => {
const params = useParams();
const [data, refetch, error] = useApiData<PhoneNumber>(
`PhoneNumbers/${params.phone_number_sid}`
`PhoneNumbers/${params.phone_number_sid}`,
);
useEffect(() => {

View File

@@ -51,13 +51,13 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
useRedirect<Account>(
accounts,
ROUTE_INTERNAL_ACCOUNTS,
"You must create an account before you can create a phone number."
"You must create an account before you can create a phone number.",
);
useRedirect<Carrier>(
carriers,
ROUTE_INTERNAL_CARRIERS,
"You must create a SIP trunk before you can create a phone number."
"You must create a SIP trunk before you can create a phone number.",
);
const handleSubmit = (e: React.FormEvent) => {
@@ -69,7 +69,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
const filtered =
phoneNumber && phoneNumber.data
? phoneNumbers.filter(
(a) => a.phone_number_sid !== phoneNumber.data!.phone_number_sid
(a) => a.phone_number_sid !== phoneNumber.data!.phone_number_sid,
)
: phoneNumbers;
@@ -90,7 +90,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
phoneNumber.refetch();
toastSuccess("Phone number updated successfully");
navigate(
`${ROUTE_INTERNAL_PHONE_NUMBERS}/${phoneNumber.data?.phone_number_sid}/edit`
`${ROUTE_INTERNAL_PHONE_NUMBERS}/${phoneNumber.data?.phone_number_sid}/edit`,
);
})
.catch((error) => {
@@ -141,7 +141,12 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
return (
<>
<Section slim>
<form className="form form--internal" onSubmit={handleSubmit}>
<form
className={`form form--internal ${
!phoneNumber?.data && phoneNumber?.refetch ? "form--blur" : ""
}`}
onSubmit={handleSubmit}
>
<fieldset>
<MS>{MSG_REQUIRED_FIELDS}</MS>
</fieldset>
@@ -196,7 +201,7 @@ export const PhoneNumberForm = ({ phoneNumber }: PhoneNumberFormProps) => {
applications={
applications
? applications.filter(
(application) => application.account_sid === accountSid
(application) => application.account_sid === accountSid,
)
: []
}

View File

@@ -56,14 +56,14 @@ export const PhoneNumbers = () => {
setAccountSid(getAccountFilter());
return phoneNumbers
? phoneNumbers.filter(
(phn) => !accountSid || phn.account_sid === accountSid
(phn) => !accountSid || phn.account_sid === accountSid,
)
: [];
}, [accountSid, phoneNumbers]);
const filteredPhoneNumbers = useFilteredResults<PhoneNumber>(
filter,
phoneNumbersFiltered
phoneNumbersFiltered,
);
const handleMassEdit = () => {
@@ -74,7 +74,7 @@ export const PhoneNumbers = () => {
};
return putPhoneNumber(phoneNumber.phone_number_sid, payload);
})
}),
)
.then(() => {
refetch();
@@ -98,7 +98,7 @@ export const PhoneNumbers = () => {
toastSuccess(
<>
Deleted phone number <strong>{phoneNumber.number}</strong>
</>
</>,
);
})
.catch((error) => {
@@ -129,7 +129,7 @@ export const PhoneNumbers = () => {
</Link>
)}
</section>
<section className="filters filters--spaced">
<section className="filters filters--multi">
<SearchFilter
placeholder="Filter phone numbers"
filter={[filter, setFilter]}
@@ -177,7 +177,7 @@ export const PhoneNumbers = () => {
application={[applicationSid, setApplicationSid]}
applications={applications?.filter(
(application) =>
application.account_sid === accountSid
application.account_sid === accountSid,
)}
defaultOption="None"
/>
@@ -224,7 +224,7 @@ export const PhoneNumbers = () => {
selectedPhoneNumbers.find(
(phone) =>
phone.phone_number_sid ===
phoneNumber.phone_number_sid
phoneNumber.phone_number_sid,
)
? true
: false
@@ -240,8 +240,8 @@ export const PhoneNumbers = () => {
curr.filter(
(phone) =>
phone.phone_number_sid !==
phoneNumber.phone_number_sid
)
phoneNumber.phone_number_sid,
),
);
}
}}
@@ -270,7 +270,8 @@ export const PhoneNumbers = () => {
{
accounts?.find(
(acct) =>
acct.account_sid === phoneNumber.account_sid
acct.account_sid ===
phoneNumber.account_sid,
)?.name
}
</span>
@@ -287,7 +288,7 @@ export const PhoneNumbers = () => {
{applications?.find(
(app) =>
app.application_sid ===
phoneNumber.application_sid
phoneNumber.application_sid,
)?.name || "None"}
</span>
</div>

View File

@@ -15,7 +15,7 @@ export const CallDetail = ({ call }: CallDetailProps) => {
<div>{key}:</div>
<div>
{call[key as keyof typeof call]
? call[key as keyof typeof call].toString()
? String(call[key as keyof typeof call])
: "null"}
</div>
</React.Fragment>

View File

@@ -2,8 +2,8 @@ import React, { useEffect, useState } from "react";
import { Bar } from "./jaeger/bar";
import { JaegerGroup, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
import { getJaegerTrace } from "src/api";
import { toastError } from "src/store";
import { RecentCall } from "src/api/types";
import { getSpansFromJaegerRoot } from "./utils";
function useWindowSize() {
const [windowSize, setWindowSize] = useState({
@@ -34,30 +34,6 @@ export const CallTracing = ({ call }: CallTracingProps) => {
const [jaegerGroup, setJaegerGroup] = useState<JaegerGroup>();
const windowSize = useWindowSize();
const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
setJaegerRoot(trace);
const spans: JaegerSpan[] = [];
trace.resourceSpans.forEach((resourceSpan) => {
resourceSpan.instrumentationLibrarySpans.forEach(
(instrumentationLibrarySpan) => {
instrumentationLibrarySpan.spans.forEach((value) => {
const attrs = value.attributes.filter(
(attr) =>
!(
attr.key.startsWith("telemetry") ||
attr.key.startsWith("internal")
)
);
value.attributes = attrs;
spans.push(value);
});
}
);
});
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
return spans;
};
const getGroupsByParent = (spanId: string, groups: JaegerGroup[]) => {
groups.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
return groups.filter((value) => value.parentSpanId === spanId);
@@ -87,6 +63,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
};
const buildSpans = (root: JaegerRoot) => {
setJaegerRoot(root);
const spans = getSpansFromJaegerRoot(root);
const rootSpan = getRootSpan(spans);
if (rootSpan) {
@@ -121,7 +98,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
rootGroup.children = buildChildren(
rootGroup.level + 1,
rootGroup,
groups
groups,
);
setJaegerGroup(rootGroup);
}
@@ -131,7 +108,7 @@ export const CallTracing = ({ call }: CallTracingProps) => {
const buildChildren = (
level: number,
rootGroup: JaegerGroup,
groups: JaegerGroup[]
groups: JaegerGroup[],
): JaegerGroup[] => {
return getGroupsByParent(rootGroup.spanId, groups).map((group) => {
group.level = level;
@@ -142,15 +119,11 @@ export const CallTracing = ({ call }: CallTracingProps) => {
useEffect(() => {
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
getJaegerTrace(call.account_sid, call.trace_id)
.then(({ json }) => {
if (json) {
buildSpans(json);
}
})
.catch((error) => {
toastError(error.msg);
});
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
if (json) {
buildSpans(json);
}
});
}
}, []);

View File

@@ -2,13 +2,15 @@ import React, { useState } from "react";
import dayjs from "dayjs";
import { Icons } from "src/components";
import { formatPhoneNumber } from "src/utils";
import { formatPhoneNumber, hasValue } from "src/utils";
import { PcapButton } from "./pcap";
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 { Player } from "./player";
import "./styles.scss";
type DetailsItemProps = {
call: RecentCall;
@@ -18,6 +20,12 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState("");
const transformRecentCall = (call: RecentCall): RecentCall => {
const newCall = { ...call };
delete newCall.recording_url;
return newCall;
};
return (
<div className="item">
<details
@@ -32,7 +40,7 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
<div className="item__info">
<div className="item__title">
<strong>
{dayjs(call.attempted_at).format("YYYY MM.DD hh:mm a")}
{dayjs(call.attempted_at).format("YYYY MM.DD hh:mm:ss a")}
</strong>
<span className="i txt--dark">
{call.direction === "inbound" ? (
@@ -61,27 +69,26 @@ export const DetailsItem = ({ call }: DetailsItemProps) => {
</summary>
{call.trace_id === "00000000000000000000000000000000" ||
DISABLE_JAEGER_TRACING ? (
<CallDetail call={call} />
<CallDetail call={transformRecentCall(call)} />
) : (
<Tabs active={[activeTab, setActiveTab]}>
<Tab id="details" label="Details">
<CallDetail call={call} />
<CallDetail call={transformRecentCall(call)} />
</Tab>
<Tab id="tracing" label="Tracing">
<CallTracing call={call} />
{open && <CallTracing call={call} />}
</Tab>
</Tabs>
)}
{open && (
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "300px",
}}
>
<PcapButton call={call} />
</div>
<>
<div className="footer">
{hasValue(call.recording_url) && <Player call={call} />}
<div className="footer__buttons">
<PcapButton call={call} />
</div>
</div>
</>
)}
</details>
</div>

View File

@@ -15,6 +15,7 @@ import {
Spinner,
Pagination,
SelectFilter,
SearchFilter,
} from "src/components";
import { hasLength, hasValue } from "src/utils";
import { DetailsItem } from "./details";
@@ -47,6 +48,7 @@ export const RecentCalls = () => {
const [dateFilter, setDateFilter] = useState("today");
const [directionFilter, setDirectionFilter] = useState("io");
const [statusFilter, setStatusFilter] = useState("all");
const [filter, setFilter] = useState("");
const [pageNumber, setPageNumber] = useState(1);
const [perPageFilter, setPerPageFilter] = useState("25");
@@ -64,6 +66,7 @@ export const RecentCalls = () => {
: { days: Number(dateFilter) }),
...(statusFilter !== "all" && { answered: statusFilter }),
...(directionFilter !== "io" && { direction: directionFilter }),
...(filter && { filter }),
};
getRecentCalls(accountSid, payload)
@@ -94,7 +97,14 @@ export const RecentCalls = () => {
if (accountSid) {
handleFilterChange();
}
}, [accountSid, pageNumber, dateFilter, directionFilter, statusFilter]);
}, [
accountSid,
pageNumber,
dateFilter,
directionFilter,
statusFilter,
filter,
]);
/** Reset page number when filters change */
useEffect(() => {
@@ -136,6 +146,11 @@ export const RecentCalls = () => {
filter={[statusFilter, setStatusFilter]}
options={statusSelection}
/>
<SearchFilter
placeholder="Filter"
filter={[filter, setFilter]}
delay={1000}
/>
</section>
<Section {...(hasLength(calls) && { slim: true })}>
<div className="list">

View File

@@ -3,6 +3,7 @@ import { JaegerGroup, JaegerValue } from "src/api/jaeger-types";
import dayjs from "dayjs";
import "./styles.scss";
import { formattedDuration } from "./utils";
import { getSpansByNameRegex } from "../utils";
type JaegerDetailProps = {
group: JaegerGroup;
@@ -44,14 +45,17 @@ export const JaegerDetail = ({ group }: JaegerDetailProps) => {
.format("DD/MM/YY HH:mm:ss.SSS")}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
{!(group.name && group.name.startsWith("dtmf:")) && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{formattedDuration(group.durationMs)}
</div>
</div>
<div className="spanDetailsWrapper__details_body">
{formattedDuration(group.durationMs)}
</div>
</div>
)}
{group.attributes.map((attribute) => (
<div key={attribute.key} className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
@@ -62,6 +66,37 @@ export const JaegerDetail = ({ group }: JaegerDetailProps) => {
</div>
</div>
))}
{/* TTS Streaming Attrs */}
{group.children.length &&
getSpansByNameRegex(group.children, /tts-generation/)?.map((span) => {
return span.attributes.map((attribute) => {
if (
![
"tts.vendor",
"tts.language",
"tts.voice",
"tts.cached",
"engine",
"voice",
].includes(attribute.key)
) {
return (
<div
key={attribute.key}
className="spanDetailsWrapper__details"
>
<div className="spanDetailsWrapper__details_header">
<strong>{attribute.key}</strong>:
</div>
<div className="spanDetailsWrapper__details_body">
{extractSpanGroupValue(attribute.value)}
</div>
</div>
);
}
});
})}
</div>
</div>
);

View File

@@ -3,28 +3,30 @@ import React, { useEffect, useState } from "react";
import { getPcap } from "src/api";
import { toastError } from "src/store";
import type { Pcap, RecentCall } from "src/api/types";
import type { DownloadedBlob, RecentCall } from "src/api/types";
type PcapButtonProps = {
call: RecentCall;
};
export const PcapButton = ({ call }: PcapButtonProps) => {
const [pcap, setPcap] = useState<Pcap>();
const [pcap, setPcap] = useState<DownloadedBlob | null>(null);
useEffect(() => {
getPcap(call.account_sid, call.sip_callid)
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call.sip_callid}.pcap`,
});
}
})
.catch((error) => {
toastError(error.msg);
});
if (!pcap) {
getPcap(call.account_sid, call.sip_callid, "invite")
.then(({ blob }) => {
if (blob) {
setPcap({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call.sip_callid}.pcap`,
});
}
})
.catch((error) => {
toastError(error.msg);
});
}
}, []);
if (pcap) {

View File

@@ -0,0 +1,797 @@
import React from "react";
import WaveSurfer from "wavesurfer.js";
import { useEffect, useRef, useState } from "react";
import { Icon, P } from "@jambonz/ui-kit";
import { Icons, Modal, ModalClose } from "src/components";
import { deleteRecord, getBlob, getJaegerTrace } from "src/api";
import { DownloadedBlob, RecentCall } from "src/api/types";
import RegionsPlugin, { Region } from "wavesurfer.js/dist/plugins/regions";
import TimelinePlugin from "wavesurfer.js/dist/plugins/timeline";
import { API_BASE_URL } from "src/api/constants";
import {
JaegerRoot,
JaegerSpan,
WaveSurferDtmfResult,
WaveSurferGatherSpeechVerbHookLatencyResult,
WaveSurferSttResult,
WaveSurferTtsLatencyResult,
} from "src/api/jaeger-types";
import {
getSpanAttributeByName,
getSpansByNameRegex,
getSpansFromJaegerRoot,
} from "./utils";
import { toastError, toastSuccess } from "src/store";
type PlayerProps = {
call: RecentCall;
};
export const Player = ({ call }: PlayerProps) => {
const { recording_url, call_sid } = call;
const url =
recording_url && recording_url.startsWith("http://")
? recording_url
: `${API_BASE_URL}${recording_url}`;
const JUMP_DURATION = 15; //seconds
const [isPlaying, setIsPlaying] = useState(false);
const [isReady, setIsReady] = useState(false);
const [playBackTime, setPlayBackTime] = useState("");
const [jaegerRoot, setJeagerRoot] = useState<JaegerRoot>();
const [waveSurferRegionData, setWaveSurferRegionData] =
useState<WaveSurferSttResult | null>();
const [waveSurferDtmfData, setWaveSurferDtmfData] =
useState<WaveSurferDtmfResult | null>();
const [waveSurferTtsLatencyData, setWaveSurferTtsLatencyData] =
useState<WaveSurferTtsLatencyResult | null>();
const [
waveSurferGatherSpeechVerbHookLatencyData,
setWaveSurferGatherSpeechVerbHookLatencyData,
] = useState<WaveSurferGatherSpeechVerbHookLatencyResult | null>();
const [regionChecked, setRegionChecked] = useState(false);
const wavesurferId = `wavesurfer--${call_sid}`;
const waveSurferRef = useRef<WaveSurfer | null>(null);
const waveSurferRegionsPluginRef = useRef<RegionsPlugin | null>();
const [record, setRecord] = useState<DownloadedBlob | null>(null);
const [deleteRecordUrl, setDeleteRecordUrl] = useState("");
const drawDtmfRegionForSpan = (s: JaegerSpan, startPoint: JaegerSpan) => {
if (waveSurferRegionsPluginRef.current) {
waveSurferRef.current;
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (!r) {
const [dtmfValue] = getSpanAttributeByName(s.attributes, "dtmf");
const [durationValue] = getSpanAttributeByName(
s.attributes,
"duration",
);
if (dtmfValue && durationValue) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) /
1_000_000_000;
const duration =
Number(durationValue.value.stringValue.replace("ms", "")) / 1_000;
// as duration of DTMF is short, cannot be shown in wavesurfer,
// adjust region width here.
const delta = duration <= 0.1 ? 0.1 : duration;
const end = start + delta;
const region = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(138, 43, 226, 0.15)",
drag: false,
resize: false,
});
changeRegionMouseStyle(region);
const att: WaveSurferDtmfResult = {
dtmf: dtmfValue.value.stringValue,
duration: durationValue.value.stringValue,
};
region.on("click", () => {
setWaveSurferDtmfData(att);
});
}
}
}
};
const changeRegionMouseStyle = (region: Region, channel = 0) => {
region.element.style.display = regionChecked ? "" : "none";
region.element.style.height = "49%";
region.element.style.top = channel === 0 ? "0" : "51%";
region.element.addEventListener("mouseenter", () => {
region.element.style.cursor = "pointer"; // Change to your desired cursor style
});
region.element.addEventListener("mouseleave", () => {
region.element.style.cursor = "default";
});
};
const PEAKS_WINDOW = 5; // require 30 ms of speech energy over threshold to trigger
const PEAK_THRESHOLD = 0.03;
const getSilenceStartTime = (
start: number,
end: number,
channel: number,
): number => {
if (waveSurferRef.current) {
const duration = waveSurferRef.current.getDecodedData()?.duration;
if (duration && duration > 0) {
const maxLength = Math.round(duration * 8000) / 10; // evaluate speech energy every 10 ms
const peaks = waveSurferRef.current.exportPeaks({ maxLength });
if (peaks && peaks.length > channel) {
if (duration && duration > 0) {
const data = peaks[channel];
const startPeak = Math.ceil((start * data.length) / duration);
const endPeak = Math.ceil((end * data.length) / duration);
let count = 0;
for (let i = endPeak; i > startPeak; i--)
if (Math.abs(data[i]) > PEAK_THRESHOLD) {
count++;
if (count === PEAKS_WINDOW) {
return (
((i + PEAKS_WINDOW) * duration) / data.length + 0.02 // add 20 ms adjustment
);
}
} else {
count = 0;
}
}
}
}
}
return -1;
};
const drawSttRegionForSpan = (
s: JaegerSpan,
startPoint: JaegerSpan,
channel = 0,
) => {
if (waveSurferRegionsPluginRef.current) {
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000 +
0.05; // add magic 0.01 second in each region start time to isolate 2 near regions
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const endSpeechTime = getSilenceStartTime(start, end, channel);
const [sttResult] = getSpanAttributeByName(s.attributes, "stt.result");
let att: WaveSurferSttResult;
if (sttResult) {
const data = JSON.parse(sttResult.value.stringValue);
att = {
vendor: data.vendor.name,
transcript: data.alternatives[0].transcript,
confidence: data.alternatives[0].confidence,
language_code: data.language_code,
...(endSpeechTime > 0 && { latency: end - endSpeechTime }),
};
const [sttResolve] = getSpanAttributeByName(
s.attributes,
"stt.resolve",
);
if (
endSpeechTime > 0 &&
sttResolve &&
sttResolve.value.stringValue === "speech"
) {
const latencyRegion = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId + "latency",
start: endSpeechTime,
end,
color: "rgba(255, 255, 0, 0.55)",
drag: false,
resize: false,
content: `${(end - endSpeechTime).toFixed(2)}s`,
});
changeRegionMouseStyle(latencyRegion, channel);
}
} else {
const [sttResolve] = getSpanAttributeByName(
s.attributes,
"stt.resolve",
);
if (sttResolve && sttResolve.value.stringValue === "timeout") {
att = {
vendor: "",
transcript: "None (speech session timeout)",
confidence: 0,
language_code: "",
};
} else {
att = {
vendor: "",
transcript:
"None (call disconnected or speech session terminated)",
confidence: 0,
language_code: "",
};
}
}
const region = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start,
end,
color: "rgba(255, 0, 0, 0.15)",
drag: false,
resize: false,
});
changeRegionMouseStyle(region, channel);
region.on("click", () => {
setWaveSurferRegionData(att);
});
}
}
};
const drawTtsLatencyRegion = (s: JaegerSpan, startPoint: JaegerSpan) => {
if (waveSurferRegionsPluginRef.current) {
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
let end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const [ttsVendor] = getSpanAttributeByName(s.attributes, "tts.vendor");
const [ttsCache] = getSpanAttributeByName(s.attributes, "tts.cached");
const [streamLatency] = getSpanAttributeByName(
s.attributes,
"time_to_first_byte_ms",
);
if (streamLatency && streamLatency.value.stringValue) {
end = start + Number(streamLatency.value.stringValue) / 1_000;
}
if (ttsVendor && ttsCache && !Boolean(ttsCache.value.boolValue)) {
const latencyRegion = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start: start,
end,
color: "rgba(255, 155, 0, 0.55)",
drag: false,
resize: false,
content: createMultiLineTextElement(`${(end - start).toFixed(2)}s`),
});
changeRegionMouseStyle(latencyRegion, 1);
latencyRegion.on("click", () => {
setWaveSurferTtsLatencyData({
vendor: ttsVendor.value.stringValue,
latency: `${(end - start).toFixed(2)}s`,
isCached: String(ttsCache.value.boolValue),
});
});
}
}
}
};
const drawVerbHookDelayRegion = (s: JaegerSpan, startPoint: JaegerSpan) => {
if (waveSurferRegionsPluginRef.current) {
const r = waveSurferRegionsPluginRef.current
.getRegions()
.find((r) => r.id === s.spanId);
if (!r) {
const start =
(s.startTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const end =
(s.endTimeUnixNano - startPoint.startTimeUnixNano) / 1_000_000_000;
const tmpEnd = end - start < 0.05 ? start + 0.05 : end;
const latencyRegion = waveSurferRegionsPluginRef.current.addRegion({
id: s.spanId,
start: start,
end: tmpEnd,
color: "rgba(255, 3, 180, 0.55)",
drag: false,
resize: false,
content: createMultiLineTextElement(`${(end - start).toFixed(2)}s`),
});
const [statusCode] = getSpanAttributeByName(
s.attributes,
"http.statusCode",
);
changeRegionMouseStyle(latencyRegion, 0);
latencyRegion.on("click", () => {
setWaveSurferGatherSpeechVerbHookLatencyData({
statusCode: statusCode ? Number(statusCode.value.doubleValue) : 404,
latency: `${(end - start).toFixed(2)}s`,
});
});
}
}
};
function createMultiLineTextElement(text: string) {
const div = document.createElement("div");
div.style.paddingLeft = "10px";
div.style.paddingTop = "15px";
div.appendChild(document.createElement("br"));
div.appendChild(document.createTextNode(text));
return div;
}
const buildWavesurferRegion = () => {
if (jaegerRoot) {
const spans = getSpansFromJaegerRoot(jaegerRoot);
const start = getSpansByNameRegex(spans, /background-record:listen/);
const startPoint = start ? start[0] : null;
// there should be only one startPoint for background listen
if (startPoint) {
const gatherSpans = getSpansByNameRegex(spans, /:gather{/);
gatherSpans.forEach((s) => {
drawSttRegionForSpan(s, startPoint);
});
// Trasscription
const transcribeSpans = getSpansByNameRegex(spans, /stt-listen:/);
transcribeSpans.forEach((cs) => {
// Channel start from 0
const channel = Number(cs.name.split(":")[1]);
drawSttRegionForSpan(
cs,
startPoint,
channel > 0 ? channel - 1 : channel,
);
});
// DTMF
const dtmfSpans = getSpansByNameRegex(spans, /dtmf:/);
dtmfSpans.forEach((ds) => {
drawDtmfRegionForSpan(ds, startPoint);
});
// TTS delay
const ttsSpans = getSpansByNameRegex(spans, /tts-generation/);
ttsSpans.forEach((tts) => {
drawTtsLatencyRegion(tts, startPoint);
});
// Gather verb hook delay
const verbHookSpans = getSpansByNameRegex(spans, /verb:hook/);
verbHookSpans
.filter((s) => {
const [httpBody] = getSpanAttributeByName(
s.attributes,
"http.body",
);
return (
httpBody.value.stringValue.includes(
'"reason":"speechDetected"',
) ||
httpBody.value.stringValue.includes('"reason":"dtmfDetected"')
);
})
.forEach((s) => {
drawVerbHookDelayRegion(s, startPoint);
});
}
}
};
const handleDeleteRecordSubmit = () => {
if (deleteRecordUrl) {
deleteRecord(deleteRecordUrl)
.then(() => {
setDeleteRecordUrl("");
toastSuccess("Successfully deleted record");
})
.catch((error) => {
toastError(error.msg);
});
}
};
useEffect(() => {
buildWavesurferRegion();
}, [jaegerRoot, isReady]);
useEffect(() => {
getBlob(url).then(({ blob, headers }) => {
if (blob) {
const ext = headers.get("Content-Type") === "audio/wav" ? "wav" : "mp3";
setRecord({
data_url: URL.createObjectURL(blob),
file_name: `callid-${call_sid}.${ext}`,
});
}
});
if (call.trace_id && call.trace_id != "00000000000000000000000000000000") {
getJaegerTrace(call.account_sid, call.trace_id).then(({ json }) => {
if (json) {
setJeagerRoot(json);
}
});
}
}, []);
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds
.toString()
.padStart(2, "0")}`;
}
useEffect(() => {
if (waveSurferRef.current !== null || !record) return;
waveSurferRegionsPluginRef.current = RegionsPlugin.create();
waveSurferRef.current = WaveSurfer.create({
container: `#${wavesurferId}`,
waveColor: "#da1c5c",
progressColor: "grey",
height: 50,
cursorWidth: 1,
cursorColor: "lightgray",
normalize: true,
autoScroll: true,
splitChannels: [],
minPxPerSec: 100,
plugins: [
waveSurferRegionsPluginRef.current,
TimelinePlugin.create({
timeInterval: 0.2,
primaryLabelInterval: 5,
secondaryLabelInterval: 1,
style: {
fontSize: "15px",
color: "#000000",
fontWeight: "bold",
},
}),
],
});
waveSurferRef.current.load(record?.data_url);
// All event should be after load
waveSurferRef.current.on("finish", () => {
setIsPlaying(false);
});
waveSurferRef.current.on("play", () => {
setIsPlaying(true);
});
waveSurferRef.current.on("pause", () => {
setIsPlaying(false);
});
waveSurferRef.current.on("ready", () => {
setIsReady(true);
setPlayBackTime(formatTime(waveSurferRef.current?.getDuration() || 0));
});
waveSurferRef.current.on("audioprocess", () => {
setPlayBackTime(formatTime(waveSurferRef.current?.getCurrentTime() || 0));
});
}, [record]);
const togglePlayback = () => {
if (waveSurferRef.current) {
if (!isPlaying) {
waveSurferRef.current.play();
} else {
waveSurferRef.current.pause();
}
}
};
const setPlaybackJump = (delta: number) => {
if (waveSurferRef.current) {
const idx = waveSurferRef.current.getCurrentTime() + delta;
const value =
idx <= 0
? 0
: idx >= waveSurferRef.current.getDuration()
? waveSurferRef.current.getDuration() - 1
: idx;
waveSurferRef.current.setTime(value);
setPlayBackTime(formatTime(value));
}
};
if (!record) return null;
return (
<>
<div className="media-container">
<div id={wavesurferId} />
<div className="media-container__center">
<strong>{playBackTime}</strong>
</div>
<div className="controll-btn-container">
<div className="controll-btn-container__placeholder"></div>
<div className="controll-btn-container__center">
<button
className="btnty"
type="button"
onClick={() => {
setPlaybackJump(-JUMP_DURATION);
}}
title="Jump left"
disabled={!isReady}
>
<Icon>
<Icons.ChevronsLeft />
</Icon>
</button>
<button
className="btnty"
type="button"
onClick={togglePlayback}
title="play/pause"
disabled={!isReady}
>
<Icon>{isPlaying ? <Icons.Pause /> : <Icons.Play />}</Icon>
</button>
<button
className="btnty"
type="button"
onClick={() => {
setPlaybackJump(JUMP_DURATION);
}}
title="Jump right"
disabled={!isReady}
>
<Icon>
<Icons.ChevronsRight />
</Icon>
</button>
</div>
<div className="controll-btn-container__right">
<a
href={record.data_url}
download={record.file_name}
className="btnty"
title="Download record file"
>
<Icon>
<Icons.Download />
</Icon>
</a>
<button
type="button"
onClick={() => {
setDeleteRecordUrl(url || "");
}}
title="Delete record file"
>
<Icon>
<Icons.Trash2 />
</Icon>
</button>
</div>
</div>
<label htmlFor="is_active" className="chk">
<input
id={`is_active${call.call_sid}`}
name="is_active"
type="checkbox"
checked={regionChecked}
onChange={(e) => {
setRegionChecked(e.target.checked);
if (waveSurferRegionsPluginRef.current) {
const regionsList =
waveSurferRegionsPluginRef.current.getRegions();
for (const [, region] of Object.entries(regionsList)) {
region.element.style.display = e.target.checked ? "" : "none";
}
}
}}
/>
<div>Show latencies</div>
</label>
</div>
{waveSurferRegionData && (
<ModalClose handleClose={() => setWaveSurferRegionData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Speech to text result</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
{waveSurferRegionData.vendor && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Vendor:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.vendor}
</div>
</div>
)}
{waveSurferRegionData.confidence !== 0 && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Confidence:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.confidence}
</div>
</div>
)}
{waveSurferRegionData.language_code && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Language code:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.language_code}
</div>
</div>
)}
{waveSurferRegionData.transcript && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Transcript:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.transcript}
</div>
</div>
)}
{waveSurferRegionData.latency && (
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Latency:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferRegionData.latency.toFixed(2)} seconds
</div>
</div>
)}
</div>
</div>
</ModalClose>
)}
{waveSurferDtmfData && (
<ModalClose handleClose={() => setWaveSurferDtmfData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Dtmf result</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Dtmf:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferDtmfData.dtmf}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Duration:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferDtmfData.duration}
</div>
</div>
</div>
</div>
</ModalClose>
)}
{waveSurferTtsLatencyData && (
<ModalClose handleClose={() => setWaveSurferTtsLatencyData(null)}>
<div className="spanDetailsWrapper__header">
<P>
<strong>Tts Latency</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Vendor:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferTtsLatencyData.vendor}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Latency:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferTtsLatencyData.latency}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>From Cache:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferTtsLatencyData.isCached}
</div>
</div>
</div>
</div>
</ModalClose>
)}
{waveSurferGatherSpeechVerbHookLatencyData && (
<ModalClose
handleClose={() => setWaveSurferGatherSpeechVerbHookLatencyData(null)}
>
<div className="spanDetailsWrapper__header">
<P>
<strong>Application Response Latency</strong>
</P>
</div>
<div className="spanDetailsWrapper">
<div className="spanDetailsWrapper__detailsWrapper">
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Status Code:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferGatherSpeechVerbHookLatencyData.statusCode}
</div>
</div>
<div className="spanDetailsWrapper__details">
<div className="spanDetailsWrapper__details_header">
<strong>Latency:</strong>
</div>
<div className="spanDetailsWrapper__details_body">
{waveSurferGatherSpeechVerbHookLatencyData.latency}
</div>
</div>
</div>
</div>
</ModalClose>
)}
{deleteRecordUrl && (
<Modal
handleCancel={() => setDeleteRecordUrl("")}
handleSubmit={handleDeleteRecordSubmit}
>
<P>
Are you sure you want to delete the record for call{" "}
<strong>{call_sid}</strong>?
</P>
</Modal>
)}
</>
);
};

View File

@@ -0,0 +1,87 @@
@use "src/styles/vars";
@use "src/styles/mixins";
@use "@jambonz/ui-kit/src/styles/vars" as ui-vars;
.wavesuffer {
position: relative;
}
.footer {
display: grid;
width: 100%;
margin-top: ui-vars.$px02;
&__buttons {
padding: ui-vars.$px02;
display: inline-flex;
flex-wrap: wrap;
gap: ui-vars.$px02;
}
}
.controll-btn-container {
display: flex;
justify-content: space-between;
position: relative;
padding: 13px;
&__center {
position: absolute;
left: 50%;
transform: translateX(-50%);
button:not(:last-child) {
margin-right: ui-vars.$px01;
}
button {
background-color: transparent;
width: auto;
height: auto;
border: 0;
padding: 0;
}
.ico {
color: ui-vars.$white;
@include mixins.icosize();
}
}
&__right {
a:not(:last-child) {
margin-right: ui-vars.$px01;
}
button {
background-color: transparent;
width: auto;
height: auto;
border: 0;
padding: 0;
}
.ico {
color: ui-vars.$white;
@include mixins.icosize();
}
}
&__placeholder {
flex: 1;
}
}
.media-container {
overflow-x: auto;
border: 1px solid black;
border-radius: ui-vars.$px01;
padding: 13px;
position: relative;
&__center {
display: flex;
align-items: center;
justify-content: center;
grid-gap: ui-vars.$px01;
margin-top: ui-vars.$px01;
}
}

View File

@@ -0,0 +1,46 @@
import { JaegerAttribute, JaegerRoot, JaegerSpan } from "src/api/jaeger-types";
export const getSpansFromJaegerRoot = (trace: JaegerRoot) => {
const spans: JaegerSpan[] = [];
trace.resourceSpans.forEach((resourceSpan) => {
resourceSpan.instrumentationLibrarySpans.forEach(
(instrumentationLibrarySpan) => {
instrumentationLibrarySpan.spans.forEach((value) => {
const attrs = value.attributes.filter(
(attr) =>
!(
attr.key.startsWith("telemetry") ||
attr.key.startsWith("internal")
),
);
value.attributes = attrs;
spans.push(value);
});
},
);
});
spans.sort((a, b) => a.startTimeUnixNano - b.startTimeUnixNano);
return spans;
};
export const getSpansByName = (
spans: JaegerSpan[],
name: string,
): JaegerSpan[] => {
return spans.filter((s) => s.name === name);
};
export const getSpansByNameRegex = (
spans: JaegerSpan[],
pattern: RegExp,
): JaegerSpan[] => {
const matcher = new RegExp(pattern);
return spans.filter((s) => matcher.test(s.name));
};
export const getSpanAttributeByName = (
attr: JaegerAttribute[],
name: string,
): JaegerAttribute[] => {
return attr.filter((a) => a.key === name);
};

View File

@@ -1,22 +1,25 @@
import React, { useEffect, useState } from "react";
import { ButtonGroup, Button } from "@jambonz/ui-kit";
import { ButtonGroup, Button, MS, P } from "@jambonz/ui-kit";
import {
useApiData,
postPasswordSettings,
postSystemInformation,
deleteTtsCache,
} from "src/api";
import { PasswordSettings, SystemInformation } from "src/api/types";
import { PasswordSettings, SystemInformation, TtsCache } from "src/api/types";
import { toastError, toastSuccess } from "src/store";
import { Selector } from "src/components/forms";
import { hasValue } from "src/utils";
import { PASSWORD_LENGTHS_OPTIONS, PASSWORD_MIN } from "src/api/constants";
import { Modal } from "src/components";
export const AdminSettings = () => {
const [passwordSettings, passwordSettingsFetcher] =
useApiData<PasswordSettings>("PasswordSettings");
const [systemInformatin, systemInformationFetcher] =
const [systemInformation, systemInformationFetcher] =
useApiData<SystemInformation>("SystemInformation");
const [ttsCache, ttsCacheFetcher] = useApiData<TtsCache>("TtsCache");
// Min value is 8
const [minPasswordLength, setMinPasswordLength] = useState(PASSWORD_MIN);
const [requireDigit, setRequireDigit] = useState(false);
@@ -24,6 +27,19 @@ export const AdminSettings = () => {
const [domainName, setDomainName] = useState("");
const [sipDomainName, setSipDomainName] = useState("");
const [monitoringDomainName, setMonitoringDomainName] = useState("");
const [clearTtsCacheFlag, setClearTtsCacheFlag] = useState(false);
const handleClearCache = () => {
deleteTtsCache()
.then(() => {
ttsCacheFetcher();
setClearTtsCacheFlag(false);
toastSuccess("Tts Cache successfully cleaned");
})
.catch((error) => {
toastError(error.msg);
});
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -56,18 +72,18 @@ export const AdminSettings = () => {
if (hasValue(passwordSettings)) {
setRequireDigit(passwordSettings.require_digit > 0 ? true : false);
setRequireSpecialCharacter(
passwordSettings.require_special_character > 0 ? true : false
passwordSettings.require_special_character > 0 ? true : false,
);
if (passwordSettings.min_password_length) {
setMinPasswordLength(passwordSettings.min_password_length);
}
}
if (hasValue(systemInformatin)) {
setDomainName(systemInformatin.domain_name);
setSipDomainName(systemInformatin.sip_domain_name);
setMonitoringDomainName(systemInformatin.monitoring_domain_name);
if (hasValue(systemInformation)) {
setDomainName(systemInformation.domain_name);
setSipDomainName(systemInformation.sip_domain_name);
setMonitoringDomainName(systemInformation.monitoring_domain_name);
}
}, [passwordSettings, systemInformatin]);
}, [passwordSettings, systemInformation]);
return (
<>
@@ -132,6 +148,23 @@ export const AdminSettings = () => {
<div>Password require special character</div>
</label>
</fieldset>
<fieldset>
<ButtonGroup left>
<Button
onClick={(e: React.FormEvent) => {
e.preventDefault();
setClearTtsCacheFlag(true);
}}
small
disabled={!ttsCache || ttsCache.size === 0}
>
Clear TTS Cache
</Button>
</ButtonGroup>
<MS>{`There are ${
ttsCache ? ttsCache.size : 0
} cached TTS prompts`}</MS>
</fieldset>
<fieldset>
<ButtonGroup left>
<Button onClick={handleSubmit} small>
@@ -139,6 +172,14 @@ export const AdminSettings = () => {
</Button>
</ButtonGroup>
</fieldset>
{clearTtsCacheFlag && (
<Modal
handleSubmit={handleClearCache}
handleCancel={() => setClearTtsCacheFlag(false)}
>
<P>Are you sure you want to clean TTS cache?</P>
</Modal>
)}
</>
);
};

View File

@@ -27,7 +27,7 @@ export const Settings = ({ currentServiceProvider }: SettingsProps) => {
Scope.service_provider,
`${ROUTE_INTERNAL_ACCOUNTS}/${user?.account_sid}/edit`,
user,
"You do not have permissions to manage Settings"
"You do not have permissions to manage Settings",
);
return (

View File

@@ -56,13 +56,13 @@ export const ServiceProviderSettings = ({
return limit.quantity === ""
? deleteServiceProviderLimit(
currentServiceProvider.service_provider_sid,
limit.category
limit.category,
)
: postServiceProviderLimit(
currentServiceProvider.service_provider_sid,
limit
limit,
);
})
}),
)
.then(() => {
refetchLimits();
@@ -93,7 +93,7 @@ export const ServiceProviderSettings = ({
<>
Deleted service provider{" "}
<strong>{currentServiceProvider.name}</strong>
</>
</>,
);
removeActiveSP();
})
@@ -193,5 +193,5 @@ export const ServiceProviderSettings = ({
};
export default withSelectState(["serviceProviders", "currentServiceProvider"])(
ServiceProviderSettings
ServiceProviderSettings,
);

View File

@@ -19,7 +19,11 @@ export const DeleteSpeechService = ({
return (
<Modal handleCancel={handleCancel} handleSubmit={handleSubmit}>
<P>
Are you sure you want to delete the <strong>{credential.vendor}</strong>{" "}
Are you sure you want to delete the{" "}
<strong>
{credential.vendor}
{credential.label ? ` (${credential.label})` : ""}
</strong>{" "}
speech service?
</P>
</Modal>

View File

@@ -24,17 +24,17 @@ export const EditSpeechService = () => {
ROUTE_INTERNAL_SPEECH,
user,
"You do not have access to this resource",
data
data,
);
const getUrlForSpeech = () => {
if (user && user?.scope === USER_ACCOUNT) {
setUrl(
`Accounts/${user?.account_sid}/SpeechCredentials/${params.speech_credential_sid}`
`Accounts/${user?.account_sid}/SpeechCredentials/${params.speech_credential_sid}`,
);
} else {
setUrl(
`ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials/${params.speech_credential_sid}`
`ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials/${params.speech_credential_sid}`,
);
}
};

File diff suppressed because it is too large Load Diff

View File

@@ -49,35 +49,39 @@ export const SpeechServices = () => {
? credentials.filter((credential) =>
accountSid
? credential.account_sid === accountSid
: credential.account_sid === null
: credential.account_sid === null,
)
: [];
}, [accountSid, accounts, credentials]);
const filteredCredentials = useFilteredResults<SpeechCredential>(
filter,
credentialsFiltered
credentialsFiltered,
);
const handleDelete = () => {
if (credential && currentServiceProvider) {
if (isUserAccountScope(accountSid, user)) {
toastError(
"You do not have permissions to delete these Speech Credentials"
"You do not have permissions to delete these Speech Credentials",
);
return;
}
deleteSpeechService(
currentServiceProvider.service_provider_sid,
credential.speech_credential_sid
credential.speech_credential_sid,
)
.then(() => {
setCredential(null);
refetch();
toastSuccess(
<>
Deleted speech service <strong>{credential.vendor}</strong>
</>
Deleted speech service{" "}
<strong>
{credential.vendor}
{credential.label ? ` (${credential.label})` : ""}
</strong>{" "}
</>,
);
})
.catch((error) => {
@@ -93,7 +97,7 @@ export const SpeechServices = () => {
setApiUrl(`Accounts/${accountSid}/SpeechCredentials`);
} else {
setApiUrl(
`ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials`
`ServiceProviders/${currentServiceProvider?.service_provider_sid}/SpeechCredentials`,
);
}
}, [currentServiceProvider, accountSid]);
@@ -108,7 +112,7 @@ export const SpeechServices = () => {
</Icon>
</Link>
</section>
<section className="filters filters--ender">
<section className="filters filters--multi">
<ScopedAccess user={user} scope={Scope.service_provider}>
<AccountFilter
account={[accountSid, setAccountSid]}
@@ -145,7 +149,7 @@ export const SpeechServices = () => {
Vendor:{" "}
{credential.vendor.startsWith(VENDOR_CUSTOM)
? credential.vendor.substring(
VENDOR_CUSTOM.length + 1
VENDOR_CUSTOM.length + 1,
)
: credential.vendor}
</strong>
@@ -195,6 +199,14 @@ export const SpeechServices = () => {
<div>
<CredentialStatus cred={credential} />
</div>
{credential.label && (
<div>
<div className="i txt--teal">
<Icons.Tag />
<span>{credential.label}</span>
</div>
</div>
)}
</div>
</div>
<ScopedAccess

View File

@@ -37,8 +37,8 @@ export const CredentialStatus = ({
status === CRED_OK
? "teal"
: status === CRED_NOT_TESTED
? "jean"
: "jam"
? "jean"
: "jam"
}`}
title={status === CRED_NOT_TESTED ? notTestedTxt : reason}
>
@@ -61,11 +61,11 @@ export const CredentialStatus = ({
useEffect(() => {
if (user && user.scope === USER_ACCOUNT) {
setApiUrl(
`Accounts/${user.account_sid}/SpeechCredentials/${cred.speech_credential_sid}/test`
`Accounts/${user.account_sid}/SpeechCredentials/${cred.speech_credential_sid}/test`,
);
} else if (currentServiceProvider) {
setApiUrl(
`ServiceProviders/${currentServiceProvider.service_provider_sid}/SpeechCredentials/${cred.speech_credential_sid}/test`
`ServiceProviders/${currentServiceProvider.service_provider_sid}/SpeechCredentials/${cred.speech_credential_sid}/test`,
);
}
}, [user, cred, currentServiceProvider]);

Some files were not shown because too many files have changed in this diff Show More